├── .dockerignore ├── .github └── workflows │ └── docker-push.yml ├── .gitignore ├── .vscode ├── launch.json └── tasks.json ├── API ├── API.csproj ├── Controllers │ ├── AccountController.cs │ ├── ActivitiesController.cs │ ├── BaseApiController.cs │ ├── BuggyController.cs │ ├── FallbackController.cs │ ├── FollowController.cs │ ├── PhotosController.cs │ ├── ProfilesController.cs │ └── WeatherForecastController.cs ├── DTOs │ ├── LoginDto.cs │ ├── RegisterDto.cs │ └── UserDto.cs ├── Extensions │ ├── ApplicationServiceExtensions.cs │ ├── HttpExtensions.cs │ └── IdentityServiceExtensions.cs ├── Middleware │ └── ExceptionMiddleware.cs ├── Program.cs ├── Properties │ └── launchSettings.json ├── Services │ └── TokenService.cs ├── SignalR │ └── ChatHub.cs ├── WeatherForecast.cs ├── appsettings.Development.json ├── appsettings.json └── wwwroot │ ├── asset-manifest.json │ ├── assets │ ├── categoryImages │ │ ├── culture.jpg │ │ ├── drinks.jpg │ │ ├── film.jpg │ │ ├── food.jpg │ │ ├── music.jpg │ │ └── travel.jpg │ ├── logo.png │ ├── placeholder.png │ └── user.png │ ├── favicon.ico │ ├── index.html │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ ├── robots.txt │ └── static │ ├── css │ ├── main.adf0a962.css │ └── main.adf0a962.css.map │ ├── js │ ├── 787.627756db.chunk.js │ ├── 787.627756db.chunk.js.map │ ├── main.0e75fd9a.js │ ├── main.0e75fd9a.js.LICENSE.txt │ └── main.0e75fd9a.js.map │ └── media │ ├── brand-icons.278156e41e0ad908cf7f.woff2 │ ├── brand-icons.65a2fb6d9aaa164b41a0.ttf │ ├── brand-icons.6729d29753e000c17489.svg │ ├── brand-icons.cac87dc00c87a5d74711.woff │ ├── brand-icons.d68fa3e67dbb653a13ce.eot │ ├── flags.99f63ae7a743f21ab308.png │ ├── icons.38c6d8bab26db77d8c80.woff2 │ ├── icons.425399f81e4ce7cbd967.woff │ ├── icons.62d9dae4e0040e81c980.svg │ ├── icons.a01e3f2d6c83dc3aee17.eot │ ├── icons.c656b8caa454ed19b9a2.ttf │ ├── outline-icons.5367103510b27b784827.ttf │ ├── outline-icons.687a4990ea22bb1a49d4.woff2 │ ├── outline-icons.752905fa5edf21fc52a1.eot │ ├── outline-icons.9c4845b4b41ef40a22fa.svg │ └── outline-icons.ddae9b1ba9b0b42f5880.woff ├── Application ├── Activities │ ├── ActivityDto.cs │ ├── ActivityParams.cs │ ├── ActivityValidator.cs │ ├── AttendeeDto.cs │ ├── Create.cs │ ├── Delete.cs │ ├── Details.cs │ ├── Edit.cs │ ├── List.cs │ └── UpdateAttendance.cs ├── Application.csproj ├── Comments │ ├── CommentDto.cs │ ├── Create.cs │ └── List.cs ├── Core │ ├── AppException.cs │ ├── MappingProfiles.cs │ ├── PagedList.cs │ ├── PagingParams.cs │ └── Result.cs ├── Followers │ ├── FollowToggle.cs │ └── List.cs ├── Interfaces │ ├── IPhotoAccessor.cs │ └── IUserAccessor.cs ├── Photos │ ├── Add.cs │ ├── Delete.cs │ ├── PhotoUploadResult.cs │ └── SetMain.cs └── Profiles │ ├── Details.cs │ ├── Edit.cs │ ├── ListActivities.cs │ ├── Profile.cs │ └── UserActivityDto.cs ├── Dockerfile ├── Domain ├── Activity.cs ├── ActivityAttendee.cs ├── AppUser.cs ├── Comment.cs ├── Domain.csproj ├── Photo.cs └── UserFollowing.cs ├── Infrastructure ├── Infrastructure.csproj ├── Photos │ ├── CloudinarySettings.cs │ └── PhotoAccessor.cs └── Security │ ├── IsHostRequirement.cs │ └── UserAccessor.cs ├── Persistence ├── DataContext.cs ├── Migrations │ ├── 20221204055302_PostgresInitial.Designer.cs │ ├── 20221204055302_PostgresInitial.cs │ └── DataContextModelSnapshot.cs ├── Persistence.csproj └── Seed.cs ├── README.md ├── Reactivities.sln ├── client-app ├── .env.development ├── .env.production ├── .gitignore ├── README.md ├── package-lock.json ├── package.json ├── public │ ├── assets │ │ ├── categoryImages │ │ │ ├── culture.jpg │ │ │ ├── drinks.jpg │ │ │ ├── film.jpg │ │ │ ├── food.jpg │ │ │ ├── music.jpg │ │ │ └── travel.jpg │ │ ├── logo.png │ │ ├── placeholder.png │ │ └── user.png │ ├── favicon.ico │ ├── index.html │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── robots.txt ├── src │ ├── app │ │ ├── api │ │ │ └── agent.ts │ │ ├── common │ │ │ ├── form │ │ │ │ ├── MyDateInput.tsx │ │ │ │ ├── MySelectInput.tsx │ │ │ │ ├── MyTextArea.tsx │ │ │ │ └── MyTextInput.tsx │ │ │ ├── imageUpload │ │ │ │ ├── PhotoUploadWidget.tsx │ │ │ │ ├── PhotoWidgetCropper.tsx │ │ │ │ └── PhotoWidgetDropzone.tsx │ │ │ ├── modals │ │ │ │ └── ModalContainer.tsx │ │ │ └── options │ │ │ │ └── categoryOptions.ts │ │ ├── layout │ │ │ ├── App.tsx │ │ │ ├── LoadingComponent.tsx │ │ │ ├── NavBar.tsx │ │ │ └── styles.css │ │ ├── models │ │ │ ├── activity.ts │ │ │ ├── comment.ts │ │ │ ├── pagination.ts │ │ │ ├── profile.ts │ │ │ ├── serverError.ts │ │ │ └── user.ts │ │ ├── router │ │ │ ├── RequireAuth.tsx │ │ │ └── Routes.tsx │ │ └── stores │ │ │ ├── activityStore.ts │ │ │ ├── commentStore.ts │ │ │ ├── commonStore.ts │ │ │ ├── modalStore.ts │ │ │ ├── profileStore.ts │ │ │ ├── store.ts │ │ │ └── userStore.ts │ ├── features │ │ ├── activities │ │ │ ├── dashboard │ │ │ │ ├── ActivityDashboard.tsx │ │ │ │ ├── ActivityFilters.tsx │ │ │ │ ├── ActivityList.tsx │ │ │ │ ├── ActivityListItem.tsx │ │ │ │ ├── ActivityListItemAttendee.tsx │ │ │ │ └── ActivityListItemPlaceHolder.tsx │ │ │ ├── details │ │ │ │ ├── ActivityDetailedChat.tsx │ │ │ │ ├── ActivityDetailedHeader.tsx │ │ │ │ ├── ActivityDetailedInfo.tsx │ │ │ │ ├── ActivityDetailedSidebar.tsx │ │ │ │ └── ActivityDetails.tsx │ │ │ └── form │ │ │ │ └── ActivityForm.tsx │ │ ├── errors │ │ │ ├── NotFound.tsx │ │ │ ├── ServerError.tsx │ │ │ ├── TestError.tsx │ │ │ └── ValidationError.tsx │ │ ├── home │ │ │ └── HomePage.tsx │ │ ├── profiles │ │ │ ├── FollowButton.tsx │ │ │ ├── ProfileAbout.tsx │ │ │ ├── ProfileActivities.tsx │ │ │ ├── ProfileCard.tsx │ │ │ ├── ProfileContent.tsx │ │ │ ├── ProfileEditForm.tsx │ │ │ ├── ProfileFollowings.tsx │ │ │ ├── ProfileHeader.tsx │ │ │ ├── ProfilePage.tsx │ │ │ └── ProfilePhotos.tsx │ │ └── users │ │ │ ├── LoginForm.tsx │ │ │ └── RegsiterForm.tsx │ ├── index.tsx │ ├── react-app-env.d.ts │ ├── reportWebVitals.ts │ └── setupTests.ts └── tsconfig.json └── fly.toml /.dockerignore: -------------------------------------------------------------------------------- 1 | **/bin 2 | **/obj -------------------------------------------------------------------------------- /.github/workflows/docker-push.yml: -------------------------------------------------------------------------------- 1 | name: docker-push 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - 'main' 8 | env: 9 | FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} 10 | jobs: 11 | docker: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - 15 | name: Set up Docker Buildx 16 | uses: docker/setup-buildx-action@v2 17 | - 18 | name: Login to Docker Hub 19 | uses: docker/login-action@v2 20 | with: 21 | username: ${{ secrets.DOCKERHUB_USERNAME }} 22 | password: ${{ secrets.DOCKERHUB_TOKEN }} 23 | - 24 | name: Build and push 25 | uses: docker/build-push-action@v3 26 | with: 27 | push: true 28 | tags: trycatchlearn/reactivities:latest 29 | deploy: 30 | needs: docker 31 | name: Deploy app 32 | runs-on: ubuntu-latest 33 | steps: 34 | - uses: actions/checkout@v3 35 | - uses: superfly/flyctl-actions/setup-flyctl@master 36 | - run: flyctl deploy --remote-only -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | // Use IntelliSense to find out which attributes exist for C# debugging 6 | // Use hover for the description of the existing attributes 7 | // For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md 8 | "name": ".NET Core Launch (web)", 9 | "type": "coreclr", 10 | "request": "launch", 11 | "preLaunchTask": "build", 12 | // If you have changed target frameworks, make sure to update the program path. 13 | "program": "${workspaceFolder}/API/bin/Debug/net7.0/API.dll", 14 | "args": [], 15 | "cwd": "${workspaceFolder}/API", 16 | "stopAtEntry": false, 17 | // Enable launching a web browser when ASP.NET Core starts. For more information: https://aka.ms/VSCode-CS-LaunchJson-WebBrowser 18 | "serverReadyAction": { 19 | "action": "openExternally", 20 | "pattern": "\\bNow listening on:\\s+(https?://\\S+)" 21 | }, 22 | "env": { 23 | "ASPNETCORE_ENVIRONMENT": "Development" 24 | }, 25 | "sourceFileMap": { 26 | "/Views": "${workspaceFolder}/Views" 27 | } 28 | }, 29 | { 30 | "name": ".NET Core Attach", 31 | "type": "coreclr", 32 | "request": "attach" 33 | } 34 | ] 35 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "build", 6 | "command": "dotnet", 7 | "type": "process", 8 | "args": [ 9 | "build", 10 | "${workspaceFolder}/API/API.csproj", 11 | "/property:GenerateFullPaths=true", 12 | "/consoleloggerparameters:NoSummary" 13 | ], 14 | "problemMatcher": "$msCompile" 15 | }, 16 | { 17 | "label": "publish", 18 | "command": "dotnet", 19 | "type": "process", 20 | "args": [ 21 | "publish", 22 | "${workspaceFolder}/API/API.csproj", 23 | "/property:GenerateFullPaths=true", 24 | "/consoleloggerparameters:NoSummary" 25 | ], 26 | "problemMatcher": "$msCompile" 27 | }, 28 | { 29 | "label": "watch", 30 | "command": "dotnet", 31 | "type": "process", 32 | "args": [ 33 | "watch", 34 | "run", 35 | "--project", 36 | "${workspaceFolder}/API/API.csproj" 37 | ], 38 | "problemMatcher": "$msCompile" 39 | } 40 | ] 41 | } -------------------------------------------------------------------------------- /API/API.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net7.0 5 | disable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | runtime; build; native; contentfiles; analyzers; buildtransitive 14 | all 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /API/Controllers/AccountController.cs: -------------------------------------------------------------------------------- 1 | using System.Security.Claims; 2 | using API.DTOs; 3 | using API.Services; 4 | using Domain; 5 | using Microsoft.AspNetCore.Authorization; 6 | using Microsoft.AspNetCore.Identity; 7 | using Microsoft.AspNetCore.Mvc; 8 | using Microsoft.EntityFrameworkCore; 9 | 10 | namespace API.Controllers 11 | { 12 | [ApiController] 13 | [Route("api/[controller]")] 14 | public class AccountController : ControllerBase 15 | { 16 | private readonly UserManager _userManager; 17 | private readonly TokenService _tokenService; 18 | public AccountController(UserManager userManager, TokenService tokenService) 19 | { 20 | _tokenService = tokenService; 21 | _userManager = userManager; 22 | } 23 | 24 | [AllowAnonymous] 25 | [HttpPost("login")] 26 | public async Task> Login(LoginDto loginDto) 27 | { 28 | var user = await _userManager.Users.Include(p => p.Photos) 29 | .FirstOrDefaultAsync(x => x.Email == loginDto.Email); 30 | 31 | if (user == null) return Unauthorized(); 32 | 33 | var result = await _userManager.CheckPasswordAsync(user, loginDto.Password); 34 | 35 | if (result) 36 | { 37 | return CreateUserObject(user); 38 | } 39 | 40 | return Unauthorized(); 41 | } 42 | 43 | [AllowAnonymous] 44 | [HttpPost("register")] 45 | public async Task> Register(RegisterDto registerDto) 46 | { 47 | if (await _userManager.Users.AnyAsync(x => x.UserName == registerDto.Username)) 48 | { 49 | ModelState.AddModelError("username", "Username taken"); 50 | return ValidationProblem(); 51 | } 52 | 53 | if (await _userManager.Users.AnyAsync(x => x.Email == registerDto.Email)) 54 | { 55 | ModelState.AddModelError("email", "Email taken"); 56 | return ValidationProblem(); 57 | } 58 | 59 | var user = new AppUser 60 | { 61 | DisplayName = registerDto.DisplayName, 62 | Email = registerDto.Email, 63 | UserName = registerDto.Username 64 | }; 65 | 66 | var result = await _userManager.CreateAsync(user, registerDto.Password); 67 | 68 | if (result.Succeeded) 69 | { 70 | return CreateUserObject(user); 71 | } 72 | 73 | return BadRequest(result.Errors); 74 | } 75 | 76 | [Authorize] 77 | [HttpGet] 78 | public async Task> GetCurrentUser() 79 | { 80 | var user = await _userManager.Users.Include(p => p.Photos) 81 | .FirstOrDefaultAsync(x => x.Email == User.FindFirstValue(ClaimTypes.Email)); 82 | 83 | return CreateUserObject(user); 84 | } 85 | 86 | private UserDto CreateUserObject(AppUser user) 87 | { 88 | return new UserDto 89 | { 90 | DisplayName = user.DisplayName, 91 | Image = user?.Photos?.FirstOrDefault(x => x.IsMain)?.Url, 92 | Token = _tokenService.CreateToken(user), 93 | Username = user.UserName 94 | }; 95 | } 96 | } 97 | } -------------------------------------------------------------------------------- /API/Controllers/ActivitiesController.cs: -------------------------------------------------------------------------------- 1 | using Application.Activities; 2 | using Application.Core; 3 | using Domain; 4 | using Microsoft.AspNetCore.Authorization; 5 | using Microsoft.AspNetCore.Mvc; 6 | 7 | namespace API.Controllers 8 | { 9 | public class ActivitiesController : BaseApiController 10 | { 11 | [HttpGet] 12 | public async Task GetActivities([FromQuery] ActivityParams param) 13 | { 14 | return HandlePagedResult(await Mediator.Send(new List.Query { Params = param })); 15 | } 16 | 17 | [HttpGet("{id}")] 18 | public async Task GetActivity(Guid id) 19 | { 20 | return HandleResult(await Mediator.Send(new Details.Query { Id = id })); 21 | } 22 | 23 | [HttpPost] 24 | public async Task CreateActivity(Activity activity) 25 | { 26 | return HandleResult(await Mediator.Send(new Create.Command { Activity = activity })); 27 | } 28 | 29 | [Authorize(Policy = "IsActivityHost")] 30 | [HttpPut("{id}")] 31 | public async Task Edit(Guid id, Activity activity) 32 | { 33 | activity.Id = id; 34 | return HandleResult(await Mediator.Send(new Edit.Command { Activity = activity })); 35 | } 36 | 37 | [Authorize(Policy = "IsActivityHost")] 38 | [HttpDelete("{id}")] 39 | public async Task Delete(Guid id) 40 | { 41 | return HandleResult(await Mediator.Send(new Delete.Command { Id = id })); 42 | } 43 | 44 | [HttpPost("{id}/attend")] 45 | public async Task Attend(Guid id) 46 | { 47 | return HandleResult(await Mediator.Send(new UpdateAttendance.Command { Id = id })); 48 | } 49 | } 50 | } -------------------------------------------------------------------------------- /API/Controllers/BaseApiController.cs: -------------------------------------------------------------------------------- 1 | using API.Extensions; 2 | using Application.Core; 3 | using MediatR; 4 | using Microsoft.AspNetCore.Mvc; 5 | 6 | namespace API.Controllers 7 | { 8 | [ApiController] 9 | [Route("api/[controller]")] 10 | public class BaseApiController : ControllerBase 11 | { 12 | private IMediator _mediator; 13 | 14 | protected IMediator Mediator => _mediator ??= 15 | HttpContext.RequestServices.GetService(); 16 | 17 | protected ActionResult HandleResult(Result result) 18 | { 19 | if (result == null) return NotFound(); 20 | 21 | if (result.IsSuccess && result.Value != null) 22 | return Ok(result.Value); 23 | 24 | if (result.IsSuccess && result.Value == null) 25 | return NotFound(); 26 | 27 | return BadRequest(result.Error); 28 | } 29 | 30 | protected ActionResult HandlePagedResult(Result> result) 31 | { 32 | if (result == null) return NotFound(); 33 | if (result.IsSuccess && result.Value != null) 34 | { 35 | Response.AddPaginationHeader(result.Value.CurrentPage, result.Value.PageSize, 36 | result.Value.TotalCount, result.Value.TotalPages); 37 | return Ok(result.Value); 38 | } 39 | 40 | if (result.IsSuccess && result.Value == null) 41 | return NotFound(); 42 | return BadRequest(result.Error); 43 | } 44 | } 45 | } -------------------------------------------------------------------------------- /API/Controllers/BuggyController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | 3 | namespace API.Controllers 4 | { 5 | public class BuggyController : BaseApiController 6 | { 7 | [HttpGet("not-found")] 8 | public ActionResult GetNotFound() 9 | { 10 | return NotFound(); 11 | } 12 | 13 | [HttpGet("bad-request")] 14 | public ActionResult GetBadRequest() 15 | { 16 | return BadRequest("This is a bad request"); 17 | } 18 | 19 | [HttpGet("server-error")] 20 | public ActionResult GetServerError() 21 | { 22 | throw new Exception("This is a server error"); 23 | } 24 | 25 | [HttpGet("unauthorised")] 26 | public ActionResult GetUnauthorised() 27 | { 28 | return Unauthorized(); 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /API/Controllers/FallbackController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Authorization; 2 | using Microsoft.AspNetCore.Mvc; 3 | 4 | namespace API.Controllers 5 | { 6 | [AllowAnonymous] 7 | public class FallbackController : Controller 8 | { 9 | public IActionResult Index() 10 | { 11 | return PhysicalFile(Path.Combine(Directory.GetCurrentDirectory(), 12 | "wwwroot", "index.html"), "text/HTML"); 13 | } 14 | } 15 | } -------------------------------------------------------------------------------- /API/Controllers/FollowController.cs: -------------------------------------------------------------------------------- 1 | using Application.Followers; 2 | using Microsoft.AspNetCore.Mvc; 3 | 4 | namespace API.Controllers 5 | { 6 | public class FollowController : BaseApiController 7 | { 8 | [HttpPost("{username}")] 9 | public async Task Follow(string username) 10 | { 11 | return HandleResult(await Mediator.Send(new FollowToggle.Command 12 | { TargetUsername = username })); 13 | } 14 | 15 | [HttpGet("{username}")] 16 | public async Task GetFollowings(string username, string predicate) 17 | { 18 | return HandleResult(await Mediator.Send(new List.Query { Username = username, Predicate = predicate })); 19 | } 20 | } 21 | } 22 | 23 | -------------------------------------------------------------------------------- /API/Controllers/PhotosController.cs: -------------------------------------------------------------------------------- 1 | using Application.Photos; 2 | using Microsoft.AspNetCore.Mvc; 3 | 4 | namespace API.Controllers 5 | { 6 | public class PhotosController : BaseApiController 7 | { 8 | public async Task Add([FromForm] Add.Command command) 9 | { 10 | return HandleResult(await Mediator.Send(command)); 11 | } 12 | 13 | [HttpDelete("{id}")] 14 | public async Task Delete(string id) 15 | { 16 | return HandleResult(await Mediator.Send(new Delete.Command { Id = id })); 17 | } 18 | 19 | [HttpPost("{id}/setMain")] 20 | public async Task SetMain(string id) 21 | { 22 | return HandleResult(await Mediator.Send(new SetMain.Command { Id = id })); 23 | } 24 | } 25 | } -------------------------------------------------------------------------------- /API/Controllers/ProfilesController.cs: -------------------------------------------------------------------------------- 1 | using Application.Profiles; 2 | using Microsoft.AspNetCore.Mvc; 3 | 4 | namespace API.Controllers 5 | { 6 | public class ProfilesController : BaseApiController 7 | { 8 | [HttpGet("{username}")] 9 | public async Task GetProfile(string username) 10 | { 11 | return HandleResult(await Mediator.Send(new Details.Query { Username = username })); 12 | } 13 | 14 | [HttpPut] 15 | public async Task Edit(Edit.Command command) 16 | { 17 | return HandleResult(await Mediator.Send(command)); 18 | } 19 | 20 | [HttpGet("{username}/activities")] 21 | public async Task GetUserActivities(string username, 22 | string predicate) 23 | { 24 | return HandleResult(await Mediator.Send(new ListActivities.Query 25 | { Username = username, Predicate = predicate })); 26 | } 27 | } 28 | } -------------------------------------------------------------------------------- /API/Controllers/WeatherForecastController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | 3 | namespace API.Controllers; 4 | 5 | [ApiController] 6 | [Route("[controller]")] // localhost:5000/weatherforecast 7 | public class WeatherForecastController : ControllerBase 8 | { 9 | private static readonly string[] Summaries = new[] 10 | { 11 | "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" 12 | }; 13 | 14 | private readonly ILogger _logger; 15 | 16 | public WeatherForecastController(ILogger logger) 17 | { 18 | _logger = logger; 19 | } 20 | 21 | [HttpGet(Name = "GetWeatherForecast")] 22 | public IEnumerable Get() 23 | { 24 | return Enumerable.Range(1, 5).Select(index => new WeatherForecast 25 | { 26 | Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)), 27 | TemperatureC = Random.Shared.Next(-20, 55), 28 | Summary = Summaries[Random.Shared.Next(Summaries.Length)] 29 | }) 30 | .ToArray(); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /API/DTOs/LoginDto.cs: -------------------------------------------------------------------------------- 1 | namespace API.DTOs 2 | { 3 | public class LoginDto 4 | { 5 | public string Email { get; set; } 6 | public string Password { get; set; } 7 | } 8 | } -------------------------------------------------------------------------------- /API/DTOs/RegisterDto.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace API.DTOs 4 | { 5 | public class RegisterDto 6 | { 7 | [Required] 8 | [EmailAddress] 9 | public string Email { get; set; } 10 | 11 | [Required] 12 | [RegularExpression("(?=.*\\d)(?=.*[a-z])(?=.*[A-Z]).{4,8}$", ErrorMessage = "Password must be complex")] 13 | public string Password { get; set; } 14 | 15 | [Required] 16 | public string DisplayName { get; set; } 17 | 18 | [Required] 19 | public string Username { get; set; } 20 | } 21 | } -------------------------------------------------------------------------------- /API/DTOs/UserDto.cs: -------------------------------------------------------------------------------- 1 | namespace API.DTOs 2 | { 3 | public class UserDto 4 | { 5 | public string DisplayName { get; set; } 6 | public string Token { get; set; } 7 | public string Image { get; set; } 8 | public string Username { get; set; } 9 | } 10 | } -------------------------------------------------------------------------------- /API/Extensions/ApplicationServiceExtensions.cs: -------------------------------------------------------------------------------- 1 | using Application.Activities; 2 | using Application.Core; 3 | using Application.Interfaces; 4 | using FluentValidation; 5 | using FluentValidation.AspNetCore; 6 | using Infrastructure.Photos; 7 | using Infrastructure.Security; 8 | using MediatR; 9 | using Microsoft.EntityFrameworkCore; 10 | using Persistence; 11 | 12 | namespace API.Extensions 13 | { 14 | public static class ApplicationServiceExtensions 15 | { 16 | public static IServiceCollection AddApplicationServices(this IServiceCollection services, 17 | IConfiguration config) 18 | { 19 | // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle 20 | services.AddEndpointsApiExplorer(); 21 | services.AddSwaggerGen(); 22 | services.AddDbContext(options => 23 | { 24 | var env = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); 25 | 26 | string connStr; 27 | 28 | // Depending on if in development or production, use either FlyIO 29 | // connection string, or development connection string from env var. 30 | if (env == "Development") 31 | { 32 | // Use connection string from file. 33 | connStr = config.GetConnectionString("DefaultConnection"); 34 | } 35 | else 36 | { 37 | // Use connection string provided at runtime by Flyio. 38 | var connUrl = Environment.GetEnvironmentVariable("DATABASE_URL"); 39 | 40 | // Parse connection URL to connection string for Npgsql 41 | connUrl = connUrl.Replace("postgres://", string.Empty); 42 | var pgUserPass = connUrl.Split("@")[0]; 43 | var pgHostPortDb = connUrl.Split("@")[1]; 44 | var pgHostPort = pgHostPortDb.Split("/")[0]; 45 | var pgDb = pgHostPortDb.Split("/")[1]; 46 | var pgUser = pgUserPass.Split(":")[0]; 47 | var pgPass = pgUserPass.Split(":")[1]; 48 | var pgHost = pgHostPort.Split(":")[0]; 49 | var pgPort = pgHostPort.Split(":")[1]; 50 | 51 | connStr = $"Server={pgHost};Port={pgPort};User Id={pgUser};Password={pgPass};Database={pgDb};"; 52 | } 53 | 54 | // Whether the connection string came from the local development configuration file 55 | // or from the environment variable from FlyIO, use it to set up your DbContext. 56 | options.UseNpgsql(connStr); 57 | }); 58 | services.AddCors(opt => 59 | { 60 | opt.AddPolicy("CorsPolicy", policy => 61 | { 62 | policy 63 | .AllowAnyMethod() 64 | .AllowAnyHeader() 65 | .AllowCredentials() 66 | .WithOrigins("http://localhost:3000"); 67 | }); 68 | }); 69 | services.AddMediatR(typeof(List.Handler)); 70 | services.AddAutoMapper(typeof(MappingProfiles).Assembly); 71 | services.AddFluentValidationAutoValidation(); 72 | services.AddValidatorsFromAssemblyContaining(); 73 | services.AddHttpContextAccessor(); 74 | services.AddScoped(); 75 | services.AddScoped(); 76 | services.Configure(config.GetSection("Cloudinary")); 77 | services.AddSignalR(); 78 | 79 | return services; 80 | } 81 | } 82 | } -------------------------------------------------------------------------------- /API/Extensions/HttpExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | 3 | namespace API.Extensions 4 | { 5 | public static class HttpExtensions 6 | { 7 | public static void AddPaginationHeader(this HttpResponse response, int currentPage, 8 | int itemsPerPage, int totalItems, int totalPages) 9 | { 10 | var paginationHeader = new 11 | { 12 | currentPage, 13 | itemsPerPage, 14 | totalItems, 15 | totalPages 16 | }; 17 | response.Headers.Add("Pagination", JsonSerializer.Serialize(paginationHeader)); 18 | response.Headers.Add("Access-Control-Expose-Headers", "Pagination"); 19 | } 20 | } 21 | } 22 | 23 | -------------------------------------------------------------------------------- /API/Extensions/IdentityServiceExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Text; 2 | using API.Services; 3 | using Domain; 4 | using Infrastructure.Security; 5 | using Microsoft.AspNetCore.Authentication.JwtBearer; 6 | using Microsoft.AspNetCore.Authorization; 7 | using Microsoft.IdentityModel.Tokens; 8 | using Persistence; 9 | 10 | namespace API.Extensions 11 | { 12 | public static class IdentityServiceExtensions 13 | { 14 | public static IServiceCollection AddIdentityServices(this IServiceCollection services, 15 | IConfiguration config) 16 | { 17 | services.AddIdentityCore(opt => 18 | { 19 | opt.Password.RequireNonAlphanumeric = false; 20 | opt.User.RequireUniqueEmail = true; 21 | }) 22 | .AddEntityFrameworkStores(); 23 | 24 | var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["TokenKey"])); 25 | 26 | services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) 27 | .AddJwtBearer(opt => 28 | { 29 | opt.TokenValidationParameters = new TokenValidationParameters 30 | { 31 | ValidateIssuerSigningKey = true, 32 | IssuerSigningKey = key, 33 | ValidateIssuer = false, 34 | ValidateAudience = false 35 | }; 36 | opt.Events = new JwtBearerEvents 37 | { 38 | OnMessageReceived = context => 39 | { 40 | var accessToken = context.Request.Query["access_token"]; 41 | var path = context.HttpContext.Request.Path; 42 | if (!string.IsNullOrEmpty(accessToken) && (path.StartsWithSegments("/chat"))) 43 | { 44 | context.Token = accessToken; 45 | } 46 | return Task.CompletedTask; 47 | } 48 | }; 49 | }); 50 | 51 | services.AddAuthorization(opt => 52 | { 53 | opt.AddPolicy("IsActivityHost", policy => 54 | { 55 | policy.Requirements.Add(new IsHostRequirement()); 56 | }); 57 | }); 58 | 59 | services.AddTransient(); 60 | services.AddScoped(); 61 | 62 | return services; 63 | } 64 | } 65 | } -------------------------------------------------------------------------------- /API/Middleware/ExceptionMiddleware.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using System.Text.Json; 3 | using Application.Core; 4 | 5 | namespace API.Middleware 6 | { 7 | public class ExceptionMiddleware 8 | { 9 | private readonly RequestDelegate _next; 10 | private readonly ILogger _logger; 11 | private readonly IHostEnvironment _env; 12 | public ExceptionMiddleware(RequestDelegate next, ILogger logger, 13 | IHostEnvironment env) 14 | { 15 | _env = env; 16 | _logger = logger; 17 | _next = next; 18 | } 19 | 20 | public async Task InvokeAsync(HttpContext context) 21 | { 22 | try 23 | { 24 | await _next(context); 25 | } 26 | catch (Exception ex) 27 | { 28 | _logger.LogError(ex, ex.Message); 29 | context.Response.ContentType = "application/json"; 30 | context.Response.StatusCode = (int)HttpStatusCode.InternalServerError; 31 | 32 | var response = _env.IsDevelopment() 33 | ? new AppException(context.Response.StatusCode, ex.Message, ex.StackTrace?.ToString()) 34 | : new AppException(context.Response.StatusCode, "Internal Server Error"); 35 | 36 | var options = new JsonSerializerOptions{PropertyNamingPolicy = JsonNamingPolicy.CamelCase}; 37 | 38 | var json = JsonSerializer.Serialize(response, options); 39 | 40 | await context.Response.WriteAsync(json); 41 | } 42 | } 43 | } 44 | } -------------------------------------------------------------------------------- /API/Program.cs: -------------------------------------------------------------------------------- 1 | using API.Extensions; 2 | using API.Middleware; 3 | using API.SignalR; 4 | using Domain; 5 | using Microsoft.AspNetCore.Authorization; 6 | using Microsoft.AspNetCore.Identity; 7 | using Microsoft.AspNetCore.Mvc.Authorization; 8 | using Microsoft.EntityFrameworkCore; 9 | using Persistence; 10 | 11 | var builder = WebApplication.CreateBuilder(args); 12 | 13 | // Add services to the container. 14 | 15 | builder.Services.AddControllers(opt => 16 | { 17 | var policy = new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build(); 18 | opt.Filters.Add(new AuthorizeFilter(policy)); 19 | }); 20 | builder.Services.AddApplicationServices(builder.Configuration); 21 | builder.Services.AddIdentityServices(builder.Configuration); 22 | 23 | var app = builder.Build(); 24 | 25 | // Configure the HTTP request pipeline. 26 | app.UseMiddleware(); 27 | 28 | app.UseXContentTypeOptions(); 29 | app.UseReferrerPolicy(opt => opt.NoReferrer()); 30 | app.UseXXssProtection(opt => opt.EnabledWithBlockMode()); 31 | app.UseXfo(opt => opt.Deny()); 32 | app.UseCsp(opt => opt 33 | .BlockAllMixedContent() 34 | .StyleSources(s => s.Self().CustomSources("https://fonts.googleapis.com")) 35 | .FontSources(s => s.Self().CustomSources("https://fonts.gstatic.com", "data:")) 36 | .FormActions(s => s.Self()) 37 | .FrameAncestors(s => s.Self()) 38 | .ImageSources(s => s.Self().CustomSources("blob:", "https://res.cloudinary.com", "https://platform-lookaside.fbsbx.com")) 39 | .ScriptSources(s => s.Self()) 40 | ); 41 | 42 | if (app.Environment.IsDevelopment()) 43 | { 44 | app.UseSwagger(); 45 | app.UseSwaggerUI(); 46 | } 47 | else 48 | { 49 | app.Use(async (context, next) => 50 | { 51 | context.Response.Headers.Add("Strict-Transport-Security", "max-age=31536000"); 52 | await next.Invoke(); 53 | }); 54 | } 55 | 56 | app.UseCors("CorsPolicy"); 57 | 58 | app.UseAuthentication(); 59 | app.UseAuthorization(); 60 | 61 | app.UseDefaultFiles(); 62 | app.UseStaticFiles(); 63 | 64 | app.MapControllers(); 65 | app.MapHub("/chat"); 66 | app.MapFallbackToController("Index", "Fallback"); 67 | 68 | using var scope = app.Services.CreateScope(); 69 | var services = scope.ServiceProvider; 70 | 71 | try 72 | { 73 | var context = services.GetRequiredService(); 74 | var userManager = services.GetRequiredService>(); 75 | await context.Database.MigrateAsync(); 76 | await Seed.SeedData(context, userManager); 77 | } 78 | catch (Exception ex) 79 | { 80 | var logger = services.GetRequiredService>(); 81 | logger.LogError(ex, "An error occured during migration"); 82 | } 83 | 84 | app.Run(); 85 | -------------------------------------------------------------------------------- /API/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/launchsettings.json", 3 | "profiles": { 4 | "http": { 5 | "commandName": "Project", 6 | "dotnetRunMessages": true, 7 | "launchBrowser": false, 8 | "applicationUrl": "http://localhost:5000", 9 | "environmentVariables": { 10 | "ASPNETCORE_ENVIRONMENT": "Development" 11 | } 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /API/Services/TokenService.cs: -------------------------------------------------------------------------------- 1 | using System.IdentityModel.Tokens.Jwt; 2 | using System.Security.Claims; 3 | using System.Text; 4 | using Domain; 5 | using Microsoft.IdentityModel.Tokens; 6 | 7 | namespace API.Services 8 | { 9 | public class TokenService 10 | { 11 | private readonly IConfiguration _config; 12 | public TokenService(IConfiguration config) 13 | { 14 | _config = config; 15 | } 16 | public string CreateToken(AppUser user) 17 | { 18 | var claims = new List 19 | { 20 | new Claim(ClaimTypes.Name, user.UserName), 21 | new Claim(ClaimTypes.NameIdentifier, user.Id), 22 | new Claim(ClaimTypes.Email, user.Email), 23 | }; 24 | 25 | var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_config["TokenKey"])); 26 | var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha512Signature); 27 | 28 | var tokenDescriptor = new SecurityTokenDescriptor 29 | { 30 | Subject = new ClaimsIdentity(claims), 31 | Expires = DateTime.UtcNow.AddDays(7), 32 | SigningCredentials = creds 33 | }; 34 | 35 | var tokenHandler = new JwtSecurityTokenHandler(); 36 | 37 | var token = tokenHandler.CreateToken(tokenDescriptor); 38 | 39 | return tokenHandler.WriteToken(token); 40 | } 41 | } 42 | } -------------------------------------------------------------------------------- /API/SignalR/ChatHub.cs: -------------------------------------------------------------------------------- 1 | using Application.Comments; 2 | using MediatR; 3 | using Microsoft.AspNetCore.SignalR; 4 | 5 | namespace API.SignalR 6 | { 7 | public class ChatHub : Hub 8 | { 9 | private readonly IMediator _mediator; 10 | 11 | public ChatHub(IMediator mediator) 12 | { 13 | _mediator = mediator; 14 | } 15 | 16 | public async Task SendComment(Create.Command command) 17 | { 18 | var comment = await _mediator.Send(command); 19 | 20 | await Clients.Group(command.ActivityId.ToString()) 21 | .SendAsync("ReceiveComment", comment.Value); 22 | } 23 | 24 | public override async Task OnConnectedAsync() 25 | { 26 | var httpContext = Context.GetHttpContext(); 27 | var activityId = httpContext.Request.Query["activityId"]; 28 | await Groups.AddToGroupAsync(Context.ConnectionId, activityId); 29 | var result = await _mediator.Send(new List.Query{ActivityId = Guid.Parse(activityId)}); 30 | await Clients.Caller.SendAsync("LoadComments", result.Value); 31 | } 32 | } 33 | } 34 | 35 | -------------------------------------------------------------------------------- /API/WeatherForecast.cs: -------------------------------------------------------------------------------- 1 | namespace API; 2 | 3 | public class WeatherForecast 4 | { 5 | public DateOnly Date { get; set; } 6 | 7 | public int TemperatureC { get; set; } 8 | 9 | public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); 10 | 11 | public string Summary { get; set; } 12 | } 13 | -------------------------------------------------------------------------------- /API/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Information" 6 | } 7 | }, 8 | "ConnectionStrings": { 9 | "DefaultConnection": "Server=localhost; Port=5432; User Id=admin; Password=secret; Database=reactivities" 10 | }, 11 | "TokenKey": "super secret key" 12 | } 13 | -------------------------------------------------------------------------------- /API/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | }, 8 | "Cloudinary": { 9 | "CloudName": "***REPLACEME***", 10 | "ApiKey": "***REPLACEME***", 11 | "ApiSecret": "***REPLACEME***" 12 | }, 13 | "AllowedHosts": "*" 14 | } 15 | -------------------------------------------------------------------------------- /API/wwwroot/asset-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": { 3 | "main.css": "/static/css/main.adf0a962.css", 4 | "main.js": "/static/js/main.0e75fd9a.js", 5 | "static/js/787.627756db.chunk.js": "/static/js/787.627756db.chunk.js", 6 | "static/media/brand-icons.svg": "/static/media/brand-icons.6729d29753e000c17489.svg", 7 | "static/media/icons.svg": "/static/media/icons.62d9dae4e0040e81c980.svg", 8 | "static/media/outline-icons.svg": "/static/media/outline-icons.9c4845b4b41ef40a22fa.svg", 9 | "static/media/icons.eot": "/static/media/icons.a01e3f2d6c83dc3aee17.eot", 10 | "static/media/icons.ttf": "/static/media/icons.c656b8caa454ed19b9a2.ttf", 11 | "static/media/brand-icons.eot": "/static/media/brand-icons.d68fa3e67dbb653a13ce.eot", 12 | "static/media/brand-icons.ttf": "/static/media/brand-icons.65a2fb6d9aaa164b41a0.ttf", 13 | "static/media/brand-icons.woff": "/static/media/brand-icons.cac87dc00c87a5d74711.woff", 14 | "static/media/brand-icons.woff2": "/static/media/brand-icons.278156e41e0ad908cf7f.woff2", 15 | "static/media/icons.woff": "/static/media/icons.425399f81e4ce7cbd967.woff", 16 | "static/media/icons.woff2": "/static/media/icons.38c6d8bab26db77d8c80.woff2", 17 | "static/media/outline-icons.eot": "/static/media/outline-icons.752905fa5edf21fc52a1.eot", 18 | "static/media/outline-icons.ttf": "/static/media/outline-icons.5367103510b27b784827.ttf", 19 | "static/media/flags.png": "/static/media/flags.99f63ae7a743f21ab308.png", 20 | "static/media/outline-icons.woff": "/static/media/outline-icons.ddae9b1ba9b0b42f5880.woff", 21 | "static/media/outline-icons.woff2": "/static/media/outline-icons.687a4990ea22bb1a49d4.woff2", 22 | "index.html": "/index.html", 23 | "main.adf0a962.css.map": "/static/css/main.adf0a962.css.map", 24 | "main.0e75fd9a.js.map": "/static/js/main.0e75fd9a.js.map", 25 | "787.627756db.chunk.js.map": "/static/js/787.627756db.chunk.js.map" 26 | }, 27 | "entrypoints": [ 28 | "static/css/main.adf0a962.css", 29 | "static/js/main.0e75fd9a.js" 30 | ] 31 | } -------------------------------------------------------------------------------- /API/wwwroot/assets/categoryImages/culture.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities-net7react18/59c11a50c5e25103faf9b99c64f875d0d5d53469/API/wwwroot/assets/categoryImages/culture.jpg -------------------------------------------------------------------------------- /API/wwwroot/assets/categoryImages/drinks.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities-net7react18/59c11a50c5e25103faf9b99c64f875d0d5d53469/API/wwwroot/assets/categoryImages/drinks.jpg -------------------------------------------------------------------------------- /API/wwwroot/assets/categoryImages/film.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities-net7react18/59c11a50c5e25103faf9b99c64f875d0d5d53469/API/wwwroot/assets/categoryImages/film.jpg -------------------------------------------------------------------------------- /API/wwwroot/assets/categoryImages/food.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities-net7react18/59c11a50c5e25103faf9b99c64f875d0d5d53469/API/wwwroot/assets/categoryImages/food.jpg -------------------------------------------------------------------------------- /API/wwwroot/assets/categoryImages/music.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities-net7react18/59c11a50c5e25103faf9b99c64f875d0d5d53469/API/wwwroot/assets/categoryImages/music.jpg -------------------------------------------------------------------------------- /API/wwwroot/assets/categoryImages/travel.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities-net7react18/59c11a50c5e25103faf9b99c64f875d0d5d53469/API/wwwroot/assets/categoryImages/travel.jpg -------------------------------------------------------------------------------- /API/wwwroot/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities-net7react18/59c11a50c5e25103faf9b99c64f875d0d5d53469/API/wwwroot/assets/logo.png -------------------------------------------------------------------------------- /API/wwwroot/assets/placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities-net7react18/59c11a50c5e25103faf9b99c64f875d0d5d53469/API/wwwroot/assets/placeholder.png -------------------------------------------------------------------------------- /API/wwwroot/assets/user.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities-net7react18/59c11a50c5e25103faf9b99c64f875d0d5d53469/API/wwwroot/assets/user.png -------------------------------------------------------------------------------- /API/wwwroot/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities-net7react18/59c11a50c5e25103faf9b99c64f875d0d5d53469/API/wwwroot/favicon.ico -------------------------------------------------------------------------------- /API/wwwroot/index.html: -------------------------------------------------------------------------------- 1 | React AppYou need to enable JavaScript to run this app. -------------------------------------------------------------------------------- /API/wwwroot/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities-net7react18/59c11a50c5e25103faf9b99c64f875d0d5d53469/API/wwwroot/logo192.png -------------------------------------------------------------------------------- /API/wwwroot/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities-net7react18/59c11a50c5e25103faf9b99c64f875d0d5d53469/API/wwwroot/logo512.png -------------------------------------------------------------------------------- /API/wwwroot/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /API/wwwroot/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /API/wwwroot/static/js/787.627756db.chunk.js: -------------------------------------------------------------------------------- 1 | "use strict";(self.webpackChunkclient_app=self.webpackChunkclient_app||[]).push([[787],{787:function(e,t,n){n.r(t),n.d(t,{getCLS:function(){return y},getFCP:function(){return g},getFID:function(){return C},getLCP:function(){return P},getTTFB:function(){return D}});var i,r,a,o,u=function(e,t){return{name:e,value:void 0===t?-1:t,delta:0,entries:[],id:"v2-".concat(Date.now(),"-").concat(Math.floor(8999999999999*Math.random())+1e12)}},c=function(e,t){try{if(PerformanceObserver.supportedEntryTypes.includes(e)){if("first-input"===e&&!("PerformanceEventTiming"in self))return;var n=new PerformanceObserver((function(e){return e.getEntries().map(t)}));return n.observe({type:e,buffered:!0}),n}}catch(e){}},f=function(e,t){var n=function n(i){"pagehide"!==i.type&&"hidden"!==document.visibilityState||(e(i),t&&(removeEventListener("visibilitychange",n,!0),removeEventListener("pagehide",n,!0)))};addEventListener("visibilitychange",n,!0),addEventListener("pagehide",n,!0)},s=function(e){addEventListener("pageshow",(function(t){t.persisted&&e(t)}),!0)},m=function(e,t,n){var i;return function(r){t.value>=0&&(r||n)&&(t.delta=t.value-(i||0),(t.delta||void 0===i)&&(i=t.value,e(t)))}},v=-1,p=function(){return"hidden"===document.visibilityState?0:1/0},d=function(){f((function(e){var t=e.timeStamp;v=t}),!0)},l=function(){return v<0&&(v=p(),d(),s((function(){setTimeout((function(){v=p(),d()}),0)}))),{get firstHiddenTime(){return v}}},g=function(e,t){var n,i=l(),r=u("FCP"),a=function(e){"first-contentful-paint"===e.name&&(f&&f.disconnect(),e.startTime-1&&e(t)},r=u("CLS",0),a=0,o=[],v=function(e){if(!e.hadRecentInput){var t=o[0],i=o[o.length-1];a&&e.startTime-i.startTime<1e3&&e.startTime-t.startTime<5e3?(a+=e.value,o.push(e)):(a=e.value,o=[e]),a>r.value&&(r.value=a,r.entries=o,n())}},p=c("layout-shift",v);p&&(n=m(i,r,t),f((function(){p.takeRecords().map(v),n(!0)})),s((function(){a=0,T=-1,r=u("CLS",0),n=m(i,r,t)})))},E={passive:!0,capture:!0},w=new Date,L=function(e,t){i||(i=t,r=e,a=new Date,F(removeEventListener),S())},S=function(){if(r>=0&&r1e12?new Date:performance.now())-e.timeStamp;"pointerdown"==e.type?function(e,t){var n=function(){L(e,t),r()},i=function(){r()},r=function(){removeEventListener("pointerup",n,E),removeEventListener("pointercancel",i,E)};addEventListener("pointerup",n,E),addEventListener("pointercancel",i,E)}(t,e):L(t,e)}},F=function(e){["mousedown","keydown","touchstart","pointerdown"].forEach((function(t){return e(t,b,E)}))},C=function(e,t){var n,a=l(),v=u("FID"),p=function(e){e.startTimeperformance.now())return;n.entries=[t],e(n)}catch(e){}},"complete"===document.readyState?setTimeout(t,0):addEventListener("load",(function(){return setTimeout(t,0)}))}}}]); 2 | //# sourceMappingURL=787.627756db.chunk.js.map -------------------------------------------------------------------------------- /API/wwwroot/static/js/main.0e75fd9a.js.LICENSE.txt: -------------------------------------------------------------------------------- 1 | /*! 2 | Copyright (c) 2018 Jed Watson. 3 | Licensed under the MIT License (MIT), see 4 | http://jedwatson.github.io/classnames 5 | */ 6 | 7 | /*! 8 | Copyright (c) 2015 Jed Watson. 9 | Based on code that is Copyright 2013-2015, Facebook, Inc. 10 | All rights reserved. 11 | */ 12 | 13 | /*! 14 | * Cropper.js v1.5.13 15 | * https://fengyuanchen.github.io/cropperjs 16 | * 17 | * Copyright 2015-present Chen Fengyuan 18 | * Released under the MIT license 19 | * 20 | * Date: 2022-11-20T05:30:46.114Z 21 | */ 22 | 23 | /*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/facebook/regenerator/blob/main/LICENSE */ 24 | 25 | /** 26 | * @license React 27 | * react-dom.production.min.js 28 | * 29 | * Copyright (c) Facebook, Inc. and its affiliates. 30 | * 31 | * This source code is licensed under the MIT license found in the 32 | * LICENSE file in the root directory of this source tree. 33 | */ 34 | 35 | /** 36 | * @license React 37 | * react-jsx-runtime.production.min.js 38 | * 39 | * Copyright (c) Facebook, Inc. and its affiliates. 40 | * 41 | * This source code is licensed under the MIT license found in the 42 | * LICENSE file in the root directory of this source tree. 43 | */ 44 | 45 | /** 46 | * @license React 47 | * react.production.min.js 48 | * 49 | * Copyright (c) Facebook, Inc. and its affiliates. 50 | * 51 | * This source code is licensed under the MIT license found in the 52 | * LICENSE file in the root directory of this source tree. 53 | */ 54 | 55 | /** 56 | * @license React 57 | * scheduler.production.min.js 58 | * 59 | * Copyright (c) Facebook, Inc. and its affiliates. 60 | * 61 | * This source code is licensed under the MIT license found in the 62 | * LICENSE file in the root directory of this source tree. 63 | */ 64 | 65 | /** 66 | * @remix-run/router v1.0.3 67 | * 68 | * Copyright (c) Remix Software Inc. 69 | * 70 | * This source code is licensed under the MIT license found in the 71 | * LICENSE.md file in the root directory of this source tree. 72 | * 73 | * @license MIT 74 | */ 75 | 76 | /** 77 | * React Router DOM v6.4.3 78 | * 79 | * Copyright (c) Remix Software Inc. 80 | * 81 | * This source code is licensed under the MIT license found in the 82 | * LICENSE.md file in the root directory of this source tree. 83 | * 84 | * @license MIT 85 | */ 86 | 87 | /** 88 | * React Router v6.4.3 89 | * 90 | * Copyright (c) Remix Software Inc. 91 | * 92 | * This source code is licensed under the MIT license found in the 93 | * LICENSE.md file in the root directory of this source tree. 94 | * 95 | * @license MIT 96 | */ 97 | 98 | /** @license React v16.13.1 99 | * react-is.production.min.js 100 | * 101 | * Copyright (c) Facebook, Inc. and its affiliates. 102 | * 103 | * This source code is licensed under the MIT license found in the 104 | * LICENSE file in the root directory of this source tree. 105 | */ 106 | -------------------------------------------------------------------------------- /API/wwwroot/static/media/brand-icons.278156e41e0ad908cf7f.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities-net7react18/59c11a50c5e25103faf9b99c64f875d0d5d53469/API/wwwroot/static/media/brand-icons.278156e41e0ad908cf7f.woff2 -------------------------------------------------------------------------------- /API/wwwroot/static/media/brand-icons.65a2fb6d9aaa164b41a0.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities-net7react18/59c11a50c5e25103faf9b99c64f875d0d5d53469/API/wwwroot/static/media/brand-icons.65a2fb6d9aaa164b41a0.ttf -------------------------------------------------------------------------------- /API/wwwroot/static/media/brand-icons.cac87dc00c87a5d74711.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities-net7react18/59c11a50c5e25103faf9b99c64f875d0d5d53469/API/wwwroot/static/media/brand-icons.cac87dc00c87a5d74711.woff -------------------------------------------------------------------------------- /API/wwwroot/static/media/brand-icons.d68fa3e67dbb653a13ce.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities-net7react18/59c11a50c5e25103faf9b99c64f875d0d5d53469/API/wwwroot/static/media/brand-icons.d68fa3e67dbb653a13ce.eot -------------------------------------------------------------------------------- /API/wwwroot/static/media/flags.99f63ae7a743f21ab308.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities-net7react18/59c11a50c5e25103faf9b99c64f875d0d5d53469/API/wwwroot/static/media/flags.99f63ae7a743f21ab308.png -------------------------------------------------------------------------------- /API/wwwroot/static/media/icons.38c6d8bab26db77d8c80.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities-net7react18/59c11a50c5e25103faf9b99c64f875d0d5d53469/API/wwwroot/static/media/icons.38c6d8bab26db77d8c80.woff2 -------------------------------------------------------------------------------- /API/wwwroot/static/media/icons.425399f81e4ce7cbd967.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities-net7react18/59c11a50c5e25103faf9b99c64f875d0d5d53469/API/wwwroot/static/media/icons.425399f81e4ce7cbd967.woff -------------------------------------------------------------------------------- /API/wwwroot/static/media/icons.a01e3f2d6c83dc3aee17.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities-net7react18/59c11a50c5e25103faf9b99c64f875d0d5d53469/API/wwwroot/static/media/icons.a01e3f2d6c83dc3aee17.eot -------------------------------------------------------------------------------- /API/wwwroot/static/media/icons.c656b8caa454ed19b9a2.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities-net7react18/59c11a50c5e25103faf9b99c64f875d0d5d53469/API/wwwroot/static/media/icons.c656b8caa454ed19b9a2.ttf -------------------------------------------------------------------------------- /API/wwwroot/static/media/outline-icons.5367103510b27b784827.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities-net7react18/59c11a50c5e25103faf9b99c64f875d0d5d53469/API/wwwroot/static/media/outline-icons.5367103510b27b784827.ttf -------------------------------------------------------------------------------- /API/wwwroot/static/media/outline-icons.687a4990ea22bb1a49d4.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities-net7react18/59c11a50c5e25103faf9b99c64f875d0d5d53469/API/wwwroot/static/media/outline-icons.687a4990ea22bb1a49d4.woff2 -------------------------------------------------------------------------------- /API/wwwroot/static/media/outline-icons.752905fa5edf21fc52a1.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities-net7react18/59c11a50c5e25103faf9b99c64f875d0d5d53469/API/wwwroot/static/media/outline-icons.752905fa5edf21fc52a1.eot -------------------------------------------------------------------------------- /API/wwwroot/static/media/outline-icons.ddae9b1ba9b0b42f5880.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities-net7react18/59c11a50c5e25103faf9b99c64f875d0d5d53469/API/wwwroot/static/media/outline-icons.ddae9b1ba9b0b42f5880.woff -------------------------------------------------------------------------------- /Application/Activities/ActivityDto.cs: -------------------------------------------------------------------------------- 1 | using Application.Comments; 2 | using Application.Profiles; 3 | 4 | namespace Application.Activities 5 | { 6 | public class ActivityDto 7 | { 8 | public Guid Id { get; set; } 9 | public string Title { get; set; } 10 | public DateTime Date { get; set; } 11 | public string Description { get; set; } 12 | public string Category { get; set; } 13 | public string City { get; set; } 14 | public string Venue { get; set; } 15 | public string HostUsername { get; set; } 16 | public bool IsCancelled { get; set; } 17 | public ICollection Attendees { get; set; } 18 | public ICollection Comments { get; set; } 19 | } 20 | } -------------------------------------------------------------------------------- /Application/Activities/ActivityParams.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Application.Core; 3 | 4 | namespace Application.Activities 5 | { 6 | public class ActivityParams : PagingParams 7 | { 8 | public bool IsGoing { get; set; } 9 | public bool IsHost { get; set; } 10 | public DateTime StartDate { get; set; } = DateTime.UtcNow; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Application/Activities/ActivityValidator.cs: -------------------------------------------------------------------------------- 1 | using Domain; 2 | using FluentValidation; 3 | 4 | namespace Application.Activities 5 | { 6 | public class ActivityValidator : AbstractValidator 7 | { 8 | public ActivityValidator() 9 | { 10 | RuleFor(x => x.Title).NotEmpty(); 11 | RuleFor(x => x.Description).NotEmpty(); 12 | RuleFor(x => x.Date).NotEmpty(); 13 | RuleFor(x => x.Category).NotEmpty(); 14 | RuleFor(x => x.City).NotEmpty(); 15 | RuleFor(x => x.Venue).NotEmpty(); 16 | } 17 | } 18 | } -------------------------------------------------------------------------------- /Application/Activities/AttendeeDto.cs: -------------------------------------------------------------------------------- 1 | namespace Application.Activities 2 | { 3 | public class AttendeeDto 4 | { 5 | public string Username { get; set; } 6 | public string DisplayName { get; set; } 7 | public string Bio { get; set; } 8 | public string Image { get; set; } 9 | public bool Following { get; set; } 10 | public int FollowersCount { get; set; } 11 | public int FollowingCount { get; set; } 12 | } 13 | } -------------------------------------------------------------------------------- /Application/Activities/Create.cs: -------------------------------------------------------------------------------- 1 | using Application.Core; 2 | using Application.Interfaces; 3 | using Domain; 4 | using FluentValidation; 5 | using MediatR; 6 | using Microsoft.EntityFrameworkCore; 7 | using Persistence; 8 | 9 | namespace Application.Activities 10 | { 11 | public class Create 12 | { 13 | public class Command : IRequest> 14 | { 15 | public Activity Activity { get; set; } 16 | } 17 | 18 | public class Handler : IRequestHandler> 19 | { 20 | private readonly DataContext _context; 21 | private readonly IUserAccessor _userAccessor; 22 | 23 | public Handler(DataContext context, IUserAccessor userAccessor) 24 | { 25 | _userAccessor = userAccessor; 26 | _context = context; 27 | } 28 | 29 | public class CommandValidator : AbstractValidator 30 | { 31 | public CommandValidator() 32 | { 33 | RuleFor(x => x.Activity).SetValidator(new ActivityValidator()); 34 | } 35 | } 36 | 37 | public async Task> Handle(Command request, CancellationToken cancellationToken) 38 | { 39 | var user = await _context.Users.FirstOrDefaultAsync(x => 40 | x.UserName == _userAccessor.GetUsername()); 41 | 42 | var attendee = new ActivityAttendee 43 | { 44 | AppUser = user, 45 | Activity = request.Activity, 46 | IsHost = true 47 | }; 48 | 49 | request.Activity.Attendees.Add(attendee); 50 | 51 | _context.Activities.Add(request.Activity); 52 | 53 | var result = await _context.SaveChangesAsync() > 0; 54 | 55 | if (!result) return Result.Failure("Failed to create activity"); 56 | 57 | return Result.Success(Unit.Value); 58 | } 59 | } 60 | } 61 | } -------------------------------------------------------------------------------- /Application/Activities/Delete.cs: -------------------------------------------------------------------------------- 1 | using Application.Core; 2 | using MediatR; 3 | using Persistence; 4 | 5 | namespace Application.Activities 6 | { 7 | public class Delete 8 | { 9 | public class Command : IRequest> 10 | { 11 | public Guid Id { get; set; } 12 | } 13 | 14 | public class Handler : IRequestHandler> 15 | { 16 | private readonly DataContext _context; 17 | 18 | public Handler(DataContext context) 19 | { 20 | _context = context; 21 | } 22 | 23 | public async Task> Handle(Command request, CancellationToken cancellationToken) 24 | { 25 | var activity = await _context.Activities.FindAsync(request.Id); 26 | 27 | if (activity == null) return null; 28 | 29 | _context.Remove(activity); 30 | 31 | var result = await _context.SaveChangesAsync() > 0; 32 | 33 | if (!result) return Result.Failure("Failed to delete the activity"); 34 | 35 | return Result.Success(Unit.Value); 36 | } 37 | } 38 | } 39 | } -------------------------------------------------------------------------------- /Application/Activities/Details.cs: -------------------------------------------------------------------------------- 1 | using Application.Core; 2 | using Application.Interfaces; 3 | using AutoMapper; 4 | using AutoMapper.QueryableExtensions; 5 | using Domain; 6 | using MediatR; 7 | using Microsoft.EntityFrameworkCore; 8 | using Persistence; 9 | 10 | namespace Application.Activities 11 | { 12 | public class Details 13 | { 14 | public class Query : IRequest> 15 | { 16 | public Guid Id { get; set; } 17 | } 18 | 19 | public class Handler : IRequestHandler> 20 | { 21 | private readonly DataContext _context; 22 | private readonly IMapper _mapper; 23 | private readonly IUserAccessor _userAccessor; 24 | 25 | public Handler(DataContext context, IMapper mapper, IUserAccessor userAccessor) 26 | { 27 | _userAccessor = userAccessor; 28 | _mapper = mapper; 29 | _context = context; 30 | } 31 | 32 | public async Task> Handle(Query request, CancellationToken cancellationToken) 33 | { 34 | var activity = await _context.Activities 35 | .ProjectTo(_mapper.ConfigurationProvider, new{currentUsername = _userAccessor.GetUsername()}) 36 | .FirstOrDefaultAsync(x => x.Id == request.Id); 37 | 38 | return Result.Success(activity); 39 | } 40 | } 41 | } 42 | } 43 | 44 | -------------------------------------------------------------------------------- /Application/Activities/Edit.cs: -------------------------------------------------------------------------------- 1 | using Application.Core; 2 | using AutoMapper; 3 | using Domain; 4 | using FluentValidation; 5 | using MediatR; 6 | using Persistence; 7 | 8 | namespace Application.Activities 9 | { 10 | public class Edit 11 | { 12 | public class Command : IRequest> 13 | { 14 | public Activity Activity { get; set; } 15 | } 16 | 17 | public class CommandValidator : AbstractValidator 18 | { 19 | public CommandValidator() 20 | { 21 | RuleFor(x => x.Activity).SetValidator(new ActivityValidator()); 22 | } 23 | } 24 | 25 | public class Handler : IRequestHandler> 26 | { 27 | private readonly DataContext _context; 28 | private readonly IMapper _mapper; 29 | 30 | public Handler(DataContext context, IMapper mapper) 31 | { 32 | _mapper = mapper; 33 | _context = context; 34 | } 35 | 36 | public async Task> Handle(Command request, CancellationToken cancellationToken) 37 | { 38 | var activity = await _context.Activities.FindAsync(request.Activity.Id); 39 | 40 | if (activity == null) return null; 41 | 42 | _mapper.Map(request.Activity, activity); 43 | 44 | var result = await _context.SaveChangesAsync() > 0; 45 | 46 | if (!result) return Result.Failure("Failed to update activity"); 47 | 48 | return Result.Success(Unit.Value); 49 | } 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /Application/Activities/List.cs: -------------------------------------------------------------------------------- 1 | using Application.Core; 2 | using Application.Interfaces; 3 | using AutoMapper; 4 | using AutoMapper.QueryableExtensions; 5 | using MediatR; 6 | using Persistence; 7 | 8 | namespace Application.Activities 9 | { 10 | public class List 11 | { 12 | public class Query : IRequest>> 13 | { 14 | public ActivityParams Params { get; set; } 15 | } 16 | 17 | public class Handler : IRequestHandler>> 18 | { 19 | private readonly DataContext _context; 20 | private readonly IMapper _mapper; 21 | private readonly IUserAccessor _userAccessor; 22 | public Handler(DataContext context, IMapper mapper, IUserAccessor userAccessor) 23 | { 24 | _userAccessor = userAccessor; 25 | _mapper = mapper; 26 | _context = context; 27 | } 28 | 29 | public async Task>> Handle(Query request, CancellationToken cancellationToken) 30 | { 31 | var query = _context.Activities 32 | .Where(x => x.Date >= request.Params.StartDate) 33 | .OrderBy(d => d.Date) 34 | .ProjectTo(_mapper.ConfigurationProvider, new { currentUsername = _userAccessor.GetUsername() }) 35 | .AsQueryable(); 36 | 37 | if (request.Params.IsGoing && !request.Params.IsHost) 38 | { 39 | query = query.Where(x => x.Attendees.Any(a => a.Username == _userAccessor.GetUsername())); 40 | } 41 | 42 | if (request.Params.IsHost && !request.Params.IsGoing) 43 | { 44 | query = query.Where(x => x.HostUsername == _userAccessor.GetUsername()); 45 | } 46 | 47 | return Result> 48 | .Success(await PagedList.CreateAsync(query, 49 | request.Params.PageNumber, request.Params.PageSize)); 50 | } 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Application/Activities/UpdateAttendance.cs: -------------------------------------------------------------------------------- 1 | using Application.Core; 2 | using Application.Interfaces; 3 | using Domain; 4 | using MediatR; 5 | using Microsoft.EntityFrameworkCore; 6 | using Persistence; 7 | 8 | namespace Application.Activities 9 | { 10 | public class UpdateAttendance 11 | { 12 | public class Command : IRequest> 13 | { 14 | public Guid Id { get; set; } 15 | } 16 | 17 | public class Handler : IRequestHandler> 18 | { 19 | private readonly DataContext _context; 20 | private readonly IUserAccessor _userAccessor; 21 | public Handler(DataContext context, IUserAccessor userAccessor) 22 | { 23 | _userAccessor = userAccessor; 24 | _context = context; 25 | } 26 | 27 | public async Task> Handle(Command request, CancellationToken cancellationToken) 28 | { 29 | var activity = await _context.Activities 30 | .Include(a => a.Attendees).ThenInclude(u => u.AppUser) 31 | .SingleOrDefaultAsync(x => x.Id == request.Id); 32 | 33 | if (activity == null) return null; 34 | 35 | var user = await _context.Users.FirstOrDefaultAsync(x => x.UserName == _userAccessor.GetUsername()); 36 | 37 | if (user == null) return null; 38 | 39 | var hostUsername = activity.Attendees.FirstOrDefault(x => x.IsHost)?.AppUser.UserName; 40 | 41 | var attendance = activity.Attendees.FirstOrDefault(x => x.AppUser.UserName == user.UserName); 42 | 43 | if (attendance != null && hostUsername == user.UserName) 44 | activity.IsCancelled = !activity.IsCancelled; 45 | 46 | if (attendance != null && hostUsername != user.UserName) 47 | activity.Attendees.Remove(attendance); 48 | 49 | if (attendance == null) 50 | { 51 | attendance = new ActivityAttendee 52 | { 53 | AppUser = user, 54 | Activity = activity, 55 | IsHost = false 56 | }; 57 | 58 | activity.Attendees.Add(attendance); 59 | } 60 | 61 | var result = await _context.SaveChangesAsync() > 0; 62 | 63 | return result ? Result.Success(Unit.Value) : Result.Failure("Problem updating attendance"); 64 | } 65 | } 66 | } 67 | } -------------------------------------------------------------------------------- /Application/Application.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | net7.0 16 | enable 17 | disable 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /Application/Comments/CommentDto.cs: -------------------------------------------------------------------------------- 1 | namespace Application.Comments 2 | { 3 | public class CommentDto 4 | { 5 | public int Id { get; set; } 6 | public DateTime CreatedAt { get; set; } 7 | public string Body { get; set; } 8 | public string Username { get; set; } 9 | public string DisplayName { get; set; } 10 | public string Image { get; set; } 11 | } 12 | } -------------------------------------------------------------------------------- /Application/Comments/Create.cs: -------------------------------------------------------------------------------- 1 | using Application.Core; 2 | using Application.Interfaces; 3 | using AutoMapper; 4 | using Domain; 5 | using FluentValidation; 6 | using MediatR; 7 | using Microsoft.EntityFrameworkCore; 8 | using Persistence; 9 | 10 | namespace Application.Comments 11 | { 12 | public class Create 13 | { 14 | public class Command : IRequest> 15 | { 16 | public string Body { get; set; } 17 | public Guid ActivityId { get; set; } 18 | } 19 | 20 | public class CommandValidator : AbstractValidator 21 | { 22 | public CommandValidator() 23 | { 24 | RuleFor(x => x.Body).NotEmpty(); 25 | } 26 | } 27 | 28 | public class Handler : IRequestHandler> 29 | { 30 | private readonly DataContext _context; 31 | private readonly IMapper _mapper; 32 | private readonly IUserAccessor _userAccessor; 33 | 34 | public Handler(DataContext context, IMapper mapper, IUserAccessor userAccessor) 35 | { 36 | _userAccessor = userAccessor; 37 | _context = context; 38 | _mapper = mapper; 39 | } 40 | 41 | public async Task> Handle(Command request, CancellationToken cancellationToken) 42 | { 43 | var activity = await _context.Activities 44 | .Include(x => x.Comments) 45 | .ThenInclude(x => x.Author) 46 | .ThenInclude(x => x.Photos) 47 | .FirstOrDefaultAsync(x => x.Id == request.ActivityId); 48 | 49 | 50 | if (activity == null) return null; 51 | 52 | var user = await _context.Users 53 | .SingleOrDefaultAsync(x => x.UserName == _userAccessor.GetUsername()); 54 | 55 | var comment = new Comment 56 | { 57 | Author = user, 58 | Activity = activity, 59 | Body = request.Body 60 | }; 61 | 62 | activity.Comments.Add(comment); 63 | 64 | var success = await _context.SaveChangesAsync() > 0; 65 | 66 | if (success) return Result.Success(_mapper.Map(comment)); 67 | 68 | return Result.Failure("Failed to add comment"); 69 | } 70 | } 71 | } 72 | } -------------------------------------------------------------------------------- /Application/Comments/List.cs: -------------------------------------------------------------------------------- 1 | using Application.Core; 2 | using AutoMapper; 3 | using AutoMapper.QueryableExtensions; 4 | using MediatR; 5 | using Microsoft.EntityFrameworkCore; 6 | using Persistence; 7 | 8 | namespace Application.Comments 9 | { 10 | public class List 11 | { 12 | public class Query : IRequest>> 13 | { 14 | public Guid ActivityId { get; set; } 15 | } 16 | 17 | public class Handler : IRequestHandler>> 18 | { 19 | private readonly DataContext _context; 20 | private readonly IMapper _mapper; 21 | public Handler(DataContext context, IMapper mapper) 22 | { 23 | _mapper = mapper; 24 | _context = context; 25 | } 26 | 27 | public async Task>> Handle(Query request, CancellationToken cancellationToken) 28 | { 29 | var comments = await _context.Comments 30 | .Where(x => x.Activity.Id == request.ActivityId) 31 | .OrderByDescending(x => x.CreatedAt) 32 | .ProjectTo(_mapper.ConfigurationProvider) 33 | .ToListAsync(); 34 | 35 | return Result>.Success(comments); 36 | } 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Application/Core/AppException.cs: -------------------------------------------------------------------------------- 1 | namespace Application.Core 2 | { 3 | public class AppException 4 | { 5 | public AppException(int statusCode, string message, string details = null) 6 | { 7 | StatusCode = statusCode; 8 | Message = message; 9 | Details = details; 10 | } 11 | 12 | public int StatusCode { get; set; } 13 | public string Message { get; set; } 14 | public string Details { get; set; } 15 | } 16 | } -------------------------------------------------------------------------------- /Application/Core/MappingProfiles.cs: -------------------------------------------------------------------------------- 1 | using Application.Activities; 2 | using Application.Comments; 3 | using Application.Profiles; 4 | using Domain; 5 | 6 | namespace Application.Core 7 | { 8 | public class MappingProfiles : AutoMapper.Profile 9 | { 10 | public MappingProfiles() 11 | { 12 | string currentUsername = null; 13 | CreateMap(); 14 | CreateMap() 15 | .ForMember(d => d.HostUsername, o => o.MapFrom(s => s.Attendees 16 | .FirstOrDefault(x => x.IsHost).AppUser.UserName)); 17 | CreateMap() 18 | .ForMember(d => d.DisplayName, o => o.MapFrom(s => s.AppUser.DisplayName)) 19 | .ForMember(d => d.Username, o => o.MapFrom(s => s.AppUser.UserName)) 20 | .ForMember(d => d.Bio, o => o.MapFrom(s => s.AppUser.Bio)) 21 | .ForMember(d => d.Image, o => o.MapFrom(s => s.AppUser.Photos.FirstOrDefault(x => x.IsMain).Url)) 22 | .ForMember(d => d.FollowersCount, o => o.MapFrom(s => s.AppUser.Followers.Count)) 23 | .ForMember(d => d.FollowingCount, o => o.MapFrom(s => s.AppUser.Followings.Count)) 24 | .ForMember(d => d.Following, 25 | o => o.MapFrom(s => s.AppUser.Followers.Any(x => x.Observer.UserName == currentUsername))); 26 | CreateMap() 27 | .ForMember(d => d.Image, s => s.MapFrom(o => o.Photos.FirstOrDefault(x => x.IsMain).Url)) 28 | .ForMember(d => d.FollowersCount, o => o.MapFrom(s => s.Followers.Count)) 29 | .ForMember(d => d.FollowingCount, o => o.MapFrom(s => s.Followings.Count)) 30 | .ForMember(d => d.Following, 31 | o => o.MapFrom(s => s.Followers.Any(x => x.Observer.UserName == currentUsername))); 32 | CreateMap() 33 | .ForMember(d => d.Username, o => o.MapFrom(s => s.Author.UserName)) 34 | .ForMember(d => d.DisplayName, o => o.MapFrom(s => s.Author.DisplayName)) 35 | .ForMember(d => d.Image, o => o.MapFrom(s => s.Author.Photos.FirstOrDefault(x => x.IsMain).Url)); 36 | CreateMap() 37 | .ForMember(d => d.Id, o => o.MapFrom(s => s.Activity.Id)) 38 | .ForMember(d => d.Date, o => o.MapFrom(s => s.Activity.Date)) 39 | .ForMember(d => d.Title, o => o.MapFrom(s => s.Activity.Title)) 40 | .ForMember(d => d.Category, o => o.MapFrom(s => s.Activity.Category)) 41 | .ForMember(d => d.HostUsername, o => o.MapFrom(s => 42 | s.Activity.Attendees.FirstOrDefault(x => x.IsHost).AppUser.UserName)); 43 | } 44 | } 45 | } -------------------------------------------------------------------------------- /Application/Core/PagedList.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | using Microsoft.EntityFrameworkCore; 6 | 7 | namespace Application.Core 8 | { 9 | public class PagedList : List 10 | { 11 | public PagedList(IEnumerable items, int count, int pageNumber, int pageSize) 12 | { 13 | CurrentPage = pageNumber; 14 | TotalPages = (int)Math.Ceiling(count / (double)pageSize); 15 | PageSize = pageSize; 16 | TotalCount = count; 17 | AddRange(items); 18 | } 19 | 20 | public int CurrentPage { get; set; } 21 | public int TotalPages { get; set; } 22 | public int PageSize { get; set; } 23 | public int TotalCount { get; set; } 24 | 25 | public static async Task> CreateAsync(IQueryable source, int pageNumber, 26 | int pageSize) 27 | { 28 | var count = await source.CountAsync(); 29 | var items = await source.Skip((pageNumber - 1) * pageSize).Take(pageSize).ToListAsync(); 30 | return new PagedList(items, count, pageNumber, pageSize); 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Application/Core/PagingParams.cs: -------------------------------------------------------------------------------- 1 | namespace Application.Core 2 | { 3 | public class PagingParams 4 | { 5 | private const int MaxPageSize = 50; 6 | public int PageNumber { get; set; } = 1; 7 | private int _pageSize = 10; 8 | 9 | public int PageSize 10 | { 11 | get => _pageSize; 12 | set => _pageSize = (value > MaxPageSize) ? MaxPageSize : value; 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Application/Core/Result.cs: -------------------------------------------------------------------------------- 1 | namespace Application.Core 2 | { 3 | public class Result 4 | { 5 | public bool IsSuccess { get; set; } 6 | public T Value { get; set; } 7 | public string Error { get; set; } 8 | 9 | public static Result Success(T value) => new Result {IsSuccess = true, Value = value}; 10 | public static Result Failure(string error) => new Result {IsSuccess = false, Error = error}; 11 | } 12 | } -------------------------------------------------------------------------------- /Application/Followers/FollowToggle.cs: -------------------------------------------------------------------------------- 1 | using Application.Core; 2 | using Application.Interfaces; 3 | using Domain; 4 | using MediatR; 5 | using Microsoft.EntityFrameworkCore; 6 | using Persistence; 7 | 8 | namespace Application.Followers 9 | { 10 | public class FollowToggle 11 | { 12 | public class Command : IRequest> 13 | { 14 | public string TargetUsername { get; set; } 15 | } 16 | 17 | public class Handler : IRequestHandler> 18 | { 19 | private readonly DataContext _context; 20 | private readonly IUserAccessor _userAccessor; 21 | 22 | public Handler(DataContext context, IUserAccessor userAccessor) 23 | { 24 | _userAccessor = userAccessor; 25 | _context = context; 26 | } 27 | 28 | public async Task> Handle(Command request, CancellationToken cancellationToken) 29 | { 30 | var observer = await _context.Users.FirstOrDefaultAsync(x => 31 | x.UserName == _userAccessor.GetUsername()); 32 | 33 | var target = await _context.Users.FirstOrDefaultAsync(x => 34 | x.UserName == request.TargetUsername); 35 | 36 | if (target == null) return null; 37 | 38 | var following = await _context.UserFollowings.FindAsync(observer.Id, target.Id); 39 | 40 | if (following == null) 41 | { 42 | following = new UserFollowing 43 | { 44 | Observer = observer, 45 | Target = target 46 | }; 47 | 48 | _context.UserFollowings.Add(following); 49 | } 50 | else 51 | { 52 | _context.UserFollowings.Remove(following); 53 | } 54 | 55 | var success = await _context.SaveChangesAsync() > 0; 56 | 57 | if (success) return Result.Success(Unit.Value); 58 | 59 | return Result.Failure("Failed to update following"); 60 | } 61 | } 62 | 63 | } 64 | } -------------------------------------------------------------------------------- /Application/Followers/List.cs: -------------------------------------------------------------------------------- 1 | using Application.Core; 2 | using Application.Interfaces; 3 | using AutoMapper; 4 | using AutoMapper.QueryableExtensions; 5 | using MediatR; 6 | using Microsoft.EntityFrameworkCore; 7 | using Persistence; 8 | 9 | namespace Application.Followers 10 | { 11 | public class List 12 | { 13 | public class Query : IRequest>> 14 | { 15 | public string Predicate { get; set; } 16 | public string Username { get; set; } 17 | } 18 | 19 | public class Handler : IRequestHandler>> 20 | { 21 | private readonly DataContext _context; 22 | 23 | private readonly IMapper _mapper; 24 | private readonly IUserAccessor _userAccessor; 25 | 26 | public Handler(DataContext context, IMapper mapper, IUserAccessor userAccessor) 27 | { 28 | _userAccessor = userAccessor; 29 | _context = context; 30 | _mapper = mapper; 31 | } 32 | 33 | public async Task>> Handle(Query request, CancellationToken cancellationToken) 34 | { 35 | var profiles = new List(); 36 | 37 | switch (request.Predicate) 38 | { 39 | case "followers": 40 | profiles = await _context.UserFollowings.Where(x => x.Target.UserName == request.Username) 41 | .Select(u => u.Observer) 42 | .ProjectTo(_mapper.ConfigurationProvider, 43 | new {currentUsername = _userAccessor.GetUsername()}) 44 | .ToListAsync(); 45 | break; 46 | case "following": 47 | profiles = await _context.UserFollowings.Where(x => x.Observer.UserName == request.Username) 48 | .Select(u => u.Target) 49 | .ProjectTo(_mapper.ConfigurationProvider, 50 | new {currentUsername = _userAccessor.GetUsername()}) 51 | .ToListAsync(); 52 | break; 53 | } 54 | 55 | return Result>.Success(profiles); 56 | } 57 | } 58 | } 59 | } -------------------------------------------------------------------------------- /Application/Interfaces/IPhotoAccessor.cs: -------------------------------------------------------------------------------- 1 | using Application.Photos; 2 | using Microsoft.AspNetCore.Http; 3 | 4 | namespace Application.Interfaces 5 | { 6 | public interface IPhotoAccessor 7 | { 8 | Task AddPhoto(IFormFile file); 9 | Task DeletePhoto(string publicId); 10 | } 11 | } -------------------------------------------------------------------------------- /Application/Interfaces/IUserAccessor.cs: -------------------------------------------------------------------------------- 1 | namespace Application.Interfaces 2 | { 3 | public interface IUserAccessor 4 | { 5 | string GetUsername(); 6 | } 7 | } -------------------------------------------------------------------------------- /Application/Photos/Add.cs: -------------------------------------------------------------------------------- 1 | using Application.Core; 2 | using Application.Interfaces; 3 | using Domain; 4 | using MediatR; 5 | using Microsoft.AspNetCore.Http; 6 | using Microsoft.EntityFrameworkCore; 7 | using Persistence; 8 | 9 | namespace Application.Photos 10 | { 11 | public class Add 12 | { 13 | public class Command : IRequest> 14 | { 15 | public IFormFile File { get; set; } 16 | } 17 | 18 | public class Handler : IRequestHandler> 19 | { 20 | private readonly DataContext _context; 21 | private readonly IPhotoAccessor _photoAccessor; 22 | private readonly IUserAccessor _userAccessor; 23 | 24 | public Handler(DataContext context, IPhotoAccessor photoAccessor, IUserAccessor userAccessor) 25 | { 26 | _userAccessor = userAccessor; 27 | _context = context; 28 | _photoAccessor = photoAccessor; 29 | } 30 | 31 | public async Task> Handle(Command request, CancellationToken cancellationToken) 32 | { 33 | var photoUploadResult = await _photoAccessor.AddPhoto(request.File); 34 | var user = await _context.Users.Include(p => p.Photos) 35 | .FirstOrDefaultAsync(x => x.UserName == _userAccessor.GetUsername()); 36 | 37 | var photo = new Photo 38 | { 39 | Url = photoUploadResult.Url, 40 | Id = photoUploadResult.PublicId 41 | }; 42 | 43 | if (!user.Photos.Any(x => x.IsMain)) photo.IsMain = true; 44 | 45 | user.Photos.Add(photo); 46 | 47 | var result = await _context.SaveChangesAsync() > 0; 48 | 49 | if (result) return Result.Success(photo); 50 | 51 | return Result.Failure("Problem adding photo"); 52 | } 53 | } 54 | } 55 | } -------------------------------------------------------------------------------- /Application/Photos/Delete.cs: -------------------------------------------------------------------------------- 1 | using Application.Core; 2 | using Application.Interfaces; 3 | using MediatR; 4 | using Microsoft.EntityFrameworkCore; 5 | using Persistence; 6 | 7 | namespace Application.Photos 8 | { 9 | public class Delete 10 | { 11 | public class Command : IRequest> 12 | { 13 | public string Id { get; set; } 14 | } 15 | 16 | public class Handler : IRequestHandler> 17 | { 18 | private readonly DataContext _context; 19 | private readonly IUserAccessor _userAccessor; 20 | private readonly IPhotoAccessor _photoAccessor; 21 | public Handler(DataContext context, IUserAccessor userAccessor, IPhotoAccessor photoAccessor) 22 | { 23 | _photoAccessor = photoAccessor; 24 | _userAccessor = userAccessor; 25 | _context = context; 26 | } 27 | 28 | public async Task> Handle(Command request, CancellationToken cancellationToken) 29 | { 30 | var user = await _context.Users.Include(p => p.Photos) 31 | .FirstOrDefaultAsync(x => x.UserName == _userAccessor.GetUsername()); 32 | 33 | var photo = user.Photos.FirstOrDefault(x => x.Id == request.Id); 34 | 35 | if (photo == null) return null; 36 | 37 | if (photo.IsMain) return Result.Failure("You cannot delete your main photo"); 38 | 39 | var result = await _photoAccessor.DeletePhoto(photo.Id); 40 | 41 | if (result == null) return Result.Failure("Problem deleting photo"); 42 | 43 | user.Photos.Remove(photo); 44 | 45 | var success = await _context.SaveChangesAsync() > 0; 46 | 47 | if (success) return Result.Success(Unit.Value); 48 | 49 | return Result.Failure("Problem deleting photo"); 50 | } 51 | } 52 | } 53 | } -------------------------------------------------------------------------------- /Application/Photos/PhotoUploadResult.cs: -------------------------------------------------------------------------------- 1 | namespace Application.Photos 2 | { 3 | public class PhotoUploadResult 4 | { 5 | public string PublicId { get; set; } 6 | public string Url { get; set; } 7 | } 8 | } -------------------------------------------------------------------------------- /Application/Photos/SetMain.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | using Application.Core; 6 | using Application.Interfaces; 7 | using MediatR; 8 | using Microsoft.EntityFrameworkCore; 9 | using Persistence; 10 | 11 | namespace Application.Photos 12 | { 13 | public class SetMain 14 | { 15 | public class Command : IRequest> 16 | { 17 | public string Id { get; set; } 18 | } 19 | 20 | public class Handler : IRequestHandler> 21 | { 22 | private readonly DataContext _context; 23 | private readonly IUserAccessor _userAccessor; 24 | 25 | public Handler(DataContext context, IUserAccessor userAccessor) 26 | { 27 | _userAccessor = userAccessor; 28 | _context = context; 29 | } 30 | 31 | public async Task> Handle(Command request, CancellationToken cancellationToken) 32 | { 33 | var user = await _context.Users 34 | .Include(p => p.Photos) 35 | .FirstOrDefaultAsync(x => x.UserName == _userAccessor.GetUsername()); 36 | 37 | var photo = user.Photos.FirstOrDefault(x => x.Id == request.Id); 38 | 39 | if (photo == null) return null; 40 | 41 | var currentMain = user.Photos.FirstOrDefault(x => x.IsMain); 42 | 43 | if (currentMain != null) currentMain.IsMain = false; 44 | 45 | photo.IsMain = true; 46 | var success = await _context.SaveChangesAsync() > 0; 47 | 48 | if (success) return Result.Success(Unit.Value); 49 | 50 | return Result.Failure("Problem setting main photo"); 51 | } 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Application/Profiles/Details.cs: -------------------------------------------------------------------------------- 1 | using System.Threading; 2 | using System.Threading.Tasks; 3 | using Application.Core; 4 | using Application.Interfaces; 5 | using AutoMapper; 6 | using AutoMapper.QueryableExtensions; 7 | using MediatR; 8 | using Microsoft.EntityFrameworkCore; 9 | using Persistence; 10 | 11 | namespace Application.Profiles 12 | { 13 | public class Details 14 | { 15 | public class Query : IRequest> 16 | { 17 | public string Username { get; set; } 18 | } 19 | 20 | public class Handler : IRequestHandler> 21 | { 22 | private readonly DataContext _context; 23 | private readonly IMapper _mapper; 24 | private readonly IUserAccessor _userAccessor; 25 | public Handler(DataContext context, IMapper mapper, IUserAccessor userAccessor) 26 | { 27 | _userAccessor = userAccessor; 28 | _mapper = mapper; 29 | _context = context; 30 | } 31 | 32 | public async Task> Handle(Query request, CancellationToken cancellationToken) 33 | { 34 | var user = await _context.Users 35 | .ProjectTo(_mapper.ConfigurationProvider, new { currentUsername = _userAccessor.GetUsername()}) 36 | .SingleOrDefaultAsync(x => x.Username == request.Username); 37 | 38 | if (user == null) return null; 39 | 40 | return Result.Success(user); 41 | } 42 | } 43 | } 44 | } 45 | 46 | -------------------------------------------------------------------------------- /Application/Profiles/Edit.cs: -------------------------------------------------------------------------------- 1 | using Application.Core; 2 | using Application.Interfaces; 3 | using FluentValidation; 4 | using MediatR; 5 | using Microsoft.EntityFrameworkCore; 6 | using Persistence; 7 | namespace Application.Profiles 8 | { 9 | public class Edit 10 | { 11 | public class Command : IRequest> 12 | { 13 | public string DisplayName { get; set; } 14 | public string Bio { get; set; } 15 | } 16 | 17 | public class CommandValidator : AbstractValidator 18 | { 19 | public CommandValidator() 20 | { 21 | RuleFor(x => x.DisplayName).NotEmpty(); 22 | } 23 | } 24 | 25 | public class Handler : IRequestHandler> 26 | { 27 | private readonly DataContext _context; 28 | private readonly IUserAccessor _userAccessor; 29 | public Handler(DataContext context, IUserAccessor userAccessor) 30 | { 31 | _userAccessor = userAccessor; 32 | _context = context; 33 | } 34 | public async Task> Handle(Command request, CancellationToken cancellationToken) 35 | { 36 | var user = await _context.Users.FirstOrDefaultAsync(x => 37 | x.UserName == _userAccessor.GetUsername()); 38 | 39 | user.Bio = request.Bio ?? user.Bio; 40 | user.DisplayName = request.DisplayName ?? user.DisplayName; 41 | 42 | var success = await _context.SaveChangesAsync() > 0; 43 | 44 | if (success) return Result.Success(Unit.Value); 45 | 46 | return Result.Failure("Problem updating profile"); 47 | } 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Application/Profiles/ListActivities.cs: -------------------------------------------------------------------------------- 1 | using Application.Core; 2 | using AutoMapper; 3 | using AutoMapper.QueryableExtensions; 4 | using MediatR; 5 | using Microsoft.EntityFrameworkCore; 6 | using Persistence; 7 | 8 | namespace Application.Profiles 9 | { 10 | public class ListActivities 11 | { 12 | public class Query : IRequest>> 13 | { 14 | public string Username { get; set; } 15 | public string Predicate { get; set; } 16 | } 17 | 18 | public class Handler : IRequestHandler>> 19 | { 20 | private readonly DataContext _context; 21 | private readonly IMapper _mapper; 22 | public Handler(DataContext context, IMapper mapper) 23 | { 24 | _mapper = mapper; 25 | _context = context; 26 | } 27 | 28 | public async Task>> Handle(Query request, CancellationToken cancellationToken) 29 | { 30 | var query = _context.ActivityAttendees 31 | .Where(u => u.AppUser.UserName == request.Username) 32 | .OrderBy(a => a.Activity.Date) 33 | .ProjectTo(_mapper.ConfigurationProvider) 34 | .AsQueryable(); 35 | 36 | var today = DateTime.UtcNow; 37 | 38 | query = request.Predicate switch 39 | { 40 | "past" => query.Where(a => a.Date <= today), 41 | "hosting" => query.Where(a => a.HostUsername == request.Username), 42 | _ => query.Where(a => a.Date >= today) 43 | }; 44 | 45 | var activities = await query.ToListAsync(); 46 | 47 | return Result>.Success(activities); 48 | } 49 | } 50 | } 51 | } -------------------------------------------------------------------------------- /Application/Profiles/Profile.cs: -------------------------------------------------------------------------------- 1 | using Domain; 2 | 3 | namespace Application.Profiles 4 | { 5 | public class Profile 6 | { 7 | public string Username { get; set; } 8 | public string DisplayName { get; set; } 9 | public string Bio { get; set; } 10 | public string Image { get; set; } 11 | public bool Following { get; set; } 12 | public int FollowersCount { get; set; } 13 | public int FollowingCount { get; set; } 14 | public ICollection Photos { get; set; } 15 | } 16 | } -------------------------------------------------------------------------------- /Application/Profiles/UserActivityDto.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace Application.Profiles 4 | { 5 | public class UserActivityDto 6 | { 7 | public Guid Id { get; set; } 8 | public string Title { get; set; } 9 | public string Category { get; set; } 10 | public DateTime Date { get; set; } 11 | 12 | [JsonIgnore] 13 | public string HostUsername { get; set; } 14 | } 15 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/dotnet/sdk:7.0 AS build-env 2 | WORKDIR /app 3 | EXPOSE 8080 4 | 5 | # copy .csproj and restore as distinct layers 6 | COPY "Reactivities.sln" "Reactivities.sln" 7 | COPY "API/API.csproj" "API/API.csproj" 8 | COPY "Application/Application.csproj" "Application/Application.csproj" 9 | COPY "Persistence/Persistence.csproj" "Persistence/Persistence.csproj" 10 | COPY "Domain/Domain.csproj" "Domain/Domain.csproj" 11 | COPY "Infrastructure/Infrastructure.csproj" "Infrastructure/Infrastructure.csproj" 12 | 13 | RUN dotnet restore "Reactivities.sln" 14 | 15 | # copy everything else and build 16 | COPY . . 17 | WORKDIR /app 18 | RUN dotnet publish -c Release -o out 19 | 20 | # build a runtime image 21 | FROM mcr.microsoft.com/dotnet/aspnet:7.0 22 | WORKDIR /app 23 | COPY --from=build-env /app/out . 24 | ENTRYPOINT [ "dotnet", "API.dll" ] 25 | -------------------------------------------------------------------------------- /Domain/Activity.cs: -------------------------------------------------------------------------------- 1 | namespace Domain 2 | { 3 | public class Activity 4 | { 5 | public Guid Id { get; set; } 6 | public string Title { get; set; } 7 | public DateTime Date { get; set; } 8 | public string Description { get; set; } 9 | public string Category { get; set; } 10 | public string City { get; set; } 11 | public string Venue { get; set; } 12 | public bool IsCancelled { get; set; } 13 | public ICollection Attendees { get; set; } = new List(); 14 | public ICollection Comments { get; set; } = new List(); 15 | } 16 | } -------------------------------------------------------------------------------- /Domain/ActivityAttendee.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | 6 | namespace Domain 7 | { 8 | public class ActivityAttendee 9 | { 10 | public string AppUserId { get; set; } 11 | public AppUser AppUser { get; set; } 12 | public Guid ActivityId { get; set; } 13 | public Activity Activity { get; set; } 14 | public bool IsHost { get; set; } 15 | } 16 | } -------------------------------------------------------------------------------- /Domain/AppUser.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Identity; 2 | 3 | namespace Domain 4 | { 5 | public class AppUser : IdentityUser 6 | { 7 | public string DisplayName { get; set; } 8 | public string Bio { get; set; } 9 | public ICollection Activities { get; set; } 10 | public ICollection Photos { get; set; } 11 | public ICollection Followings { get; set; } 12 | public ICollection Followers { get; set; } 13 | } 14 | } -------------------------------------------------------------------------------- /Domain/Comment.cs: -------------------------------------------------------------------------------- 1 | namespace Domain 2 | { 3 | public class Comment 4 | { 5 | public int Id { get; set; } 6 | public string Body { get; set; } 7 | public AppUser Author { get; set; } 8 | public Activity Activity { get; set; } 9 | public DateTime CreatedAt { get; set; } = DateTime.UtcNow; 10 | } 11 | } -------------------------------------------------------------------------------- /Domain/Domain.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net7.0 5 | enable 6 | disable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /Domain/Photo.cs: -------------------------------------------------------------------------------- 1 | namespace Domain 2 | { 3 | public class Photo 4 | { 5 | public string Id { get; set; } 6 | public string Url { get; set; } 7 | public bool IsMain { get; set; } 8 | } 9 | } -------------------------------------------------------------------------------- /Domain/UserFollowing.cs: -------------------------------------------------------------------------------- 1 | namespace Domain 2 | { 3 | public class UserFollowing 4 | { 5 | public string ObserverId { get; set; } 6 | public AppUser Observer { get; set; } 7 | public string TargetId { get; set; } 8 | public AppUser Target { get; set; } 9 | } 10 | } -------------------------------------------------------------------------------- /Infrastructure/Infrastructure.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | net7.0 13 | enable 14 | disable 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /Infrastructure/Photos/CloudinarySettings.cs: -------------------------------------------------------------------------------- 1 | namespace Infrastructure.Photos 2 | { 3 | public class CloudinarySettings 4 | { 5 | public string CloudName { get; set; } 6 | public string ApiKey { get; set; } 7 | public string ApiSecret { get; set; } 8 | } 9 | } -------------------------------------------------------------------------------- /Infrastructure/Photos/PhotoAccessor.cs: -------------------------------------------------------------------------------- 1 | using Application.Interfaces; 2 | using Application.Photos; 3 | using CloudinaryDotNet; 4 | using CloudinaryDotNet.Actions; 5 | using Microsoft.AspNetCore.Http; 6 | using Microsoft.Extensions.Options; 7 | 8 | namespace Infrastructure.Photos 9 | { 10 | public class PhotoAccessor : IPhotoAccessor 11 | { 12 | private readonly Cloudinary _cloudinary; 13 | 14 | public PhotoAccessor(IOptions config) 15 | { 16 | var account = new Account( 17 | config.Value.CloudName, 18 | config.Value.ApiKey, 19 | config.Value.ApiSecret 20 | ); 21 | _cloudinary = new Cloudinary(account); 22 | } 23 | 24 | public async Task AddPhoto(IFormFile file) 25 | { 26 | if (file.Length > 0) 27 | { 28 | await using var stream = file.OpenReadStream(); 29 | var uploadParams = new ImageUploadParams 30 | { 31 | File = new FileDescription(file.FileName, stream), 32 | Transformation = new Transformation().Height(500).Width(500).Crop("fill") 33 | }; 34 | 35 | var uploadResult = await _cloudinary.UploadAsync(uploadParams); 36 | 37 | if (uploadResult.Error != null) 38 | { 39 | throw new Exception(uploadResult.Error.Message); 40 | } 41 | 42 | return new PhotoUploadResult 43 | { 44 | PublicId = uploadResult.PublicId, 45 | Url = uploadResult.SecureUrl.ToString() 46 | }; 47 | } 48 | 49 | return null; 50 | } 51 | 52 | public async Task DeletePhoto(string publicId) 53 | { 54 | var deleteParams = new DeletionParams(publicId); 55 | var result = await _cloudinary.DestroyAsync(deleteParams); 56 | return result.Result == "ok" ? result.Result : null; 57 | } 58 | } 59 | } -------------------------------------------------------------------------------- /Infrastructure/Security/IsHostRequirement.cs: -------------------------------------------------------------------------------- 1 | using System.Security.Claims; 2 | using Microsoft.AspNetCore.Authorization; 3 | using Microsoft.AspNetCore.Http; 4 | using Microsoft.EntityFrameworkCore; 5 | using Persistence; 6 | 7 | namespace Infrastructure.Security 8 | { 9 | public class IsHostRequirement : IAuthorizationRequirement 10 | { 11 | } 12 | 13 | public class IsHostRequirementHandler : AuthorizationHandler 14 | { 15 | private readonly DataContext _dbContext; 16 | private readonly IHttpContextAccessor _httpContextAccessor; 17 | public IsHostRequirementHandler(DataContext dbContext, IHttpContextAccessor httpContextAccessor) 18 | { 19 | _httpContextAccessor = httpContextAccessor; 20 | _dbContext = dbContext; 21 | } 22 | 23 | protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, IsHostRequirement requirement) 24 | { 25 | var userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier); 26 | 27 | if (userId == null) return Task.CompletedTask; 28 | 29 | var activityId = Guid.Parse(_httpContextAccessor.HttpContext?.Request.RouteValues 30 | .SingleOrDefault(x => x.Key == "id").Value?.ToString()); 31 | 32 | var attendee = _dbContext.ActivityAttendees 33 | .AsNoTracking() 34 | .FirstOrDefaultAsync(x => x.AppUserId == userId && x.ActivityId == activityId) 35 | .Result; 36 | 37 | if (attendee == null) return Task.CompletedTask; 38 | 39 | if (attendee.IsHost) context.Succeed(requirement); 40 | 41 | return Task.CompletedTask; 42 | } 43 | } 44 | } -------------------------------------------------------------------------------- /Infrastructure/Security/UserAccessor.cs: -------------------------------------------------------------------------------- 1 | using System.Security.Claims; 2 | using Application.Interfaces; 3 | using Microsoft.AspNetCore.Http; 4 | 5 | namespace Infrastructure.Security 6 | { 7 | public class UserAccessor : IUserAccessor 8 | { 9 | private readonly IHttpContextAccessor _httpContextAccessor; 10 | public UserAccessor(IHttpContextAccessor httpContextAccessor) 11 | { 12 | _httpContextAccessor = httpContextAccessor; 13 | } 14 | 15 | public string GetUsername() 16 | { 17 | return _httpContextAccessor.HttpContext.User.FindFirstValue(ClaimTypes.Name); 18 | } 19 | } 20 | } -------------------------------------------------------------------------------- /Persistence/DataContext.cs: -------------------------------------------------------------------------------- 1 | using Domain; 2 | using Microsoft.AspNetCore.Identity.EntityFrameworkCore; 3 | using Microsoft.EntityFrameworkCore; 4 | 5 | namespace Persistence 6 | { 7 | public class DataContext : IdentityDbContext 8 | { 9 | public DataContext(DbContextOptions options) : base(options) 10 | { 11 | } 12 | 13 | public DbSet Activities { get; set; } 14 | public DbSet ActivityAttendees { get; set; } 15 | public DbSet Photos { get; set; } 16 | public DbSet Comments { get; set; } 17 | public DbSet UserFollowings { get; set; } 18 | 19 | 20 | protected override void OnModelCreating(ModelBuilder builder) 21 | { 22 | base.OnModelCreating(builder); 23 | 24 | builder.Entity(x => x.HasKey(aa => new { aa.AppUserId, aa.ActivityId })); 25 | 26 | builder.Entity() 27 | .HasOne(u => u.AppUser) 28 | .WithMany(u => u.Activities) 29 | .HasForeignKey(aa => aa.AppUserId); 30 | 31 | builder.Entity() 32 | .HasOne(u => u.Activity) 33 | .WithMany(u => u.Attendees) 34 | .HasForeignKey(aa => aa.ActivityId); 35 | 36 | builder.Entity() 37 | .HasOne(a => a.Activity) 38 | .WithMany(c => c.Comments) 39 | .OnDelete(DeleteBehavior.Cascade); 40 | 41 | builder.Entity(b => 42 | { 43 | b.HasKey(k => new { k.ObserverId, k.TargetId }); 44 | 45 | b.HasOne(o => o.Observer) 46 | .WithMany(f => f.Followings) 47 | .HasForeignKey(o => o.ObserverId) 48 | .OnDelete(DeleteBehavior.Cascade); 49 | b.HasOne(t => t.Target) 50 | .WithMany(f => f.Followers) 51 | .HasForeignKey(t => t.TargetId) 52 | .OnDelete(DeleteBehavior.Cascade); 53 | }); 54 | } 55 | } 56 | } -------------------------------------------------------------------------------- /Persistence/Persistence.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | net7.0 14 | enable 15 | disable 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Reactivities Udemy Course repository 2 | 3 |  4 | 5 | This is the updated repository for the .Net 7.0, React 18 and React Router 6 version of the course refreshed as at December 2022 6 | 7 | View a demo of this app [here](https://reactivities-course.fly.dev). You just need to register a user and sign in to see it in action. 8 | 9 | You can see how this app was made by checking out the Udemy course for this here (with discount) 10 | 11 | [Udemy course](https://www.udemy.com/course/complete-guide-to-building-an-app-with-net-core-and-react/?couponCode=REGITHUB) 12 | 13 | If you are looking for the repository for the version of this app created on .Net 6.0 and Angular v12 then this is available here: 14 | 15 | https://github.com/TryCatchLearn/Reactivities-v6 -------------------------------------------------------------------------------- /Reactivities.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.0.31903.59 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "API", "API\API.csproj", "{989E8725-8B53-4493-BD78-A57D3C83D2E1}" 7 | EndProject 8 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Application", "Application\Application.csproj", "{922F2AF1-C89E-4638-9388-BB87D9866B1E}" 9 | EndProject 10 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Domain", "Domain\Domain.csproj", "{EB04DE3F-8AD4-41E2-A218-8B2DA6812172}" 11 | EndProject 12 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Persistence", "Persistence\Persistence.csproj", "{38002F38-EC60-4314-831B-B9ACE2E404E7}" 13 | EndProject 14 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Infrastructure", "Infrastructure\Infrastructure.csproj", "{67EB0524-1757-472F-9841-7C8BD6B198E7}" 15 | EndProject 16 | Global 17 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 18 | Debug|Any CPU = Debug|Any CPU 19 | Release|Any CPU = Release|Any CPU 20 | EndGlobalSection 21 | GlobalSection(SolutionProperties) = preSolution 22 | HideSolutionNode = FALSE 23 | EndGlobalSection 24 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 25 | {989E8725-8B53-4493-BD78-A57D3C83D2E1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 26 | {989E8725-8B53-4493-BD78-A57D3C83D2E1}.Debug|Any CPU.Build.0 = Debug|Any CPU 27 | {989E8725-8B53-4493-BD78-A57D3C83D2E1}.Release|Any CPU.ActiveCfg = Release|Any CPU 28 | {989E8725-8B53-4493-BD78-A57D3C83D2E1}.Release|Any CPU.Build.0 = Release|Any CPU 29 | {922F2AF1-C89E-4638-9388-BB87D9866B1E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 30 | {922F2AF1-C89E-4638-9388-BB87D9866B1E}.Debug|Any CPU.Build.0 = Debug|Any CPU 31 | {922F2AF1-C89E-4638-9388-BB87D9866B1E}.Release|Any CPU.ActiveCfg = Release|Any CPU 32 | {922F2AF1-C89E-4638-9388-BB87D9866B1E}.Release|Any CPU.Build.0 = Release|Any CPU 33 | {EB04DE3F-8AD4-41E2-A218-8B2DA6812172}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 34 | {EB04DE3F-8AD4-41E2-A218-8B2DA6812172}.Debug|Any CPU.Build.0 = Debug|Any CPU 35 | {EB04DE3F-8AD4-41E2-A218-8B2DA6812172}.Release|Any CPU.ActiveCfg = Release|Any CPU 36 | {EB04DE3F-8AD4-41E2-A218-8B2DA6812172}.Release|Any CPU.Build.0 = Release|Any CPU 37 | {38002F38-EC60-4314-831B-B9ACE2E404E7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 38 | {38002F38-EC60-4314-831B-B9ACE2E404E7}.Debug|Any CPU.Build.0 = Debug|Any CPU 39 | {38002F38-EC60-4314-831B-B9ACE2E404E7}.Release|Any CPU.ActiveCfg = Release|Any CPU 40 | {38002F38-EC60-4314-831B-B9ACE2E404E7}.Release|Any CPU.Build.0 = Release|Any CPU 41 | {67EB0524-1757-472F-9841-7C8BD6B198E7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 42 | {67EB0524-1757-472F-9841-7C8BD6B198E7}.Debug|Any CPU.Build.0 = Debug|Any CPU 43 | {67EB0524-1757-472F-9841-7C8BD6B198E7}.Release|Any CPU.ActiveCfg = Release|Any CPU 44 | {67EB0524-1757-472F-9841-7C8BD6B198E7}.Release|Any CPU.Build.0 = Release|Any CPU 45 | EndGlobalSection 46 | EndGlobal 47 | -------------------------------------------------------------------------------- /client-app/.env.development: -------------------------------------------------------------------------------- 1 | REACT_APP_API_URL=http://localhost:5000/api 2 | REACT_APP_CHAT_URL=http://localhost:5000/chat -------------------------------------------------------------------------------- /client-app/.env.production: -------------------------------------------------------------------------------- 1 | REACT_APP_API_URL=/api 2 | REACT_APP_CHAT_URL=/chat -------------------------------------------------------------------------------- /client-app/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /client-app/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Create React App 2 | 3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 4 | 5 | ## Available Scripts 6 | 7 | In the project directory, you can run: 8 | 9 | ### `npm start` 10 | 11 | Runs the app in the development mode.\ 12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 13 | 14 | The page will reload if you make edits.\ 15 | You will also see any lint errors in the console. 16 | 17 | ### `npm test` 18 | 19 | Launches the test runner in the interactive watch mode.\ 20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 21 | 22 | ### `npm run build` 23 | 24 | Builds the app for production to the `build` folder.\ 25 | It correctly bundles React in production mode and optimizes the build for the best performance. 26 | 27 | The build is minified and the filenames include the hashes.\ 28 | Your app is ready to be deployed! 29 | 30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 31 | 32 | ### `npm run eject` 33 | 34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 35 | 36 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 37 | 38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 39 | 40 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 41 | 42 | ## Learn More 43 | 44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 45 | 46 | To learn React, check out the [React documentation](https://reactjs.org/). 47 | -------------------------------------------------------------------------------- /client-app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client-app", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@microsoft/signalr": "^7.0.0", 7 | "@testing-library/jest-dom": "^5.16.5", 8 | "@testing-library/react": "^13.4.0", 9 | "@testing-library/user-event": "^13.5.0", 10 | "@types/jest": "^27.5.2", 11 | "@types/node": "^16.18.3", 12 | "@types/react": "^18.0.25", 13 | "@types/react-dom": "^18.0.9", 14 | "@types/react-infinite-scroller": "^1.2.3", 15 | "axios": "^1.2.0", 16 | "date-fns": "^2.29.3", 17 | "formik": "^2.2.9", 18 | "mobx": "^6.7.0", 19 | "mobx-react-lite": "^3.4.0", 20 | "react": "^18.2.0", 21 | "react-calendar": "^4.0.0", 22 | "react-cropper": "^2.1.8", 23 | "react-datepicker": "^4.8.0", 24 | "react-dom": "^18.2.0", 25 | "react-dropzone": "^14.2.3", 26 | "react-infinite-scroller": "^1.2.6", 27 | "react-router-dom": "^6.4.3", 28 | "react-scripts": "5.0.1", 29 | "react-toastify": "^9.1.1", 30 | "semantic-ui-css": "^2.5.0", 31 | "semantic-ui-react": "^2.1.4", 32 | "typescript": "^4.9.3", 33 | "uuid": "^9.0.0", 34 | "web-vitals": "^2.1.4", 35 | "yup": "^0.32.11" 36 | }, 37 | "scripts": { 38 | "start": "react-scripts start", 39 | "build": "BUILD_PATH='../API/wwwroot' react-scripts build", 40 | "test": "react-scripts test", 41 | "eject": "react-scripts eject" 42 | }, 43 | "eslintConfig": { 44 | "extends": [ 45 | "react-app", 46 | "react-app/jest" 47 | ] 48 | }, 49 | "browserslist": { 50 | "production": [ 51 | ">0.2%", 52 | "not dead", 53 | "not op_mini all" 54 | ], 55 | "development": [ 56 | "last 1 chrome version", 57 | "last 1 firefox version", 58 | "last 1 safari version" 59 | ] 60 | }, 61 | "devDependencies": { 62 | "@types/react-calendar": "^3.9.0", 63 | "@types/react-datepicker": "^4.8.0", 64 | "@types/uuid": "^9.0.0", 65 | "@types/yup": "^0.32.0" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /client-app/public/assets/categoryImages/culture.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities-net7react18/59c11a50c5e25103faf9b99c64f875d0d5d53469/client-app/public/assets/categoryImages/culture.jpg -------------------------------------------------------------------------------- /client-app/public/assets/categoryImages/drinks.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities-net7react18/59c11a50c5e25103faf9b99c64f875d0d5d53469/client-app/public/assets/categoryImages/drinks.jpg -------------------------------------------------------------------------------- /client-app/public/assets/categoryImages/film.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities-net7react18/59c11a50c5e25103faf9b99c64f875d0d5d53469/client-app/public/assets/categoryImages/film.jpg -------------------------------------------------------------------------------- /client-app/public/assets/categoryImages/food.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities-net7react18/59c11a50c5e25103faf9b99c64f875d0d5d53469/client-app/public/assets/categoryImages/food.jpg -------------------------------------------------------------------------------- /client-app/public/assets/categoryImages/music.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities-net7react18/59c11a50c5e25103faf9b99c64f875d0d5d53469/client-app/public/assets/categoryImages/music.jpg -------------------------------------------------------------------------------- /client-app/public/assets/categoryImages/travel.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities-net7react18/59c11a50c5e25103faf9b99c64f875d0d5d53469/client-app/public/assets/categoryImages/travel.jpg -------------------------------------------------------------------------------- /client-app/public/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities-net7react18/59c11a50c5e25103faf9b99c64f875d0d5d53469/client-app/public/assets/logo.png -------------------------------------------------------------------------------- /client-app/public/assets/placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities-net7react18/59c11a50c5e25103faf9b99c64f875d0d5d53469/client-app/public/assets/placeholder.png -------------------------------------------------------------------------------- /client-app/public/assets/user.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities-net7react18/59c11a50c5e25103faf9b99c64f875d0d5d53469/client-app/public/assets/user.png -------------------------------------------------------------------------------- /client-app/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities-net7react18/59c11a50c5e25103faf9b99c64f875d0d5d53469/client-app/public/favicon.ico -------------------------------------------------------------------------------- /client-app/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | You need to enable JavaScript to run this app. 31 | 32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /client-app/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities-net7react18/59c11a50c5e25103faf9b99c64f875d0d5d53469/client-app/public/logo192.png -------------------------------------------------------------------------------- /client-app/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities-net7react18/59c11a50c5e25103faf9b99c64f875d0d5d53469/client-app/public/logo512.png -------------------------------------------------------------------------------- /client-app/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /client-app/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /client-app/src/app/common/form/MyDateInput.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {useField} from "formik"; 3 | import {Form, Label} from "semantic-ui-react"; 4 | import DatePicker, {ReactDatePickerProps} from 'react-datepicker'; 5 | 6 | export default function MyDateInput(props: Partial) { 7 | const [field, meta, helpers] = useField(props.name!); 8 | return ( 9 | 10 | helpers.setValue(value)} 15 | /> 16 | {meta.touched && meta.error ? ( 17 | {meta.error} 18 | ) : null} 19 | 20 | ) 21 | } -------------------------------------------------------------------------------- /client-app/src/app/common/form/MySelectInput.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {useField} from "formik"; 3 | import {Form, Label, Select} from "semantic-ui-react"; 4 | 5 | interface Props { 6 | placeholder: string; 7 | name: string; 8 | options: any; 9 | label?: string; 10 | } 11 | 12 | export default function MySelectInput(props: Props) { 13 | const [field, meta, helpers] = useField(props.name); 14 | return ( 15 | 16 | {props.label} 17 | helpers.setValue(d.value)} 22 | onBlur={() => helpers.setTouched(true)} 23 | placeholder={props.placeholder} 24 | /> 25 | {meta.touched && meta.error ? ( 26 | {meta.error} 27 | ) : null} 28 | 29 | ) 30 | } -------------------------------------------------------------------------------- /client-app/src/app/common/form/MyTextArea.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {useField} from "formik"; 3 | import {Form, Label} from "semantic-ui-react"; 4 | 5 | interface Props { 6 | placeholder: string; 7 | name: string; 8 | rows: number; 9 | label?: string; 10 | } 11 | 12 | export default function MyTextAreaInput(props: Props) { 13 | const [field, meta] = useField(props.name); 14 | return ( 15 | 16 | {props.label} 17 | 18 | {meta.touched && meta.error ? ( 19 | {meta.error} 20 | ) : null} 21 | 22 | ) 23 | } -------------------------------------------------------------------------------- /client-app/src/app/common/form/MyTextInput.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {useField} from "formik"; 3 | import {Form, Label} from "semantic-ui-react"; 4 | 5 | interface Props { 6 | placeholder: string; 7 | name: string; 8 | label?: string; 9 | type?: string; 10 | } 11 | 12 | export default function MyTextInput(props: Props) { 13 | const [field, meta] = useField(props.name); 14 | return ( 15 | 16 | {props.label} 17 | 18 | {meta.touched && meta.error ? ( 19 | {meta.error} 20 | ) : null} 21 | 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /client-app/src/app/common/imageUpload/PhotoUploadWidget.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { Button, Grid, Header } from "semantic-ui-react"; 3 | import { observer } from "mobx-react-lite"; 4 | import PhotoUploadWidgetDropzone from './PhotoWidgetDropzone'; 5 | import PhotoWidgetCropper from './PhotoWidgetCropper'; 6 | 7 | interface Props { 8 | loading: boolean; 9 | uploadPhoto: (file: Blob) => void; 10 | } 11 | 12 | export default observer(function PhotoUploadWidget({ loading, uploadPhoto }: Props) { 13 | const [files, setFiles] = useState([]); 14 | const [cropper, setCropper] = useState(); 15 | 16 | function onCrop() { 17 | if (cropper) { 18 | cropper.getCroppedCanvas().toBlob(blob => uploadPhoto(blob!)) 19 | } 20 | } 21 | 22 | useEffect(() => { 23 | return () => { 24 | files.forEach((file: any) => URL.revokeObjectURL(file.preview)) 25 | } 26 | }, [files]); 27 | 28 | return ( 29 | <> 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | {files && files.length > 0 && 40 | 41 | } 42 | 43 | 44 | 45 | 46 | 47 | 48 | {files && files.length > 0 && ( 49 | <> 50 | 51 | 52 | setFiles([])} icon='close' /> 53 | 54 | > 55 | )} 56 | 57 | 58 | > 59 | ) 60 | }) 61 | -------------------------------------------------------------------------------- /client-app/src/app/common/imageUpload/PhotoWidgetCropper.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Cropper from 'react-cropper'; 3 | import 'cropperjs/dist/cropper.css'; 4 | 5 | interface Props { 6 | imagePreview: string; 7 | setCropper: (cropper: Cropper) => void; 8 | } 9 | 10 | export default function PhotoWidgetCropper({ imagePreview, setCropper }: Props) { 11 | return ( 12 | setCropper(cropper)} 23 | /> 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /client-app/src/app/common/imageUpload/PhotoWidgetDropzone.tsx: -------------------------------------------------------------------------------- 1 | import React, {useCallback} from 'react' 2 | import {useDropzone} from 'react-dropzone' 3 | import { Header, Icon } from 'semantic-ui-react'; 4 | 5 | interface Props { 6 | setFiles: (files: any) => void; 7 | } 8 | 9 | export default function PhotoUploadWidgetDropzone({setFiles}: Props) { 10 | const dzStyles = { 11 | border: 'dashed 3px #eee', 12 | borderColor: '#eee', 13 | borderRadius: '5px', 14 | paddingTop: '30px', 15 | textAlign: 'center' as 'center', 16 | height: '200px' 17 | } 18 | 19 | const dzActive = { 20 | borderColor: 'green', 21 | } 22 | 23 | const onDrop = useCallback((acceptedFiles: any) => { 24 | setFiles(acceptedFiles.map((file: any) => Object.assign(file, { 25 | preview: URL.createObjectURL(file) 26 | }))) 27 | }, [setFiles]) 28 | 29 | const {getRootProps, getInputProps, isDragActive} = useDropzone({onDrop}) 30 | 31 | return ( 32 | 33 | 34 | 35 | 36 | 37 | ) 38 | } 39 | 40 | -------------------------------------------------------------------------------- /client-app/src/app/common/modals/ModalContainer.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from "mobx-react-lite"; 2 | import { Modal } from "semantic-ui-react"; 3 | import { useStore } from "../../stores/store"; 4 | 5 | export default observer(function ModalContainer() { 6 | const {modalStore} = useStore(); 7 | return ( 8 | 9 | 10 | {modalStore.modal.body} 11 | 12 | 13 | ) 14 | }) -------------------------------------------------------------------------------- /client-app/src/app/common/options/categoryOptions.ts: -------------------------------------------------------------------------------- 1 | export const categoryOptions = [ 2 | {text: 'Drinks', value: 'drinks'}, 3 | {text: 'Culture', value: 'culture'}, 4 | {text: 'Film', value: 'film'}, 5 | {text: 'Food', value: 'food'}, 6 | {text: 'Music', value: 'music'}, 7 | {text: 'Travel', value: 'travel'}, 8 | ] -------------------------------------------------------------------------------- /client-app/src/app/layout/App.tsx: -------------------------------------------------------------------------------- 1 | import { Container } from 'semantic-ui-react'; 2 | import NavBar from './NavBar'; 3 | import { observer } from 'mobx-react-lite'; 4 | import { Outlet, ScrollRestoration, useLocation } from 'react-router-dom'; 5 | import HomePage from '../../features/home/HomePage'; 6 | import { ToastContainer } from 'react-toastify'; 7 | import { useStore } from '../stores/store'; 8 | import { useEffect } from 'react'; 9 | import LoadingComponent from './LoadingComponent'; 10 | import ModalContainer from '../common/modals/ModalContainer'; 11 | 12 | function App() { 13 | const location = useLocation(); 14 | const { commonStore, userStore } = useStore(); 15 | 16 | useEffect(() => { 17 | if (commonStore.token) { 18 | userStore.getUser().finally(() => commonStore.setAppLoaded()) 19 | } else { 20 | commonStore.setAppLoaded() 21 | } 22 | }, [commonStore, userStore]) 23 | 24 | if (!commonStore.appLoaded) return 25 | 26 | return ( 27 | <> 28 | 29 | 30 | 31 | {location.pathname === '/' ? : ( 32 | <> 33 | 34 | 35 | 36 | 37 | > 38 | )} 39 | > 40 | ); 41 | } 42 | 43 | export default observer(App); 44 | -------------------------------------------------------------------------------- /client-app/src/app/layout/LoadingComponent.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {Dimmer, Loader} from "semantic-ui-react"; 3 | 4 | interface Props { 5 | inverted?: boolean; 6 | content?: string; 7 | } 8 | 9 | export default function LoadingComponent({inverted = true, content = 'Loading...'}: Props) { 10 | return ( 11 | 12 | 13 | 14 | ) 15 | } -------------------------------------------------------------------------------- /client-app/src/app/layout/NavBar.tsx: -------------------------------------------------------------------------------- 1 | import {Button, Container, Dropdown, Menu, Image} from "semantic-ui-react"; 2 | import { Link, NavLink } from "react-router-dom"; 3 | import { useStore } from "../stores/store"; 4 | import { observer } from "mobx-react-lite"; 5 | 6 | export default observer(function NavBar() { 7 | const {userStore: {user, logout}} = useStore(); 8 | return ( 9 | 10 | 11 | 12 | 13 | Reactivities 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | ) 32 | }) -------------------------------------------------------------------------------- /client-app/src/app/layout/styles.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #EAEAEA !important; 3 | } 4 | 5 | .ui.inverted.top.fixed.menu { 6 | background-image: linear-gradient(135deg, rgb(24, 42, 115) 0%, rgb(33, 138, 174) 69%, rgb(32, 167, 172) 89%) !important; 7 | } 8 | 9 | .react-calendar { 10 | width: 100%; 11 | border: none; 12 | box-shadow: 0 1px 2px 0 rgba(34, 36, 38, .15); 13 | } 14 | 15 | /*home page styles*/ 16 | 17 | .masthead { 18 | display: flex; 19 | align-items: center; 20 | background-image: linear-gradient( 21 | 135deg, 22 | rgb(24, 42, 115) 0%, 23 | rgb(33, 138, 174) 69%, 24 | rgb(32, 167, 172) 89% 25 | ) !important; 26 | height: 100vh; 27 | } 28 | 29 | .masthead .ui.menu .ui.button, 30 | .ui.menu a.ui.inverted.button { 31 | margin-left: 0.5em; 32 | } 33 | 34 | .masthead h1.ui.header { 35 | font-size: 4em; 36 | font-weight: normal; 37 | } 38 | 39 | .masthead h2 { 40 | font-size: 1.7em; 41 | font-weight: normal; 42 | } 43 | 44 | /*end home page styles*/ 45 | 46 | .react-datepicker-wrapper { 47 | width: 100%; 48 | } -------------------------------------------------------------------------------- /client-app/src/app/models/activity.ts: -------------------------------------------------------------------------------- 1 | import { Profile } from "./profile"; 2 | 3 | export interface Activity { 4 | id: string; 5 | title: string; 6 | description: string; 7 | category: string; 8 | date: Date | null; 9 | city: string; 10 | venue: string; 11 | hostUsername?: string; 12 | isCancelled?: boolean; 13 | isGoing?: boolean; 14 | isHost?: boolean 15 | attendees: Profile[] 16 | host?: Profile; 17 | } 18 | 19 | export class ActivityFormValues 20 | { 21 | id?: string = undefined; 22 | title: string = ''; 23 | category: string = ''; 24 | description: string = ''; 25 | date: Date | null = null; 26 | city: string = ''; 27 | venue: string = ''; 28 | 29 | constructor(activity?: ActivityFormValues) { 30 | if (activity) { 31 | this.id = activity.id; 32 | this.title = activity.title; 33 | this.category = activity.category; 34 | this.description = activity.description; 35 | this.date = activity.date; 36 | this.venue = activity.venue; 37 | this.city = activity.city; 38 | } 39 | } 40 | 41 | } 42 | 43 | export class Activity implements Activity { 44 | constructor(init?: ActivityFormValues) { 45 | Object.assign(this, init); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /client-app/src/app/models/comment.ts: -------------------------------------------------------------------------------- 1 | export interface ChatComment { 2 | id: number; 3 | createdAt: any; 4 | body: string; 5 | username: string; 6 | displayName: string; 7 | image: string; 8 | } 9 | -------------------------------------------------------------------------------- /client-app/src/app/models/pagination.ts: -------------------------------------------------------------------------------- 1 | export interface Pagination { 2 | currentPage: number; 3 | itemsPerPage: number; 4 | totalItems: number; 5 | totalPages: number; 6 | } 7 | 8 | export class PaginatedResult { 9 | data: T; 10 | pagination: Pagination; 11 | 12 | constructor(data: T, pagination: Pagination) { 13 | this.data = data; 14 | this.pagination = pagination; 15 | } 16 | } 17 | 18 | export class PagingParams { 19 | pageNumber; 20 | pageSize; 21 | 22 | constructor(pageNumber = 1, pageSize = 2) { 23 | this.pageNumber = pageNumber; 24 | this.pageSize = pageSize; 25 | } 26 | } -------------------------------------------------------------------------------- /client-app/src/app/models/profile.ts: -------------------------------------------------------------------------------- 1 | import { User } from "./user"; 2 | 3 | export interface Profile { 4 | username: string; 5 | displayName: string; 6 | image?: string; 7 | bio?: string; 8 | followersCount: number; 9 | followingCount: number; 10 | following: boolean; 11 | photos?: Photo[] 12 | } 13 | 14 | export class Profile implements Profile { 15 | constructor(user: User) { 16 | this.username = user.username; 17 | this.displayName = user.displayName; 18 | this.image = user.image 19 | } 20 | } 21 | 22 | export interface Photo { 23 | id: string; 24 | url: string; 25 | isMain: boolean; 26 | } 27 | 28 | export interface UserActivity { 29 | id: string; 30 | title: string; 31 | category: string; 32 | date: Date; 33 | } -------------------------------------------------------------------------------- /client-app/src/app/models/serverError.ts: -------------------------------------------------------------------------------- 1 | export interface ServerError { 2 | statusCode: number; 3 | message: string; 4 | details: string; 5 | } -------------------------------------------------------------------------------- /client-app/src/app/models/user.ts: -------------------------------------------------------------------------------- 1 | export interface User { 2 | username: string; 3 | displayName: string; 4 | token: string; 5 | image?: string; 6 | } 7 | 8 | export interface UserFormValues { 9 | email: string; 10 | password: string; 11 | displayName?: string; 12 | username?: string; 13 | } -------------------------------------------------------------------------------- /client-app/src/app/router/RequireAuth.tsx: -------------------------------------------------------------------------------- 1 | import { Navigate, Outlet, useLocation } from "react-router-dom"; 2 | import { useStore } from "../stores/store"; 3 | 4 | export default function RequireAuth() { 5 | const {userStore: {isLoggedIn}} = useStore(); 6 | const location = useLocation(); 7 | 8 | if (!isLoggedIn) { 9 | return 10 | } 11 | 12 | return 13 | } -------------------------------------------------------------------------------- /client-app/src/app/router/Routes.tsx: -------------------------------------------------------------------------------- 1 | import { createBrowserRouter, Navigate, RouteObject } from "react-router-dom"; 2 | import ActivityDashboard from "../../features/activities/dashboard/ActivityDashboard"; 3 | import ActivityDetails from "../../features/activities/details/ActivityDetails"; 4 | import ActivityForm from "../../features/activities/form/ActivityForm"; 5 | import NotFound from "../../features/errors/NotFound"; 6 | import ServerError from "../../features/errors/ServerError"; 7 | import TestErrors from "../../features/errors/TestError"; 8 | import ProfilePage from "../../features/profiles/ProfilePage"; 9 | import App from "../layout/App"; 10 | import RequireAuth from "./RequireAuth"; 11 | 12 | export const routes: RouteObject[] = [ 13 | { 14 | path: '/', 15 | element: , 16 | children: [ 17 | {element: , children: [ 18 | {path: 'activities', element: }, 19 | {path: 'activities/:id', element: }, 20 | {path: 'createActivity', element: }, 21 | {path: 'manage/:id', element: }, 22 | {path: 'profiles/:username', element: }, 23 | {path: 'errors', element: } 24 | ]}, 25 | {path: 'not-found', element: }, 26 | {path: 'server-error', element: }, 27 | {path: '*', element: }, 28 | ] 29 | } 30 | ] 31 | 32 | export const router = createBrowserRouter(routes); -------------------------------------------------------------------------------- /client-app/src/app/stores/commentStore.ts: -------------------------------------------------------------------------------- 1 | import { HubConnection, HubConnectionBuilder, LogLevel } from "@microsoft/signalr"; 2 | import { makeAutoObservable, runInAction } from "mobx"; 3 | import { ChatComment } from "../models/comment"; 4 | import { store } from "./store"; 5 | 6 | export default class CommentStore { 7 | comments: ChatComment[] = []; 8 | hubConnection: HubConnection | null = null; 9 | 10 | constructor() { 11 | makeAutoObservable(this); 12 | } 13 | 14 | createHubConnection = (activityId: string) => { 15 | if (store.activityStore.selectedActivity) { 16 | this.hubConnection = new HubConnectionBuilder() 17 | .withUrl(process.env.REACT_APP_CHAT_URL + '?activityId=' + activityId, { 18 | accessTokenFactory: () => store.userStore.user?.token! 19 | }) 20 | .withAutomaticReconnect() 21 | .configureLogging(LogLevel.Information) 22 | .build(); 23 | 24 | this.hubConnection.start().catch(error => console.log('Error establishing connection: ', error)); 25 | 26 | this.hubConnection.on('LoadComments', (comments: ChatComment[]) => { 27 | runInAction(() => { 28 | comments.forEach(comment => { 29 | comment.createdAt = new Date(comment.createdAt); 30 | }); 31 | this.comments = comments; 32 | }); 33 | }); 34 | 35 | this.hubConnection.on('ReceiveComment', comment => { 36 | runInAction(() => { 37 | comment.createdAt = new Date(comment.createdAt); 38 | this.comments.unshift(comment); 39 | }) 40 | }) 41 | } 42 | } 43 | 44 | stopHubConnection = () => { 45 | this.hubConnection?.stop().catch(error => console.log('Error stopping connection: ', error)); 46 | } 47 | 48 | clearComments = () => { 49 | this.comments = []; 50 | this.stopHubConnection(); 51 | } 52 | 53 | addComment = async (values: any) => { 54 | values.activityId = store.activityStore.selectedActivity?.id; 55 | try { 56 | await this.hubConnection?.invoke('SendComment', values); 57 | } catch (error) { 58 | console.log(error); 59 | } 60 | } 61 | } 62 | 63 | -------------------------------------------------------------------------------- /client-app/src/app/stores/commonStore.ts: -------------------------------------------------------------------------------- 1 | import { makeAutoObservable, reaction } from "mobx"; 2 | import { ServerError } from "../models/serverError"; 3 | 4 | export default class CommonStore { 5 | error: ServerError | null = null; 6 | token: string | null = localStorage.getItem('jwt'); 7 | appLoaded = false; 8 | 9 | constructor() { 10 | makeAutoObservable(this); 11 | 12 | reaction( 13 | () => this.token, 14 | token => { 15 | if (token) { 16 | localStorage.setItem('jwt', token) 17 | } else { 18 | localStorage.removeItem('jwt') 19 | } 20 | } 21 | ) 22 | } 23 | 24 | setServerError(error: ServerError) { 25 | this.error = error; 26 | } 27 | 28 | setToken = (token: string | null) => { 29 | this.token = token; 30 | } 31 | 32 | setAppLoaded = () => { 33 | this.appLoaded = true; 34 | } 35 | } -------------------------------------------------------------------------------- /client-app/src/app/stores/modalStore.ts: -------------------------------------------------------------------------------- 1 | import { makeAutoObservable } from "mobx" 2 | 3 | interface Modal { 4 | open: boolean; 5 | body: JSX.Element | null; 6 | } 7 | 8 | export default class ModalStore { 9 | modal: Modal = { 10 | open: false, 11 | body: null 12 | } 13 | 14 | constructor() { 15 | makeAutoObservable(this); 16 | } 17 | 18 | openModal = (content: JSX.Element) => { 19 | this.modal.open = true; 20 | this.modal.body = content; 21 | } 22 | 23 | closeModal = () => { 24 | this.modal.open = false; 25 | this.modal.body = null; 26 | } 27 | } -------------------------------------------------------------------------------- /client-app/src/app/stores/store.ts: -------------------------------------------------------------------------------- 1 | import ActivityStore from "./activityStore"; 2 | import {createContext, useContext} from "react"; 3 | import CommonStore from "./commonStore"; 4 | import UserStore from "./userStore"; 5 | import ModalStore from "./modalStore"; 6 | import ProfileStore from "./profileStore"; 7 | import CommentStore from "./commentStore"; 8 | 9 | interface Store { 10 | activityStore: ActivityStore; 11 | commonStore: CommonStore; 12 | userStore: UserStore; 13 | modalStore: ModalStore; 14 | profileStore: ProfileStore; 15 | commentStore: CommentStore; 16 | } 17 | 18 | export const store: Store = { 19 | activityStore: new ActivityStore(), 20 | commonStore: new CommonStore(), 21 | userStore: new UserStore(), 22 | modalStore: new ModalStore(), 23 | profileStore: new ProfileStore(), 24 | commentStore: new CommentStore() 25 | } 26 | 27 | export const StoreContext = createContext(store); 28 | 29 | export function useStore() { 30 | return useContext(StoreContext); 31 | } -------------------------------------------------------------------------------- /client-app/src/app/stores/userStore.ts: -------------------------------------------------------------------------------- 1 | import { makeAutoObservable, runInAction } from "mobx"; 2 | import agent from "../api/agent"; 3 | import { User, UserFormValues } from "../models/user"; 4 | import { router } from "../router/Routes"; 5 | import { store } from "./store"; 6 | 7 | export default class UserStore { 8 | user: User | null = null; 9 | 10 | constructor() { 11 | makeAutoObservable(this) 12 | } 13 | 14 | get isLoggedIn() { 15 | return !!this.user; 16 | } 17 | 18 | login = async (creds: UserFormValues) => { 19 | try { 20 | const user = await agent.Account.login(creds); 21 | store.commonStore.setToken(user.token); 22 | runInAction(() => this.user = user); 23 | router.navigate('/activities'); 24 | store.modalStore.closeModal(); 25 | } catch (error) { 26 | throw error; 27 | } 28 | } 29 | 30 | register = async (creds: UserFormValues) => { 31 | try { 32 | const user = await agent.Account.register(creds); 33 | store.commonStore.setToken(user.token); 34 | runInAction(() => this.user = user); 35 | router.navigate('/activities'); 36 | store.modalStore.closeModal(); 37 | } catch (error) { 38 | throw error; 39 | } 40 | } 41 | 42 | 43 | logout = () => { 44 | store.commonStore.setToken(null); 45 | this.user = null; 46 | router.navigate('/'); 47 | } 48 | 49 | getUser = async () => { 50 | try { 51 | const user = await agent.Account.current(); 52 | runInAction(() => this.user = user); 53 | } catch (error) { 54 | console.log(error); 55 | } 56 | } 57 | 58 | setImage = (image: string) => { 59 | if (this.user) this.user.image = image; 60 | } 61 | 62 | setUserPhoto = (url: string) => { 63 | if (this.user) this.user.image = url; 64 | } 65 | 66 | setDisplayName = (name: string) => { 67 | if (this.user) this.user.displayName = name; 68 | } 69 | } -------------------------------------------------------------------------------- /client-app/src/features/activities/dashboard/ActivityDashboard.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from 'mobx-react-lite'; 2 | import { useEffect, useState } from 'react'; 3 | import InfiniteScroll from 'react-infinite-scroller'; 4 | import { Grid, Loader } from 'semantic-ui-react'; 5 | import { PagingParams } from '../../../app/models/pagination'; 6 | import { useStore } from '../../../app/stores/store'; 7 | import ActivityFilters from './ActivityFilters'; 8 | import ActivityList from './ActivityList'; 9 | import ActivityListItemPlaceholder from './ActivityListItemPlaceHolder'; 10 | 11 | export default observer(function ActivityDashboard() { 12 | const { activityStore } = useStore(); 13 | const { loadActivities, setPagingParams, pagination } = activityStore; 14 | const [loadingNext, setLoadingNext] = useState(false); 15 | 16 | function handleGetNext() { 17 | setLoadingNext(true); 18 | setPagingParams(new PagingParams(pagination!.currentPage + 1)); 19 | loadActivities().then(() => setLoadingNext(false)); 20 | } 21 | 22 | useEffect(() => { 23 | loadActivities(); 24 | }, [loadActivities]) 25 | 26 | return ( 27 | 28 | 29 | {activityStore.loadingInitial && !loadingNext ? ( 30 | <> 31 | 32 | 33 | > 34 | ) : ( 35 | 41 | 42 | 43 | )} 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | ) 53 | }) 54 | -------------------------------------------------------------------------------- /client-app/src/features/activities/dashboard/ActivityFilters.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from 'mobx-react-lite'; 2 | import React from 'react'; 3 | import Calendar from 'react-calendar'; 4 | import { Header, Menu } from 'semantic-ui-react'; 5 | import { useStore } from '../../../app/stores/store'; 6 | 7 | export default observer(function ActivityFilters() { 8 | const {activityStore: {predicate, setPredicate}} = useStore(); 9 | return ( 10 | <> 11 | 12 | 13 | setPredicate('all', 'true')} 17 | /> 18 | setPredicate('isGoing', 'true')} 22 | /> 23 | setPredicate('isHost', 'true')} 27 | /> 28 | 29 | 30 | setPredicate('startDate', date as Date)} 32 | /> 33 | > 34 | ) 35 | }) 36 | -------------------------------------------------------------------------------- /client-app/src/features/activities/dashboard/ActivityList.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from 'mobx-react-lite'; 2 | import { Fragment } from 'react'; 3 | import { Header } from "semantic-ui-react"; 4 | import { useStore } from '../../../app/stores/store'; 5 | import ActivityListItem from './ActivityListItem'; 6 | 7 | export default observer(function ActivityList() { 8 | const { activityStore } = useStore(); 9 | const { groupedActivities } = activityStore; 10 | 11 | return ( 12 | <> 13 | {groupedActivities.map(([group, activities]) => ( 14 | 15 | 16 | {group} 17 | 18 | {activities && activities.map(activity => ( 19 | 20 | ))} 21 | 22 | ))} 23 | > 24 | 25 | ) 26 | }) 27 | -------------------------------------------------------------------------------- /client-app/src/features/activities/dashboard/ActivityListItem.tsx: -------------------------------------------------------------------------------- 1 | import { format } from "date-fns"; 2 | import { Link } from "react-router-dom"; 3 | import { Item, Button, Icon, Segment, Label } from "semantic-ui-react"; 4 | import { Activity } from "../../../app/models/activity"; 5 | import ActivityListItemAttendee from "./ActivityListItemAttendee"; 6 | 7 | interface Props { 8 | activity: Activity 9 | } 10 | 11 | export default function ActivityListItem({ activity }: Props) { 12 | return ( 13 | 14 | 15 | {activity.isCancelled && 16 | } 17 | 18 | 19 | 21 | 22 | 23 | {activity.title} 24 | 25 | Hosted by {activity.host?.displayName} 26 | {activity.isHost && ( 27 | 28 | 29 | You are hosting this activity! 30 | 31 | 32 | )} 33 | {activity.isGoing && !activity.isHost && ( 34 | 35 | 36 | You are going to this activity! 37 | 38 | 39 | )} 40 | 41 | 42 | 43 | 44 | 45 | 46 | {format(activity.date!, 'dd MMM yyyy h:mm aa')} 47 | {activity.venue} 48 | 49 | 50 | 51 | 52 | 53 | 54 | {activity.description} 55 | 62 | 63 | 64 | ) 65 | } -------------------------------------------------------------------------------- /client-app/src/features/activities/dashboard/ActivityListItemAttendee.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Image, List, Popup } from "semantic-ui-react"; 3 | import { observer } from "mobx-react-lite"; 4 | import { Profile } from '../../../app/models/profile'; 5 | import { Link } from 'react-router-dom'; 6 | import ProfileCard from '../../profiles/ProfileCard'; 7 | 8 | interface Props { 9 | attendees: Profile[]; 10 | } 11 | 12 | export default observer(function ActivityListItemAttendee({ attendees }: Props) { 13 | const styles = { 14 | borderColor: 'orange', 15 | borderWidth: 3 16 | } 17 | return ( 18 | 19 | {attendees.map(attendee => ( 20 | 25 | 30 | 31 | } 32 | > 33 | 34 | 35 | 36 | 37 | ))} 38 | 39 | ) 40 | }) -------------------------------------------------------------------------------- /client-app/src/features/activities/dashboard/ActivityListItemPlaceHolder.tsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from 'react'; 2 | import { Segment, Button, Placeholder } from 'semantic-ui-react'; 3 | 4 | export default function ActivityListItemPlaceholder() { 5 | return ( 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | ); 34 | }; -------------------------------------------------------------------------------- /client-app/src/features/activities/details/ActivityDetailedChat.tsx: -------------------------------------------------------------------------------- 1 | import { Formik, Form, Field, FieldProps } from 'formik'; 2 | import { observer } from 'mobx-react-lite' 3 | import { useEffect } from 'react' 4 | import { Link } from 'react-router-dom'; 5 | import { Segment, Header, Comment, Loader } from 'semantic-ui-react' 6 | import { useStore } from '../../../app/stores/store'; 7 | import * as Yup from 'yup'; 8 | import { formatDistanceToNow } from 'date-fns'; 9 | 10 | interface Props { 11 | activityId: string; 12 | } 13 | 14 | export default observer(function ActivityDetailedChat({ activityId }: Props) { 15 | const { commentStore } = useStore(); 16 | 17 | useEffect(() => { 18 | if (activityId) { 19 | commentStore.createHubConnection(activityId); 20 | } 21 | return () => { 22 | commentStore.clearComments(); 23 | } 24 | }, [commentStore, activityId]); 25 | 26 | return ( 27 | <> 28 | 35 | Chat about this event 36 | 37 | 38 | 40 | commentStore.addComment(values).then(() => resetForm())} 41 | initialValues={{ body: '' }} 42 | validationSchema={Yup.object({ 43 | body: Yup.string().required() 44 | })} 45 | > 46 | {({ isSubmitting, isValid, handleSubmit }) => ( 47 | 48 | 49 | {(props: FieldProps) => ( 50 | 51 | 52 | { 57 | if (e.key === 'Enter' && e.shiftKey) { 58 | return; 59 | } 60 | if (e.key === 'Enter' && !e.shiftKey) { 61 | e.preventDefault(); 62 | isValid && handleSubmit(); 63 | } 64 | }} 65 | /> 66 | 67 | )} 68 | 69 | 70 | )} 71 | 72 | 73 | {commentStore.comments.map(comment => ( 74 | 75 | 76 | 77 | {comment.displayName} 78 | 79 | {formatDistanceToNow(comment.createdAt)} ago 80 | 81 | {comment.body} 82 | 83 | 84 | ))} 85 | 86 | 87 | 88 | 89 | > 90 | 91 | ) 92 | }) -------------------------------------------------------------------------------- /client-app/src/features/activities/details/ActivityDetailedHeader.tsx: -------------------------------------------------------------------------------- 1 | import { format } from 'date-fns'; 2 | import { observer } from 'mobx-react-lite'; 3 | import React from 'react' 4 | import { Link } from 'react-router-dom'; 5 | import { Button, Header, Item, Segment, Image, Label } from 'semantic-ui-react' 6 | import { Activity } from "../../../app/models/activity"; 7 | import { useStore } from '../../../app/stores/store'; 8 | 9 | const activityImageStyle = { 10 | filter: 'brightness(30%)' 11 | }; 12 | 13 | const activityImageTextStyle = { 14 | position: 'absolute', 15 | bottom: '5%', 16 | left: '5%', 17 | width: '100%', 18 | height: 'auto', 19 | color: 'white' 20 | }; 21 | 22 | interface Props { 23 | activity: Activity 24 | } 25 | 26 | export default observer(function ActivityDetailedHeader({ activity }: Props) { 27 | const { activityStore: { updateAttendeance, loading, cancelActivityToggle } } = useStore(); 28 | return ( 29 | 30 | 31 | {activity.isCancelled && 32 | } 34 | 35 | 36 | 37 | 38 | 39 | 44 | {format(activity.date!, 'dd MMM yyyy')} 45 | 46 | Hosted by {activity.hostUsername} 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | {activity.isHost ? ( 55 | <> 56 | 64 | 71 | Manage Event 72 | 73 | > 74 | 75 | ) : activity.isGoing ? ( 76 | Cancel attendance 78 | ) : ( 79 | Join Activity 81 | )} 82 | 83 | 84 | ) 85 | }) -------------------------------------------------------------------------------- /client-app/src/features/activities/details/ActivityDetailedInfo.tsx: -------------------------------------------------------------------------------- 1 | import { format } from 'date-fns'; 2 | import { observer } from 'mobx-react-lite'; 3 | import React from 'react' 4 | import { Segment, Grid, Icon } from 'semantic-ui-react' 5 | import { Activity } from "../../../app/models/activity"; 6 | 7 | interface Props { 8 | activity: Activity 9 | } 10 | 11 | export default observer(function ActivityDetailedInfo({ activity }: Props) { 12 | return ( 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | {activity.description} 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | {format(activity.date!, 'dd MMM yyyy h:mm aa')} 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | {activity.venue}, {activity.city} 43 | 44 | 45 | 46 | 47 | ) 48 | }) -------------------------------------------------------------------------------- /client-app/src/features/activities/details/ActivityDetailedSidebar.tsx: -------------------------------------------------------------------------------- 1 | import { Segment, List, Label, Item, Image } from 'semantic-ui-react' 2 | import { Link } from 'react-router-dom' 3 | import { observer } from 'mobx-react-lite' 4 | import { Activity } from '../../../app/models/activity' 5 | 6 | interface Props { 7 | activity: Activity 8 | } 9 | 10 | export default observer(function ActivityDetailedSidebar ({activity: {attendees, host}}: Props) { 11 | if (!attendees) return null; 12 | return ( 13 | <> 14 | 22 | {attendees.length} {attendees.length === 1 ? 'Person' : 'People'} going 23 | 24 | 25 | 26 | {attendees.map(attendee => ( 27 | 28 | {attendee.username === host?.username && 29 | 34 | Host 35 | } 36 | 37 | 38 | 39 | {attendee.displayName} 40 | 41 | {attendee.following && 42 | Following} 43 | 44 | 45 | ))} 46 | 47 | 48 | > 49 | 50 | ) 51 | }) 52 | -------------------------------------------------------------------------------- /client-app/src/features/activities/details/ActivityDetails.tsx: -------------------------------------------------------------------------------- 1 | import { Grid } from "semantic-ui-react"; 2 | import { useStore } from '../../../app/stores/store'; 3 | import { observer } from 'mobx-react-lite'; 4 | import LoadingComponent from '../../../app/layout/LoadingComponent'; 5 | import { useParams } from "react-router-dom"; 6 | import { useEffect } from "react"; 7 | import ActivityDetailedChat from "./ActivityDetailedChat"; 8 | import ActivityDetailedHeader from "./ActivityDetailedHeader"; 9 | import ActivityDetailedInfo from "./ActivityDetailedInfo"; 10 | import ActivityDetailedSidebar from "./ActivityDetailedSidebar"; 11 | 12 | export default observer(function ActivityDetails() { 13 | const { activityStore } = useStore(); 14 | const { selectedActivity: activity, loadActivity, loadingInitial, clearSelectedActivity } = activityStore; 15 | const { id } = useParams(); 16 | 17 | useEffect(() => { 18 | if (id) loadActivity(id); 19 | return () => clearSelectedActivity(); 20 | }, [id, loadActivity, clearSelectedActivity]); 21 | 22 | if (loadingInitial || !activity) return 23 | 24 | return ( 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | ) 36 | }) -------------------------------------------------------------------------------- /client-app/src/features/activities/form/ActivityForm.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from 'mobx-react-lite'; 2 | import { useEffect, useState } from 'react'; 3 | import { Link, useNavigate, useParams } from 'react-router-dom'; 4 | import { Button, Header, Segment } from "semantic-ui-react"; 5 | import LoadingComponent from '../../../app/layout/LoadingComponent'; 6 | import { useStore } from '../../../app/stores/store'; 7 | import { v4 as uuid } from 'uuid'; 8 | import { Formik, Form } from 'formik'; 9 | import * as Yup from 'yup'; 10 | import MyTextInput from '../../../app/common/form/MyTextInput'; 11 | import MyTextArea from '../../../app/common/form/MyTextArea'; 12 | import MySelectInput from '../../../app/common/form/MySelectInput'; 13 | import { categoryOptions } from '../../../app/common/options/categoryOptions'; 14 | import MyDateInput from '../../../app/common/form/MyDateInput'; 15 | import { ActivityFormValues } from '../../../app/models/activity'; 16 | 17 | export default observer(function ActivityForm() { 18 | const { activityStore } = useStore(); 19 | const { createActivity, updateActivity, loadActivity, loadingInitial } = activityStore; 20 | const { id } = useParams(); 21 | const navigate = useNavigate(); 22 | 23 | const [activity, setActivity] = useState(new ActivityFormValues()); 24 | 25 | const validationSchema = Yup.object({ 26 | title: Yup.string().required('The event title is required'), 27 | category: Yup.string().required('The event category is required'), 28 | description: Yup.string().required(), 29 | date: Yup.string().required('Date is required').nullable(), 30 | venue: Yup.string().required(), 31 | city: Yup.string().required(), 32 | }) 33 | 34 | useEffect(() => { 35 | if (id) loadActivity(id).then(activity => setActivity(new ActivityFormValues(activity))) 36 | }, [id, loadActivity]) 37 | 38 | function handleFormSubmit(activity: ActivityFormValues) { 39 | if (!activity.id) { 40 | let newActivity = { 41 | ...activity, 42 | id: uuid() 43 | } 44 | createActivity(newActivity).then(() => navigate(`/activities/${newActivity.id}`)) 45 | } else { 46 | updateActivity(activity).then(() => navigate(`/activities/${activity.id}`)) 47 | } 48 | } 49 | 50 | if (loadingInitial) return 51 | 52 | return ( 53 | 54 | 55 | handleFormSubmit(values)}> 60 | {({ handleSubmit, isValid, isSubmitting, dirty }) => ( 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 77 | 78 | 79 | )} 80 | 81 | 82 | ) 83 | }) -------------------------------------------------------------------------------- /client-app/src/features/errors/NotFound.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "react-router-dom"; 2 | import { Button, Header, Icon, Segment } from "semantic-ui-react"; 3 | 4 | export default function NotFound() { 5 | return ( 6 | 7 | 8 | 9 | Oops - we've looked everywhere but could not find what you are looking for! 10 | 11 | 12 | 13 | Return to activities page 14 | 15 | 16 | 17 | ) 18 | } -------------------------------------------------------------------------------- /client-app/src/features/errors/ServerError.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from "mobx-react-lite"; 2 | import { Container, Header, Segment } from "semantic-ui-react"; 3 | import { useStore } from "../../app/stores/store"; 4 | 5 | export default observer(function ServerError() { 6 | const {commonStore} = useStore(); 7 | return ( 8 | 9 | 10 | 11 | {commonStore.error?.details && ( 12 | 13 | 14 | {commonStore.error.details} 15 | 16 | )} 17 | 18 | ) 19 | }) -------------------------------------------------------------------------------- /client-app/src/features/errors/TestError.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import {Button, Header, Segment} from "semantic-ui-react"; 3 | import axios from 'axios'; 4 | import ValidationError from './ValidationError'; 5 | 6 | export default function TestErrors() { 7 | const [errors, setErrors] = useState(null); 8 | 9 | function handleNotFound() { 10 | axios.get('/buggy/not-found').catch(err => console.log(err.response)); 11 | } 12 | 13 | function handleBadRequest() { 14 | axios.get('/buggy/bad-request').catch(err => console.log(err.response)); 15 | } 16 | 17 | function handleServerError() { 18 | axios.get('/buggy/server-error').catch(err => console.log(err.response)); 19 | } 20 | 21 | function handleUnauthorised() { 22 | axios.get('/buggy/unauthorised').catch(err => console.log(err.response)); 23 | } 24 | 25 | function handleBadGuid() { 26 | axios.get('/activities/notaguid').catch(err => console.log(err.response)); 27 | } 28 | 29 | function handleValidationError() { 30 | axios.post('/activities', {}).catch(err => setErrors(err)); 31 | } 32 | 33 | return ( 34 | <> 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | {errors && } 47 | > 48 | ) 49 | } -------------------------------------------------------------------------------- /client-app/src/features/errors/ValidationError.tsx: -------------------------------------------------------------------------------- 1 | import { Message } from "semantic-ui-react"; 2 | 3 | interface Props { 4 | errors: any; 5 | } 6 | 7 | export default function ValidationError({errors}: Props) { 8 | return ( 9 | 10 | {errors && ( 11 | 12 | {errors.map((err: string, i: any) => ( 13 | {err} 14 | ))} 15 | 16 | )} 17 | 18 | ) 19 | } -------------------------------------------------------------------------------- /client-app/src/features/home/HomePage.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from 'mobx-react-lite'; 2 | import React from 'react'; 3 | import { Link } from 'react-router-dom'; 4 | import { Button, Container, Header, Segment, Image } from "semantic-ui-react"; 5 | import { useStore } from '../../app/stores/store'; 6 | import LoginForm from '../users/LoginForm'; 7 | import RegsiterForm from '../users/RegsiterForm'; 8 | 9 | export default observer(function HomePage() { 10 | const { userStore, modalStore } = useStore(); 11 | return ( 12 | 13 | 14 | 15 | 16 | Reactivities 17 | 18 | {userStore.isLoggedIn ? ( 19 | <> 20 | 21 | 22 | Go to activities! 23 | 24 | > 25 | ) : ( 26 | <> 27 | modalStore.openModal()} size='huge' inverted> 28 | Login! 29 | 30 | modalStore.openModal()} size='huge' inverted> 31 | Register 32 | 33 | > 34 | 35 | )} 36 | 37 | 38 | ) 39 | }) -------------------------------------------------------------------------------- /client-app/src/features/profiles/FollowButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { SyntheticEvent } from 'react'; 2 | import {observer} from "mobx-react-lite"; 3 | import {Button, Reveal} from "semantic-ui-react"; 4 | import { useStore } from '../../app/stores/store'; 5 | import { Profile } from '../../app/models/profile'; 6 | 7 | interface Props { 8 | profile: Profile; 9 | } 10 | 11 | export default observer(function FollowButton({profile}: Props) { 12 | const {profileStore, userStore} = useStore(); 13 | const {updateFollowing, loading} = profileStore; 14 | 15 | if (userStore.user?.username === profile.username) return null; 16 | 17 | function handleFollow(e: SyntheticEvent, username: string) { 18 | e.preventDefault(); 19 | profile.following ? updateFollowing(username, false) : updateFollowing(username, true); 20 | ; 21 | } 22 | 23 | 24 | return ( 25 | 26 | 27 | 32 | 33 | 34 | handleFollow(e, profile.username)} 41 | /> 42 | 43 | 44 | ) 45 | }) -------------------------------------------------------------------------------- /client-app/src/features/profiles/ProfileAbout.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { useStore } from "../../app/stores/store"; 3 | import { Button, Grid, Header, Tab } from "semantic-ui-react"; 4 | import ProfileEditForm from "./ProfileEditForm"; 5 | import { observer } from 'mobx-react-lite'; 6 | 7 | export default observer(function ProfileAbout() { 8 | const { profileStore } = useStore(); 9 | const { isCurrentUser, profile } = profileStore; 10 | const [editMode, setEditMode] = useState(false); 11 | 12 | return ( 13 | 14 | 15 | 16 | 20 | {isCurrentUser && ( 21 | setEditMode(!editMode)} 26 | />)} 27 | 28 | 29 | {editMode ? : 30 | {profile?.bio}} 31 | 32 | 33 | 34 | ) 35 | }) -------------------------------------------------------------------------------- /client-app/src/features/profiles/ProfileActivities.tsx: -------------------------------------------------------------------------------- 1 | import React, { SyntheticEvent, useEffect } from 'react'; 2 | import { observer } from 'mobx-react-lite'; 3 | import { Tab, Grid, Header, Card, Image, TabProps } from 'semantic-ui-react'; 4 | import { Link } from 'react-router-dom'; 5 | import { UserActivity } from '../../app/models/profile'; 6 | import { format } from 'date-fns'; 7 | import { useStore } from "../../app/stores/store"; 8 | 9 | const panes = [ 10 | { menuItem: 'Future Events', pane: { key: 'future' } }, 11 | { menuItem: 'Past Events', pane: { key: 'past' } }, 12 | { menuItem: 'Hosting', pane: { key: 'hosting' } } 13 | ]; 14 | 15 | export default observer(function ProfileActivities() { 16 | const { profileStore } = useStore(); 17 | const { 18 | loadUserActivities, 19 | profile, 20 | loadingActivities, 21 | userActivities 22 | } = profileStore; 23 | 24 | useEffect(() => { 25 | loadUserActivities(profile!.username); 26 | }, [loadUserActivities, profile]); 27 | 28 | const handleTabChange = (e: SyntheticEvent, data: TabProps) => { 29 | loadUserActivities(profile!.username, panes[data.activeIndex as number].pane.key); 30 | }; 31 | 32 | return ( 33 | 34 | 35 | 36 | 37 | 38 | 39 | handleTabChange(e, data)} 43 | /> 44 | 45 | 46 | {userActivities.map((activity: UserActivity) => ( 47 | 52 | 56 | 57 | {activity.title} 58 | 59 | {format(new Date(activity.date), 'do LLL')} 60 | {format(new Date(activity.date), 'h:mm a')} 61 | 62 | 63 | 64 | ))} 65 | 66 | 67 | 68 | 69 | ); 70 | }); -------------------------------------------------------------------------------- /client-app/src/features/profiles/ProfileCard.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Card, Icon, Image } from "semantic-ui-react"; 3 | import { Profile } from "../../app/models/profile"; 4 | import { observer } from "mobx-react-lite"; 5 | import { Link } from "react-router-dom"; 6 | import FollowButton from './FollowButton'; 7 | 8 | interface Props { 9 | profile: Profile 10 | } 11 | 12 | export default observer(function ProfileCard({ profile }: Props) { 13 | function truncate(str: string | undefined) { 14 | if (str) { 15 | return str.length > 40 ? str.substring(0, 37) + '...' : str; 16 | } 17 | } 18 | 19 | return ( 20 | 21 | 22 | 23 | {profile.displayName} 24 | 25 | {truncate(profile.bio)} 26 | 27 | 28 | 29 | 30 | {profile.followersCount} Followers 31 | 32 | 33 | 34 | ) 35 | }) -------------------------------------------------------------------------------- /client-app/src/features/profiles/ProfileContent.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from 'mobx-react-lite'; 2 | import { Tab } from 'semantic-ui-react'; 3 | import { Profile } from '../../app/models/profile'; 4 | import { useStore } from '../../app/stores/store'; 5 | import ProfileAbout from './ProfileAbout'; 6 | import ProfileActivities from './ProfileActivities'; 7 | import ProfileFollowings from './ProfileFollowings'; 8 | import ProfilePhotos from './ProfilePhotos'; 9 | 10 | interface Props { 11 | profile: Profile 12 | } 13 | 14 | export default observer(function ProfileContent({ profile }: Props) { 15 | const {profileStore} = useStore(); 16 | 17 | const panes = [ 18 | { menuItem: 'About', render: () => }, 19 | { menuItem: 'Photos', render: () => }, 20 | { menuItem: 'Events', render: () => }, 21 | { menuItem: 'Followers', render: () => }, 22 | { menuItem: 'Following', render: () => }, 23 | ]; 24 | 25 | return ( 26 | profileStore.setActiveTab(data.activeIndex)} 31 | /> 32 | ) 33 | }) 34 | -------------------------------------------------------------------------------- /client-app/src/features/profiles/ProfileEditForm.tsx: -------------------------------------------------------------------------------- 1 | import { Form, Formik } from "formik"; 2 | import { observer } from "mobx-react-lite"; 3 | import { Button } from "semantic-ui-react"; 4 | import MyTextArea from "../../app/common/form/MyTextArea"; 5 | import MyTextInput from "../../app/common/form/MyTextInput"; 6 | import { useStore } from "../../app/stores/store"; 7 | import * as Yup from 'yup'; 8 | 9 | interface Props { 10 | setEditMode: (editMode: boolean) => void; 11 | } 12 | 13 | export default observer(function ProfileEditForm({ setEditMode }: Props) { 14 | const { profileStore: { profile, updateProfile } } = useStore(); 15 | return ( 16 | { 22 | updateProfile(values).then(() => { 23 | setEditMode(false); 24 | }) 25 | }} 26 | validationSchema={Yup.object({ 27 | displayName: Yup.string().required() 28 | })} > 29 | {({ isSubmitting, isValid, dirty }) => ( 30 | 31 | 35 | 36 | 44 | 45 | )} 46 | 47 | ) 48 | }) -------------------------------------------------------------------------------- /client-app/src/features/profiles/ProfileFollowings.tsx: -------------------------------------------------------------------------------- 1 | import {Tab, Grid, Header, Card} from "semantic-ui-react"; 2 | import ProfileCard from "./ProfileCard"; 3 | import {useStore} from "../../app/stores/store"; 4 | import { observer } from 'mobx-react-lite'; 5 | 6 | export default observer(function ProfileFollowings() { 7 | const {profileStore} = useStore(); 8 | const {profile, followings, loadingFollowings, activeTab} = profileStore; 9 | 10 | return ( 11 | 12 | 13 | 14 | 21 | 22 | 23 | 24 | {followings.map(profile => ( 25 | 26 | ))} 27 | 28 | 29 | 30 | 31 | ) 32 | }) 33 | -------------------------------------------------------------------------------- /client-app/src/features/profiles/ProfileHeader.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from 'mobx-react-lite'; 2 | import { Grid, Segment, Item, Header, Statistic, Divider } from "semantic-ui-react"; 3 | import { Profile } from '../../app/models/profile'; 4 | import FollowButton from './FollowButton'; 5 | 6 | interface Props { 7 | profile: Profile 8 | } 9 | 10 | export default observer(function ProfileHeader({ profile }: Props) { 11 | return ( 12 | 13 | 14 | 15 | 16 | 17 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | ) 40 | }) 41 | 42 | -------------------------------------------------------------------------------- /client-app/src/features/profiles/ProfilePage.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from "mobx-react-lite"; 2 | import { useEffect } from "react"; 3 | import { useParams } from "react-router-dom"; 4 | import { Grid } from "semantic-ui-react"; 5 | import LoadingComponent from "../../app/layout/LoadingComponent"; 6 | import { useStore } from "../../app/stores/store"; 7 | import ProfileContent from "./ProfileContent"; 8 | import ProfileHeader from "./ProfileHeader"; 9 | 10 | export default observer(function ProfilePage() { 11 | const {username} = useParams(); 12 | const {profileStore} = useStore(); 13 | const {loadingProfile, loadProfile, profile, setActiveTab} = profileStore; 14 | 15 | useEffect(() => { 16 | if (username) loadProfile(username); 17 | return () => { 18 | setActiveTab(0); 19 | } 20 | }, [loadProfile, username, setActiveTab]) 21 | 22 | if (loadingProfile) return 23 | 24 | if (!profile) return Problem loading profile 25 | 26 | return ( 27 | 28 | 29 | 30 | 31 | 32 | 33 | ) 34 | }) -------------------------------------------------------------------------------- /client-app/src/features/profiles/ProfilePhotos.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from "mobx-react-lite"; 2 | import { SyntheticEvent, useState } from "react"; 3 | import { Card, Header, Tab, Image, Grid, Button } from "semantic-ui-react"; 4 | import PhotoUploadWidget from "../../app/common/imageUpload/PhotoUploadWidget"; 5 | import { Photo, Profile } from "../../app/models/profile"; 6 | import { useStore } from "../../app/stores/store"; 7 | 8 | interface Props { 9 | profile: Profile 10 | } 11 | 12 | export default observer(function ProfilePhotos({ profile }: Props) { 13 | const { profileStore: { isCurrentUser, uploadPhoto, uploading, 14 | setMainPhoto, loading, deletePhoto } } = useStore(); 15 | const [addPhotoMode, setAddPhotoMode] = useState(false); 16 | const [target, setTarget] = useState(''); 17 | 18 | function handlePhotoUpload(file: any) { 19 | uploadPhoto(file).then(() => setAddPhotoMode(false)); 20 | } 21 | 22 | function handleSetMain(photo: Photo, e: SyntheticEvent) { 23 | setTarget(e.currentTarget.name); 24 | setMainPhoto(photo); 25 | } 26 | 27 | function handleDeletePhoto(photo: Photo, e: SyntheticEvent) { 28 | setTarget(e.currentTarget.name); 29 | deletePhoto(photo); 30 | } 31 | 32 | 33 | return ( 34 | 35 | 36 | 37 | 38 | {isCurrentUser && ( 39 | setAddPhotoMode(!addPhotoMode)} /> 41 | )} 42 | 43 | 44 | {addPhotoMode ? ( 45 | 46 | ) : ( 47 | 48 | {profile.photos?.map(photo => ( 49 | 50 | 51 | {isCurrentUser && ( 52 | 53 | handleSetMain(photo, e)} 61 | /> 62 | handleDeletePhoto(photo, e)} 66 | basic 67 | color='red' 68 | icon='trash' 69 | disabled={photo.isMain} 70 | /> 71 | 72 | )} 73 | 74 | ))} 75 | 76 | )} 77 | 78 | 79 | 80 | ) 81 | }) -------------------------------------------------------------------------------- /client-app/src/features/users/LoginForm.tsx: -------------------------------------------------------------------------------- 1 | import { ErrorMessage, Form, Formik } from "formik"; 2 | import { observer } from "mobx-react-lite"; 3 | import { Button, Header, Label } from "semantic-ui-react"; 4 | import MyTextInput from "../../app/common/form/MyTextInput"; 5 | import { useStore } from "../../app/stores/store"; 6 | 7 | export default observer(function LoginForm() { 8 | const { userStore } = useStore(); 9 | return ( 10 | 13 | userStore.login(values).catch(error => setErrors({ error: 'Invalid email or password' }))} 14 | > 15 | {({ handleSubmit, isSubmitting, errors }) => ( 16 | 17 | 18 | 19 | 20 | 21 | } /> 22 | 23 | 24 | )} 25 | 26 | 27 | ) 28 | }) -------------------------------------------------------------------------------- /client-app/src/features/users/RegsiterForm.tsx: -------------------------------------------------------------------------------- 1 | import { ErrorMessage, Form, Formik } from "formik"; 2 | import { observer } from "mobx-react-lite"; 3 | import { Button, Header } from "semantic-ui-react"; 4 | import MyTextInput from "../../app/common/form/MyTextInput"; 5 | import { useStore } from "../../app/stores/store"; 6 | import * as Yup from 'yup'; 7 | import ValidationError from "../errors/ValidationError"; 8 | 9 | export default observer(function RegsiterForm() { 10 | const { userStore } = useStore(); 11 | return ( 12 | 15 | userStore.register(values).catch(error => setErrors({ error: error }))} 16 | validationSchema={Yup.object({ 17 | displayName: Yup.string().required(), 18 | username: Yup.string().required(), 19 | email: Yup.string().required(), 20 | password: Yup.string().required(), 21 | })} 22 | > 23 | {({ handleSubmit, isSubmitting, errors, isValid, dirty }) => ( 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | } /> 32 | 38 | 39 | )} 40 | 41 | 42 | ) 43 | }) -------------------------------------------------------------------------------- /client-app/src/index.tsx: -------------------------------------------------------------------------------- 1 | import ReactDOM from 'react-dom/client'; 2 | import 'semantic-ui-css/semantic.min.css'; 3 | import 'react-calendar/dist/Calendar.css'; 4 | import 'react-toastify/dist/ReactToastify.min.css'; 5 | import "react-datepicker/dist/react-datepicker.css"; 6 | import './app/layout/styles.css'; 7 | import reportWebVitals from './reportWebVitals'; 8 | import { store, StoreContext } from './app/stores/store'; 9 | import { RouterProvider } from 'react-router-dom'; 10 | import { router } from './app/router/Routes'; 11 | 12 | const root = ReactDOM.createRoot( 13 | document.getElementById('root') as HTMLElement 14 | ); 15 | root.render( 16 | 17 | 18 | 19 | ); 20 | 21 | // If you want to start measuring performance in your app, pass a function 22 | // to log results (for example: reportWebVitals(console.log)) 23 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 24 | reportWebVitals(); 25 | -------------------------------------------------------------------------------- /client-app/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /client-app/src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from 'web-vitals'; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /client-app/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /client-app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx", 22 | "typeRoots": ["node_modules/yup"] 23 | }, 24 | "include": [ 25 | "src" 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | # fly.toml file generated for reactivities-course on 2022-12-04T14:03:10+07:00 2 | 3 | app = "reactivities-course" 4 | kill_signal = "SIGINT" 5 | kill_timeout = 5 6 | processes = [] 7 | 8 | [build] 9 | image = "trycatchlearn/reactivities:latest" 10 | 11 | [env] 12 | ASPNETCORE_URLS="http://+:8080" 13 | Cloudinary__CloudName="dj3wmuy3l" 14 | Cloudinary__ApiKey="895484589483755" 15 | 16 | [experimental] 17 | allowed_public_ports = [] 18 | auto_rollback = true 19 | 20 | [[services]] 21 | http_checks = [] 22 | internal_port = 8080 23 | processes = ["app"] 24 | protocol = "tcp" 25 | script_checks = [] 26 | [services.concurrency] 27 | hard_limit = 25 28 | soft_limit = 20 29 | type = "connections" 30 | 31 | [[services.ports]] 32 | force_https = true 33 | handlers = ["http"] 34 | port = 80 35 | 36 | [[services.ports]] 37 | handlers = ["tls", "http"] 38 | port = 443 39 | 40 | [[services.tcp_checks]] 41 | grace_period = "1s" 42 | interval = "15s" 43 | restart_limit = 0 44 | timeout = "2s" 45 | --------------------------------------------------------------------------------
{format(activity.date!, 'dd MMM yyyy')}
46 | Hosted by {activity.hostUsername} 47 |
{activity.description}
{commonStore.error.details}