├── .github └── workflows │ └── main_reactivities-course.yml ├── .gitignore ├── .vscode ├── launch.json └── settings.json ├── API ├── API.csproj ├── Controllers │ ├── AccountController.cs │ ├── ActivitiesController.cs │ ├── BaseApiController.cs │ ├── BuggyController.cs │ ├── FallbackController.cs │ ├── ProfilesController.cs │ └── WeatherForecastController.cs ├── DTOs │ ├── ChangePasswordDto.cs │ ├── GitHubInfo.cs │ └── RegisterDto.cs ├── Middleware │ └── ExceptionMiddleware.cs ├── Program.cs ├── Properties │ └── launchSettings.json ├── SignalR │ └── CommentHub.cs ├── WeatherForecast.cs ├── appsettings.Development.json ├── reactivities.db-wal └── wwwroot │ ├── assets │ ├── index-DKT2WX1o.css │ ├── index-u1bPwZyN.js │ ├── roboto-cyrillic-300-normal-DJfICpyc.woff2 │ ├── roboto-cyrillic-300-normal-Dg7J0kAT.woff │ ├── roboto-cyrillic-400-normal-BiRJyiea.woff2 │ ├── roboto-cyrillic-400-normal-JN0iKxGs.woff │ ├── roboto-cyrillic-500-normal-YnJLGrUm.woff │ ├── roboto-cyrillic-500-normal-_hamcpv8.woff2 │ ├── roboto-cyrillic-700-normal-BJaAVvFw.woff │ ├── roboto-cyrillic-700-normal-jruQITdB.woff2 │ ├── roboto-cyrillic-ext-300-normal-BLLmCegk.woff │ ├── roboto-cyrillic-ext-300-normal-Chhwl1Jq.woff2 │ ├── roboto-cyrillic-ext-400-normal-D76n7Daw.woff2 │ ├── roboto-cyrillic-ext-400-normal-b0JluIOJ.woff │ ├── roboto-cyrillic-ext-500-normal-37WQE4S0.woff │ ├── roboto-cyrillic-ext-500-normal-BJvL3D7h.woff2 │ ├── roboto-cyrillic-ext-700-normal-CyZgh00P.woff2 │ ├── roboto-cyrillic-ext-700-normal-DXzexxfu.woff │ ├── roboto-greek-300-normal-Bx8edVml.woff2 │ ├── roboto-greek-300-normal-D3gN5oZ1.woff │ ├── roboto-greek-400-normal-IIc_WWwF.woff │ ├── roboto-greek-400-normal-LPh2sqOm.woff2 │ ├── roboto-greek-500-normal-Bg8BLohm.woff2 │ ├── roboto-greek-500-normal-CdRewbqV.woff │ ├── roboto-greek-700-normal-1IZ-NEfb.woff │ ├── roboto-greek-700-normal-Bs05n1ZH.woff2 │ ├── roboto-latin-300-normal-BZ6gvbSO.woff │ ├── roboto-latin-300-normal-BizgZZ3y.woff2 │ ├── roboto-latin-400-normal-BVyCgWwA.woff │ ├── roboto-latin-400-normal-DXyFPIdK.woff2 │ ├── roboto-latin-500-normal-C6iW8rdg.woff2 │ ├── roboto-latin-500-normal-rpP1_v3s.woff │ ├── roboto-latin-700-normal-BWcFiwQV.woff │ ├── roboto-latin-700-normal-CbYYDfWS.woff2 │ ├── roboto-latin-ext-300-normal-BzRVPTS2.woff2 │ ├── roboto-latin-ext-300-normal-Djx841zm.woff │ ├── roboto-latin-ext-400-normal-BSFkPfbf.woff │ ├── roboto-latin-ext-400-normal-DgXbz5gU.woff2 │ ├── roboto-latin-ext-500-normal-DvHxAkTn.woff │ ├── roboto-latin-ext-500-normal-OQJhyaXd.woff2 │ ├── roboto-latin-ext-700-normal-Ba-CAIIA.woff │ ├── roboto-latin-ext-700-normal-DchBbzVz.woff2 │ ├── roboto-vietnamese-300-normal-CAomnZLO.woff │ ├── roboto-vietnamese-300-normal-PZa9KE_J.woff2 │ ├── roboto-vietnamese-400-normal-D5pJwT9g.woff │ ├── roboto-vietnamese-400-normal-DhTUfTw_.woff2 │ ├── roboto-vietnamese-500-normal-LvuCHq7y.woff │ ├── roboto-vietnamese-500-normal-p0V0BAAE.woff2 │ ├── roboto-vietnamese-700-normal-B4Nagvlm.woff │ └── roboto-vietnamese-700-normal-CBbheh0s.woff2 │ ├── images │ ├── categoryImages │ │ ├── culture.jpg │ │ ├── drinks.jpg │ │ ├── film.jpg │ │ ├── food.jpg │ │ ├── music.jpg │ │ └── travel.jpg │ ├── logo.png │ ├── placeholder.png │ └── user.png │ ├── index.html │ └── vite.svg ├── Application ├── Activities │ ├── Commands │ │ ├── AddComment.cs │ │ ├── CreateActivity.cs │ │ ├── DeleteActivity.cs │ │ ├── EditActivity.cs │ │ └── UpdateAttendance.cs │ ├── DTOs │ │ ├── ActivityDto.cs │ │ ├── BaseActivityDto.cs │ │ ├── CommentDto.cs │ │ ├── CreateActivityDto.cs │ │ └── EditActivityDto.cs │ ├── Queries │ │ ├── ActivityParams.cs │ │ ├── GetActivityDetails.cs │ │ ├── GetActivityList.cs │ │ └── GetComments.cs │ └── Validators │ │ ├── BaseActivityValidator.cs │ │ ├── CreateActivityValidator.cs │ │ └── EditActivityValidator.cs ├── Application.csproj ├── Core │ ├── AppException.cs │ ├── MappingProfiles.cs │ ├── PagedList.cs │ ├── PaginationParams.cs │ ├── Result.cs │ └── ValidationBehavior.cs ├── Interfaces │ ├── IPhotoService.cs │ └── IUserAccessor.cs └── Profiles │ ├── Commands │ ├── AddPhoto.cs │ ├── DeletePhoto.cs │ ├── EditProfile.cs │ ├── FollowToggle.cs │ └── SetMainPhoto.cs │ ├── DTOs │ ├── UserActivityDto.cs │ └── UserProfile.cs │ ├── Queries │ ├── GetFollowings.cs │ ├── GetProfile.cs │ ├── GetProfilePhotos.cs │ └── GetUserActivities.cs │ └── Validators │ └── EditProfileValidator.cs ├── Domain ├── Activity.cs ├── ActivityAttendee.cs ├── Comment.cs ├── Domain.csproj ├── Photo.cs ├── User.cs └── UserFollowing.cs ├── Infrastructure ├── Email │ └── EmailSender.cs ├── Infrastructure.csproj ├── Photos │ ├── CloudinarySettings.cs │ └── PhotoService.cs └── Security │ ├── IsHostRequirement.cs │ └── UserAccessor.cs ├── Persistence ├── AppDbContext.cs ├── DbInitializer.cs ├── Migrations │ ├── 20250127063932_InitialSql.Designer.cs │ ├── 20250127063932_InitialSql.cs │ └── AppDbContextModelSnapshot.cs └── Persistence.csproj ├── README.md ├── Reactivities.sln ├── client ├── .env.development ├── .env.production ├── .gitignore ├── README.md ├── eslint.config.js ├── index.html ├── package-lock.json ├── package.json ├── public │ ├── images │ │ ├── categoryImages │ │ │ ├── culture.jpg │ │ │ ├── drinks.jpg │ │ │ ├── film.jpg │ │ │ ├── food.jpg │ │ │ ├── music.jpg │ │ │ └── travel.jpg │ │ ├── logo.png │ │ ├── placeholder.png │ │ └── user.png │ └── vite.svg ├── src │ ├── app │ │ ├── layout │ │ │ ├── App.tsx │ │ │ ├── NavBar.tsx │ │ │ ├── UserMenu.tsx │ │ │ └── styles.css │ │ ├── router │ │ │ ├── RequireAuth.tsx │ │ │ └── Routes.tsx │ │ └── shared │ │ │ └── components │ │ │ ├── AvatarPopover.tsx │ │ │ ├── DateTimeInput.tsx │ │ │ ├── DeleteButton.tsx │ │ │ ├── LocationInput.tsx │ │ │ ├── MapComponent.tsx │ │ │ ├── MenuItemLink.tsx │ │ │ ├── PhotoUploadWidget.tsx │ │ │ ├── SelectInput.tsx │ │ │ ├── StarButton.tsx │ │ │ ├── StyledButton.tsx │ │ │ └── TextInput.tsx │ ├── features │ │ ├── account │ │ │ ├── AccountFormWrapper.tsx │ │ │ ├── AuthCallback.tsx │ │ │ ├── ChangePasswordForm.tsx │ │ │ ├── ForgotPasswordForm.tsx │ │ │ ├── LoginForm.tsx │ │ │ ├── RegisterForm.tsx │ │ │ ├── RegisterSuccess.tsx │ │ │ ├── ResetPasswordForm.tsx │ │ │ └── VerifyEmail.tsx │ │ ├── activities │ │ │ ├── dashboard │ │ │ │ ├── ActivityCard.tsx │ │ │ │ ├── ActivityDashboard.tsx │ │ │ │ ├── ActivityFilters.tsx │ │ │ │ └── ActivityList.tsx │ │ │ ├── details │ │ │ │ ├── ActivityDetailPage.tsx │ │ │ │ ├── ActivityDetailsChat.tsx │ │ │ │ ├── ActivityDetailsHeader.tsx │ │ │ │ ├── ActivityDetailsInfo.tsx │ │ │ │ └── ActivityDetailsSidebar.tsx │ │ │ └── form │ │ │ │ ├── ActivityForm.tsx │ │ │ │ └── categoryOptions.ts │ │ ├── counter │ │ │ └── Counter.tsx │ │ ├── errors │ │ │ ├── NotFound.tsx │ │ │ ├── ServerError.tsx │ │ │ └── TestErrors.tsx │ │ ├── home │ │ │ └── HomePage.tsx │ │ └── profiles │ │ │ ├── ProfileAbout.tsx │ │ │ ├── ProfileActivities.tsx │ │ │ ├── ProfileCard.tsx │ │ │ ├── ProfileContent.tsx │ │ │ ├── ProfileEditForm.tsx │ │ │ ├── ProfileFollowings.tsx │ │ │ ├── ProfileHeader.tsx │ │ │ ├── ProfilePage.tsx │ │ │ └── ProfilePhotos.tsx │ ├── lib │ │ ├── api │ │ │ └── agent.ts │ │ ├── hooks │ │ │ ├── useAccount.ts │ │ │ ├── useActivities.ts │ │ │ ├── useComments.ts │ │ │ ├── useProfile.ts │ │ │ └── useStore.ts │ │ ├── schemas │ │ │ ├── activitySchema.ts │ │ │ ├── changePasswordSchema.ts │ │ │ ├── editProfileSchema.ts │ │ │ ├── loginSchema.ts │ │ │ ├── registerSchema.ts │ │ │ └── resetPasswordSchema.ts │ │ ├── stores │ │ │ ├── activityStore.ts │ │ │ ├── counterStore.ts │ │ │ ├── store.ts │ │ │ └── uiStore.ts │ │ ├── types │ │ │ └── index.d.ts │ │ └── util │ │ │ └── util.ts │ ├── main.tsx │ └── vite-env.d.ts ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts └── docker-compose.yml /.github/workflows/main_reactivities-course.yml: -------------------------------------------------------------------------------- 1 | # Docs for the Azure Web Apps Deploy action: https://github.com/Azure/webapps-deploy 2 | # More GitHub Actions for Azure: https://github.com/Azure/actions 3 | 4 | name: Build and deploy ASP.Net Core app to Azure Web App - reactivities-course 5 | 6 | on: 7 | workflow_dispatch: 8 | 9 | jobs: 10 | build: 11 | runs-on: windows-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Set up node.js 17 | uses: actions/setup-node@v3 18 | with: 19 | node-version: '20' 20 | 21 | - name: Install deps and build react app 22 | run: | 23 | cd client 24 | npm install 25 | npm run build 26 | 27 | - name: Set up .NET Core 28 | uses: actions/setup-dotnet@v4 29 | with: 30 | dotnet-version: '9.x' 31 | 32 | - name: Build with dotnet 33 | run: dotnet build --configuration Release 34 | 35 | - name: dotnet publish 36 | run: dotnet publish -c Release -o "${{env.DOTNET_ROOT}}/myapp" 37 | 38 | - name: Upload artifact for deployment job 39 | uses: actions/upload-artifact@v4 40 | with: 41 | name: .net-app 42 | path: ${{env.DOTNET_ROOT}}/myapp 43 | 44 | deploy: 45 | runs-on: windows-latest 46 | needs: build 47 | environment: 48 | name: 'Production' 49 | url: ${{ steps.deploy-to-webapp.outputs.webapp-url }} 50 | permissions: 51 | id-token: write #This is required for requesting the JWT 52 | 53 | steps: 54 | - name: Download artifact from build job 55 | uses: actions/download-artifact@v4 56 | with: 57 | name: .net-app 58 | 59 | - name: Login to Azure 60 | uses: azure/login@v2 61 | with: 62 | client-id: ${{ secrets.AZUREAPPSERVICE_CLIENTID_59E97E96AC1E4609BE332E4A9459AC22 }} 63 | tenant-id: ${{ secrets.AZUREAPPSERVICE_TENANTID_96C64EAD944E42BDBD80DD451671BD11 }} 64 | subscription-id: ${{ secrets.AZUREAPPSERVICE_SUBSCRIPTIONID_D3A35ABCE66E4D3FB2CF5AB0868DA20D }} 65 | 66 | - name: Deploy to Azure Web App 67 | id: deploy-to-webapp 68 | uses: azure/webapps-deploy@v3 69 | with: 70 | app-name: 'reactivities-course' 71 | slot-name: 'Production' 72 | package: . 73 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "C#: API Debug", 9 | "type": "dotnet", 10 | "request": "launch", 11 | "projectPath": "${workspaceFolder}/API/API.csproj" 12 | }, 13 | { 14 | "name": ".NET Core Attach", 15 | "type": "coreclr", 16 | "request": "attach" 17 | } 18 | 19 | 20 | ] 21 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /API/API.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net9.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /API/Controllers/ActivitiesController.cs: -------------------------------------------------------------------------------- 1 | using Application.Activities.Commands; 2 | using Application.Activities.DTOs; 3 | using Application.Activities.Queries; 4 | using Application.Core; 5 | using Microsoft.AspNetCore.Authorization; 6 | using Microsoft.AspNetCore.Mvc; 7 | 8 | namespace API.Controllers; 9 | 10 | public class ActivitiesController : BaseApiController 11 | { 12 | [HttpGet] 13 | public async Task>> GetActivities( 14 | [FromQuery]ActivityParams activityParams) 15 | { 16 | return HandleResult(await Mediator.Send(new GetActivityList.Query{Params = activityParams})); 17 | } 18 | 19 | [HttpGet("{id}")] 20 | public async Task> GetActivityDetail(string id) 21 | { 22 | return HandleResult(await Mediator.Send(new GetActivityDetails.Query { Id = id })); 23 | } 24 | 25 | [HttpPost] 26 | public async Task> CreateActivity(CreateActivityDto activityDto) 27 | { 28 | return HandleResult(await Mediator.Send(new CreateActivity.Command { ActivityDto = activityDto })); 29 | } 30 | 31 | [HttpPut("{id}")] 32 | [Authorize(Policy = "IsActivityHost")] 33 | public async Task EditActivity(string id, EditActivityDto activity) 34 | { 35 | activity.Id = id; 36 | return HandleResult(await Mediator.Send(new EditActivity.Command { ActivityDto = activity })); 37 | } 38 | 39 | [HttpDelete("{id}")] 40 | [Authorize(Policy = "IsActivityHost")] 41 | public async Task DeleteActivity(string id) 42 | { 43 | return HandleResult(await Mediator.Send(new DeleteActivity.Command { Id = id })); 44 | } 45 | 46 | [HttpPost("{id}/attend")] 47 | public async Task Attend(string id) 48 | { 49 | return HandleResult(await Mediator.Send(new UpdateAttendance.Command { Id = id })); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /API/Controllers/BaseApiController.cs: -------------------------------------------------------------------------------- 1 | using Application.Core; 2 | using MediatR; 3 | using Microsoft.AspNetCore.Http; 4 | using Microsoft.AspNetCore.Mvc; 5 | 6 | namespace API.Controllers 7 | { 8 | [Route("api/[controller]")] 9 | [ApiController] 10 | public class BaseApiController : ControllerBase 11 | { 12 | private IMediator? _mediator; 13 | 14 | protected IMediator Mediator => 15 | _mediator ??= HttpContext.RequestServices.GetService() 16 | ?? throw new InvalidOperationException("IMediator service is unavailable"); 17 | 18 | protected ActionResult HandleResult(Result result) 19 | { 20 | if (!result.IsSuccess && result.Code == 404) return NotFound(); 21 | 22 | if (result.IsSuccess && result.Value != null) return Ok(result.Value); 23 | 24 | return BadRequest(result.Error); 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /API/Controllers/BuggyController.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.AspNetCore.Mvc; 3 | 4 | namespace API.Controllers 5 | { 6 | public class BuggyController : BaseApiController 7 | { 8 | [HttpGet("not-found")] 9 | public ActionResult GetNotFound() 10 | { 11 | return NotFound(); 12 | } 13 | 14 | [HttpGet("bad-request")] 15 | public ActionResult GetBadRequest() 16 | { 17 | return BadRequest("This is a bad request"); 18 | } 19 | 20 | [HttpGet("server-error")] 21 | public ActionResult GetServerError() 22 | { 23 | throw new Exception("This is a server error"); 24 | } 25 | 26 | [HttpGet("unauthorised")] 27 | public ActionResult GetUnauthorised() 28 | { 29 | return Unauthorized(); 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /API/Controllers/FallbackController.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.AspNetCore.Authorization; 3 | using Microsoft.AspNetCore.Mvc; 4 | 5 | namespace API.Controllers; 6 | 7 | [AllowAnonymous] 8 | public class FallbackController : Controller 9 | { 10 | public IActionResult Index() 11 | { 12 | return PhysicalFile(Path.Combine(Directory.GetCurrentDirectory(), 13 | "wwwroot", "index.html"), "text/HTML"); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /API/Controllers/ProfilesController.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Application.Profiles.Commands; 3 | using Application.Profiles.DTOs; 4 | using Application.Profiles.Queries; 5 | using Domain; 6 | using Microsoft.AspNetCore.Mvc; 7 | 8 | namespace API.Controllers; 9 | 10 | public class ProfilesController : BaseApiController 11 | { 12 | [HttpPost("add-photo")] 13 | public async Task> AddPhoto(IFormFile file) 14 | { 15 | return HandleResult(await Mediator.Send(new AddPhoto.Command { File = file })); 16 | } 17 | 18 | [HttpGet("{userId}/photos")] 19 | public async Task>> GetPhotosForUser(string userId) 20 | { 21 | return HandleResult(await Mediator.Send(new GetProfilePhotos.Query { UserId = userId })); 22 | } 23 | 24 | [HttpDelete("{photoId}/photos")] 25 | public async Task DeletePhoto(string photoId) 26 | { 27 | return HandleResult(await Mediator.Send(new DeletePhoto.Command { PhotoId = photoId })); 28 | } 29 | 30 | [HttpPut("{photoId}/setMain")] 31 | public async Task SetMainPhoto(string photoId) 32 | { 33 | return HandleResult(await Mediator.Send(new SetMainPhoto.Command { PhotoId = photoId })); 34 | } 35 | 36 | [HttpGet("{userId}")] 37 | public async Task> GetProfile(string userId) 38 | { 39 | return HandleResult(await Mediator.Send(new GetProfile.Query { UserId = userId })); 40 | } 41 | 42 | [HttpPut] 43 | public async Task UpdateProfile(EditProfile.Command command) 44 | { 45 | return HandleResult(await Mediator.Send(command)); 46 | } 47 | 48 | [HttpPost("{userId}/follow")] 49 | public async Task FollowToggle(string userId) 50 | { 51 | return HandleResult(await Mediator 52 | .Send(new FollowToggle.Command { TargetUserId = userId })); 53 | } 54 | 55 | [HttpGet("{userId}/follow-list")] 56 | public async Task GetFollowings(string userId, string predicate) 57 | { 58 | return HandleResult(await Mediator.Send(new GetFollowings.Query 59 | { 60 | UserId = userId, 61 | Predicate = predicate 62 | })); 63 | } 64 | 65 | [HttpGet("{userId}/activities")] 66 | public async Task GetUserActivities(string userId, string filter) 67 | { 68 | return HandleResult(await Mediator.Send(new GetUserActivities.Query 69 | { UserId = userId, Filter = filter })); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /API/Controllers/WeatherForecastController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | 3 | namespace API.Controllers; 4 | 5 | [ApiController] 6 | [Route("[controller]")] 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/ChangePasswordDto.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.ComponentModel.DataAnnotations; 3 | 4 | namespace API.DTOs; 5 | 6 | public class ChangePasswordDto 7 | { 8 | [Required] 9 | public string CurrentPassword { get; set; } = ""; 10 | 11 | [Required] 12 | public string NewPassword { get; set; } = ""; 13 | } 14 | -------------------------------------------------------------------------------- /API/DTOs/GitHubInfo.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text.Json.Serialization; 3 | 4 | namespace API.DTOs; 5 | 6 | public class GitHubInfo 7 | { 8 | public class GitHubAuthRequest 9 | { 10 | public required string Code { get; set; } 11 | 12 | [JsonPropertyName("client_id")] 13 | public required string ClientId { get; set; } 14 | 15 | [JsonPropertyName("client_secret")] 16 | public required string ClientSecret { get; set; } 17 | 18 | [JsonPropertyName("redirect_uri")] 19 | public required string RedirectUri { get; set; } 20 | } 21 | 22 | public class GitHubTokenResponse 23 | { 24 | [JsonPropertyName("access_token")] 25 | public string AccessToken { get; set; } = ""; 26 | } 27 | 28 | public class GitHubUser 29 | { 30 | public string Email { get; set; } = ""; 31 | public string Name { get; set; } = ""; 32 | 33 | [JsonPropertyName("avatar_url")] 34 | public string? ImageUrl { get; set; } 35 | } 36 | 37 | public class GitHubEmail 38 | { 39 | public string Email { get; set; } = ""; 40 | public bool Primary { get; set; } 41 | public bool Verified { get; set; } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /API/DTOs/RegisterDto.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.ComponentModel.DataAnnotations; 3 | 4 | namespace API.DTOs; 5 | 6 | public class RegisterDto 7 | { 8 | [Required] 9 | public string DisplayName { get; set; } = ""; 10 | 11 | [Required] 12 | [EmailAddress] 13 | public string Email { get; set; } = ""; 14 | 15 | public string Password { get; set; } = ""; 16 | } 17 | -------------------------------------------------------------------------------- /API/Middleware/ExceptionMiddleware.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text.Json; 3 | using Application.Core; 4 | using FluentValidation; 5 | using Microsoft.AspNetCore.Mvc; 6 | 7 | namespace API.Middleware; 8 | 9 | public class ExceptionMiddleware(ILogger logger, IHostEnvironment env) 10 | : IMiddleware 11 | { 12 | public async Task InvokeAsync(HttpContext context, RequestDelegate next) 13 | { 14 | try 15 | { 16 | await next(context); 17 | } 18 | catch (ValidationException ex) 19 | { 20 | await HandleValidationException(context, ex); 21 | } 22 | catch (Exception ex) 23 | { 24 | await HandleException(context, ex); 25 | } 26 | } 27 | 28 | private async Task HandleException(HttpContext context, Exception ex) 29 | { 30 | logger.LogError(ex, ex.Message); 31 | context.Response.ContentType = "application/json"; 32 | context.Response.StatusCode = StatusCodes.Status500InternalServerError; 33 | 34 | var response = env.IsDevelopment() 35 | ? new AppException(context.Response.StatusCode, ex.Message, ex.StackTrace) 36 | : new AppException(context.Response.StatusCode, ex.Message, null); 37 | 38 | var options = new JsonSerializerOptions{PropertyNamingPolicy = JsonNamingPolicy.CamelCase}; 39 | 40 | var json = JsonSerializer.Serialize(response, options); 41 | 42 | await context.Response.WriteAsync(json); 43 | } 44 | 45 | private static async Task HandleValidationException(HttpContext context, ValidationException ex) 46 | { 47 | var validationErrors = new Dictionary(); 48 | 49 | if (ex.Errors is not null) 50 | { 51 | foreach (var error in ex.Errors) 52 | { 53 | if (validationErrors.TryGetValue(error.PropertyName, out var existingErrors)) 54 | { 55 | validationErrors[error.PropertyName] = [.. existingErrors, error.ErrorMessage]; 56 | } 57 | else 58 | { 59 | validationErrors[error.PropertyName] = [error.ErrorMessage]; 60 | } 61 | } 62 | } 63 | 64 | context.Response.StatusCode = StatusCodes.Status400BadRequest; 65 | 66 | var validationProblemDetails = new ValidationProblemDetails(validationErrors) 67 | { 68 | Status = StatusCodes.Status400BadRequest, 69 | Type = "ValidationFailure", 70 | Title = "Validation error", 71 | Detail = "One or more validation errors has occurred" 72 | }; 73 | 74 | await context.Response.WriteAsJsonAsync(validationProblemDetails); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /API/Program.cs: -------------------------------------------------------------------------------- 1 | using API.Middleware; 2 | using API.SignalR; 3 | using Application.Activities.Queries; 4 | using Application.Activities.Validators; 5 | using Application.Core; 6 | using Application.Interfaces; 7 | using Domain; 8 | using FluentValidation; 9 | using Infrastructure.Email; 10 | using Infrastructure.Photos; 11 | using Infrastructure.Security; 12 | using Microsoft.AspNetCore.Authorization; 13 | using Microsoft.AspNetCore.Identity; 14 | using Microsoft.AspNetCore.Mvc.Authorization; 15 | using Microsoft.EntityFrameworkCore; 16 | using Persistence; 17 | using Resend; 18 | 19 | var builder = WebApplication.CreateBuilder(args); 20 | 21 | // Add services to the container. 22 | builder.Services.AddControllers(opt => 23 | { 24 | var policy = new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build(); 25 | opt.Filters.Add(new AuthorizeFilter(policy)); 26 | }); 27 | builder.Services.AddDbContext(opt => 28 | { 29 | opt.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")); 30 | }); 31 | builder.Services.AddCors(); 32 | builder.Services.AddSignalR(); 33 | builder.Services.AddMediatR(x => { 34 | x.RegisterServicesFromAssemblyContaining(); 35 | x.AddOpenBehavior(typeof(ValidationBehavior<,>)); 36 | }); 37 | builder.Services.AddHttpClient(); 38 | builder.Services.Configure(opt => 39 | { 40 | opt.ApiToken = builder.Configuration["Resend:ApiToken"]!; 41 | }); 42 | builder.Services.AddTransient(); 43 | builder.Services.AddTransient, EmailSender>(); 44 | 45 | builder.Services.AddScoped(); 46 | builder.Services.AddScoped(); 47 | builder.Services.AddAutoMapper(typeof(MappingProfiles).Assembly); 48 | builder.Services.AddValidatorsFromAssemblyContaining(); 49 | builder.Services.AddTransient(); 50 | builder.Services.AddIdentityApiEndpoints(opt => 51 | { 52 | opt.User.RequireUniqueEmail = true; 53 | opt.SignIn.RequireConfirmedEmail = true; 54 | }) 55 | .AddRoles() 56 | .AddEntityFrameworkStores(); 57 | builder.Services.AddAuthorization(opt => 58 | { 59 | opt.AddPolicy("IsActivityHost", policy => 60 | { 61 | policy.Requirements.Add(new IsHostRequirement()); 62 | }); 63 | }); 64 | builder.Services.AddTransient(); 65 | builder.Services.Configure(builder.Configuration 66 | .GetSection("CloudinarySettings")); 67 | 68 | var app = builder.Build(); 69 | 70 | // Configure the HTTP request pipeline. 71 | app.UseMiddleware(); 72 | app.UseCors(x => x.AllowAnyHeader().AllowAnyMethod() 73 | .AllowCredentials() 74 | .WithOrigins("http://localhost:3000", "https://localhost:3000")); 75 | 76 | app.UseAuthentication(); 77 | app.UseAuthorization(); 78 | 79 | app.UseDefaultFiles(); 80 | app.UseStaticFiles(); 81 | 82 | app.MapControllers(); 83 | app.MapGroup("api").MapIdentityApi(); // api/login 84 | app.MapHub("/comments"); 85 | app.MapFallbackToController("Index", "Fallback"); 86 | 87 | using var scope = app.Services.CreateScope(); 88 | var services = scope.ServiceProvider; 89 | 90 | try 91 | { 92 | var context = services.GetRequiredService(); 93 | var userManager = services.GetRequiredService>(); 94 | await context.Database.MigrateAsync(); 95 | await DbInitializer.SeedData(context, userManager); 96 | } 97 | catch (Exception ex) 98 | { 99 | var logger = services.GetRequiredService>(); 100 | logger.LogError(ex, "An error occurred during migration."); 101 | } 102 | 103 | app.Run(); 104 | -------------------------------------------------------------------------------- /API/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/launchsettings.json", 3 | "profiles": { 4 | "https": { 5 | "commandName": "Project", 6 | "dotnetRunMessages": true, 7 | "launchBrowser": false, 8 | "applicationUrl": "https://localhost:5001", 9 | "environmentVariables": { 10 | "ASPNETCORE_ENVIRONMENT": "Development" 11 | } 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /API/SignalR/CommentHub.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Application.Activities.Commands; 3 | using Application.Activities.Queries; 4 | using MediatR; 5 | using Microsoft.AspNetCore.SignalR; 6 | 7 | namespace API.SignalR; 8 | 9 | public class CommentHub(IMediator mediator) : Hub 10 | { 11 | public async Task SendComment(AddComment.Command command) 12 | { 13 | var comment = await mediator.Send(command); 14 | 15 | await Clients.Group(command.ActivityId).SendAsync("ReceiveComment", comment.Value); 16 | } 17 | 18 | public override async Task OnConnectedAsync() 19 | { 20 | var httpContext = Context.GetHttpContext(); 21 | var activityId = httpContext?.Request.Query["activityId"]; 22 | 23 | if (string.IsNullOrEmpty(activityId)) throw new HubException("No activity with this id"); 24 | 25 | await Groups.AddToGroupAsync(Context.ConnectionId, activityId!); 26 | 27 | var result = await mediator.Send(new GetComments.Query{ActivityId = activityId!}); 28 | 29 | await Clients.Caller.SendAsync("LoadComments", result.Value); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /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,1433;Database=reactivities;User Id=SA;Password=Password@1;TrustServerCertificate=True" 10 | }, 11 | "ClientAppUrl": "https://localhost:3000" 12 | } 13 | -------------------------------------------------------------------------------- /API/reactivities.db-wal: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/API/reactivities.db-wal -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-cyrillic-300-normal-DJfICpyc.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/API/wwwroot/assets/roboto-cyrillic-300-normal-DJfICpyc.woff2 -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-cyrillic-300-normal-Dg7J0kAT.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/API/wwwroot/assets/roboto-cyrillic-300-normal-Dg7J0kAT.woff -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-cyrillic-400-normal-BiRJyiea.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/API/wwwroot/assets/roboto-cyrillic-400-normal-BiRJyiea.woff2 -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-cyrillic-400-normal-JN0iKxGs.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/API/wwwroot/assets/roboto-cyrillic-400-normal-JN0iKxGs.woff -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-cyrillic-500-normal-YnJLGrUm.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/API/wwwroot/assets/roboto-cyrillic-500-normal-YnJLGrUm.woff -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-cyrillic-500-normal-_hamcpv8.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/API/wwwroot/assets/roboto-cyrillic-500-normal-_hamcpv8.woff2 -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-cyrillic-700-normal-BJaAVvFw.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/API/wwwroot/assets/roboto-cyrillic-700-normal-BJaAVvFw.woff -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-cyrillic-700-normal-jruQITdB.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/API/wwwroot/assets/roboto-cyrillic-700-normal-jruQITdB.woff2 -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-cyrillic-ext-300-normal-BLLmCegk.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/API/wwwroot/assets/roboto-cyrillic-ext-300-normal-BLLmCegk.woff -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-cyrillic-ext-300-normal-Chhwl1Jq.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/API/wwwroot/assets/roboto-cyrillic-ext-300-normal-Chhwl1Jq.woff2 -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-cyrillic-ext-400-normal-D76n7Daw.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/API/wwwroot/assets/roboto-cyrillic-ext-400-normal-D76n7Daw.woff2 -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-cyrillic-ext-400-normal-b0JluIOJ.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/API/wwwroot/assets/roboto-cyrillic-ext-400-normal-b0JluIOJ.woff -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-cyrillic-ext-500-normal-37WQE4S0.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/API/wwwroot/assets/roboto-cyrillic-ext-500-normal-37WQE4S0.woff -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-cyrillic-ext-500-normal-BJvL3D7h.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/API/wwwroot/assets/roboto-cyrillic-ext-500-normal-BJvL3D7h.woff2 -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-cyrillic-ext-700-normal-CyZgh00P.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/API/wwwroot/assets/roboto-cyrillic-ext-700-normal-CyZgh00P.woff2 -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-cyrillic-ext-700-normal-DXzexxfu.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/API/wwwroot/assets/roboto-cyrillic-ext-700-normal-DXzexxfu.woff -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-greek-300-normal-Bx8edVml.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/API/wwwroot/assets/roboto-greek-300-normal-Bx8edVml.woff2 -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-greek-300-normal-D3gN5oZ1.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/API/wwwroot/assets/roboto-greek-300-normal-D3gN5oZ1.woff -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-greek-400-normal-IIc_WWwF.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/API/wwwroot/assets/roboto-greek-400-normal-IIc_WWwF.woff -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-greek-400-normal-LPh2sqOm.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/API/wwwroot/assets/roboto-greek-400-normal-LPh2sqOm.woff2 -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-greek-500-normal-Bg8BLohm.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/API/wwwroot/assets/roboto-greek-500-normal-Bg8BLohm.woff2 -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-greek-500-normal-CdRewbqV.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/API/wwwroot/assets/roboto-greek-500-normal-CdRewbqV.woff -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-greek-700-normal-1IZ-NEfb.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/API/wwwroot/assets/roboto-greek-700-normal-1IZ-NEfb.woff -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-greek-700-normal-Bs05n1ZH.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/API/wwwroot/assets/roboto-greek-700-normal-Bs05n1ZH.woff2 -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-latin-300-normal-BZ6gvbSO.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/API/wwwroot/assets/roboto-latin-300-normal-BZ6gvbSO.woff -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-latin-300-normal-BizgZZ3y.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/API/wwwroot/assets/roboto-latin-300-normal-BizgZZ3y.woff2 -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-latin-400-normal-BVyCgWwA.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/API/wwwroot/assets/roboto-latin-400-normal-BVyCgWwA.woff -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-latin-400-normal-DXyFPIdK.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/API/wwwroot/assets/roboto-latin-400-normal-DXyFPIdK.woff2 -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-latin-500-normal-C6iW8rdg.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/API/wwwroot/assets/roboto-latin-500-normal-C6iW8rdg.woff2 -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-latin-500-normal-rpP1_v3s.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/API/wwwroot/assets/roboto-latin-500-normal-rpP1_v3s.woff -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-latin-700-normal-BWcFiwQV.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/API/wwwroot/assets/roboto-latin-700-normal-BWcFiwQV.woff -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-latin-700-normal-CbYYDfWS.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/API/wwwroot/assets/roboto-latin-700-normal-CbYYDfWS.woff2 -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-latin-ext-300-normal-BzRVPTS2.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/API/wwwroot/assets/roboto-latin-ext-300-normal-BzRVPTS2.woff2 -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-latin-ext-300-normal-Djx841zm.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/API/wwwroot/assets/roboto-latin-ext-300-normal-Djx841zm.woff -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-latin-ext-400-normal-BSFkPfbf.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/API/wwwroot/assets/roboto-latin-ext-400-normal-BSFkPfbf.woff -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-latin-ext-400-normal-DgXbz5gU.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/API/wwwroot/assets/roboto-latin-ext-400-normal-DgXbz5gU.woff2 -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-latin-ext-500-normal-DvHxAkTn.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/API/wwwroot/assets/roboto-latin-ext-500-normal-DvHxAkTn.woff -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-latin-ext-500-normal-OQJhyaXd.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/API/wwwroot/assets/roboto-latin-ext-500-normal-OQJhyaXd.woff2 -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-latin-ext-700-normal-Ba-CAIIA.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/API/wwwroot/assets/roboto-latin-ext-700-normal-Ba-CAIIA.woff -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-latin-ext-700-normal-DchBbzVz.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/API/wwwroot/assets/roboto-latin-ext-700-normal-DchBbzVz.woff2 -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-vietnamese-300-normal-CAomnZLO.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/API/wwwroot/assets/roboto-vietnamese-300-normal-CAomnZLO.woff -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-vietnamese-300-normal-PZa9KE_J.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/API/wwwroot/assets/roboto-vietnamese-300-normal-PZa9KE_J.woff2 -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-vietnamese-400-normal-D5pJwT9g.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/API/wwwroot/assets/roboto-vietnamese-400-normal-D5pJwT9g.woff -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-vietnamese-400-normal-DhTUfTw_.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/API/wwwroot/assets/roboto-vietnamese-400-normal-DhTUfTw_.woff2 -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-vietnamese-500-normal-LvuCHq7y.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/API/wwwroot/assets/roboto-vietnamese-500-normal-LvuCHq7y.woff -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-vietnamese-500-normal-p0V0BAAE.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/API/wwwroot/assets/roboto-vietnamese-500-normal-p0V0BAAE.woff2 -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-vietnamese-700-normal-B4Nagvlm.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/API/wwwroot/assets/roboto-vietnamese-700-normal-B4Nagvlm.woff -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-vietnamese-700-normal-CBbheh0s.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/API/wwwroot/assets/roboto-vietnamese-700-normal-CBbheh0s.woff2 -------------------------------------------------------------------------------- /API/wwwroot/images/categoryImages/culture.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/API/wwwroot/images/categoryImages/culture.jpg -------------------------------------------------------------------------------- /API/wwwroot/images/categoryImages/drinks.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/API/wwwroot/images/categoryImages/drinks.jpg -------------------------------------------------------------------------------- /API/wwwroot/images/categoryImages/film.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/API/wwwroot/images/categoryImages/film.jpg -------------------------------------------------------------------------------- /API/wwwroot/images/categoryImages/food.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/API/wwwroot/images/categoryImages/food.jpg -------------------------------------------------------------------------------- /API/wwwroot/images/categoryImages/music.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/API/wwwroot/images/categoryImages/music.jpg -------------------------------------------------------------------------------- /API/wwwroot/images/categoryImages/travel.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/API/wwwroot/images/categoryImages/travel.jpg -------------------------------------------------------------------------------- /API/wwwroot/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/API/wwwroot/images/logo.png -------------------------------------------------------------------------------- /API/wwwroot/images/placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/API/wwwroot/images/placeholder.png -------------------------------------------------------------------------------- /API/wwwroot/images/user.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/API/wwwroot/images/user.png -------------------------------------------------------------------------------- /API/wwwroot/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Reactivities 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | -------------------------------------------------------------------------------- /API/wwwroot/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Application/Activities/Commands/AddComment.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Application.Activities.DTOs; 3 | using Application.Core; 4 | using Application.Interfaces; 5 | using AutoMapper; 6 | using Domain; 7 | using MediatR; 8 | using Microsoft.EntityFrameworkCore; 9 | using Persistence; 10 | 11 | namespace Application.Activities.Commands; 12 | 13 | public class AddComment 14 | { 15 | public class Command : IRequest> 16 | { 17 | public required string Body { get; set; } 18 | public required string ActivityId { get; set; } 19 | } 20 | 21 | public class Handler(AppDbContext context, IMapper mapper, IUserAccessor userAccessor) 22 | : IRequestHandler> 23 | { 24 | public async Task> Handle(Command request, CancellationToken cancellationToken) 25 | { 26 | var activity = await context.Activities 27 | .Include(x => x.Comments) 28 | .ThenInclude(x => x.User) 29 | .FirstOrDefaultAsync(x => x.Id == request.ActivityId, cancellationToken); 30 | 31 | if (activity == null) return Result.Failure("Could not find activity", 404); 32 | 33 | var user = await userAccessor.GetUserAsync(); 34 | 35 | var comment = new Comment 36 | { 37 | UserId = user.Id, 38 | ActivityId = activity.Id, 39 | Body = request.Body 40 | }; 41 | 42 | activity.Comments.Add(comment); 43 | 44 | var result = await context.SaveChangesAsync(cancellationToken) > 0; 45 | 46 | return result 47 | ? Result.Success(mapper.Map(comment)) 48 | : Result.Failure("Failed to add comment", 400); 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Application/Activities/Commands/CreateActivity.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Application.Activities.DTOs; 3 | using Application.Core; 4 | using Application.Interfaces; 5 | using AutoMapper; 6 | using Domain; 7 | using FluentValidation; 8 | using MediatR; 9 | using Persistence; 10 | 11 | namespace Application.Activities.Commands; 12 | 13 | public class CreateActivity 14 | { 15 | public class Command : IRequest> 16 | { 17 | public required CreateActivityDto ActivityDto { get; set; } 18 | } 19 | 20 | public class Handler(AppDbContext context, IMapper mapper, IUserAccessor userAccessor) 21 | : IRequestHandler> 22 | { 23 | public async Task> Handle(Command request, CancellationToken cancellationToken) 24 | { 25 | var user = await userAccessor.GetUserAsync(); 26 | 27 | var activity = mapper.Map(request.ActivityDto); 28 | 29 | context.Activities.Add(activity); 30 | 31 | var attendee = new ActivityAttendee 32 | { 33 | ActivityId = activity.Id, 34 | UserId = user.Id, 35 | IsHost = true 36 | }; 37 | 38 | activity.Attendees.Add(attendee); 39 | 40 | var result = await context.SaveChangesAsync(cancellationToken) > 0; 41 | 42 | if (!result) return Result.Failure("Failed to create the activity", 400); 43 | 44 | return Result.Success(activity.Id); 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Application/Activities/Commands/DeleteActivity.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Application.Core; 3 | using MediatR; 4 | using Persistence; 5 | 6 | namespace Application.Activities.Commands; 7 | 8 | public class DeleteActivity 9 | { 10 | public class Command : IRequest> 11 | { 12 | public required string Id { get; set; } 13 | } 14 | 15 | public class Handler(AppDbContext context) : IRequestHandler> 16 | { 17 | public async Task> Handle(Command request, CancellationToken cancellationToken) 18 | { 19 | var activity = await context.Activities 20 | .FindAsync([request.Id], cancellationToken); 21 | 22 | if (activity == null) return Result.Failure("Activity not found", 404); 23 | 24 | context.Remove(activity); 25 | 26 | var result = await context.SaveChangesAsync(cancellationToken) > 0; 27 | 28 | if (!result) return Result.Failure("Failed to delete the activity", 400); 29 | 30 | return Result.Success(Unit.Value); 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Application/Activities/Commands/EditActivity.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Application.Activities.DTOs; 3 | using Application.Core; 4 | using AutoMapper; 5 | using Domain; 6 | using MediatR; 7 | using Persistence; 8 | 9 | namespace Application.Activities.Commands; 10 | 11 | public class EditActivity 12 | { 13 | public class Command : IRequest> 14 | { 15 | public required EditActivityDto ActivityDto { get; set; } 16 | } 17 | 18 | public class Handler(AppDbContext context, IMapper mapper) : IRequestHandler> 19 | { 20 | public async Task> Handle(Command request, CancellationToken cancellationToken) 21 | { 22 | var activity = await context.Activities 23 | .FindAsync([request.ActivityDto.Id], cancellationToken); 24 | 25 | if (activity == null) return Result.Failure("Activity not found", 404); 26 | 27 | mapper.Map(request.ActivityDto, activity); 28 | 29 | var result = await context.SaveChangesAsync(cancellationToken) > 0; 30 | 31 | if (!result) return Result.Failure("Failed to update the activity", 400); 32 | 33 | return Result.Success(Unit.Value); 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Application/Activities/Commands/UpdateAttendance.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Application.Core; 3 | using Application.Interfaces; 4 | using Domain; 5 | using MediatR; 6 | using Microsoft.EntityFrameworkCore; 7 | using Persistence; 8 | 9 | namespace Application.Activities.Commands; 10 | 11 | public class UpdateAttendance 12 | { 13 | public class Command : IRequest> 14 | { 15 | public required string Id { get; set; } 16 | } 17 | 18 | public class Handler(IUserAccessor userAccessor, AppDbContext context) 19 | : IRequestHandler> 20 | { 21 | public async Task> Handle(Command request, CancellationToken cancellationToken) 22 | { 23 | var activity = await context.Activities 24 | .Include(x => x.Attendees) 25 | .ThenInclude(x => x.User) 26 | .SingleOrDefaultAsync(x => x.Id == request.Id, cancellationToken); 27 | 28 | if (activity == null) return Result.Failure("Activity not found", 404); 29 | 30 | var user = await userAccessor.GetUserAsync(); 31 | 32 | var attendance = activity.Attendees.FirstOrDefault(x => x.UserId == user.Id); 33 | var isHost = activity.Attendees.Any(x => x.IsHost && x.UserId == user.Id); 34 | 35 | if (attendance != null) 36 | { 37 | if (isHost) activity.IsCancelled = !activity.IsCancelled; 38 | else activity.Attendees.Remove(attendance); 39 | } 40 | else 41 | { 42 | activity.Attendees.Add(new ActivityAttendee 43 | { 44 | UserId = user.Id, 45 | ActivityId = activity.Id, 46 | IsHost = false 47 | }); 48 | } 49 | 50 | var result = await context.SaveChangesAsync(cancellationToken) > 0; 51 | 52 | return result 53 | ? Result.Success(Unit.Value) 54 | : Result.Failure("Problem updating the DB", 400); 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Application/Activities/DTOs/ActivityDto.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Application.Profiles.DTOs; 3 | 4 | namespace Application.Activities.DTOs; 5 | 6 | public class ActivityDto 7 | { 8 | public required string Id { get; set; } 9 | public required string Title { get; set; } 10 | public DateTime Date { get; set; } 11 | public required string Description { get; set; } 12 | public required string Category { get; set; } 13 | public bool IsCancelled { get; set; } 14 | public required string HostDisplayName { get; set; } 15 | public required string HostId { get; set; } 16 | 17 | // location props 18 | public required string City { get; set; } 19 | public required string Venue { get; set; } 20 | public double Latitude { get; set; } 21 | public double Longitude { get; set; } 22 | 23 | // navigation properties 24 | public ICollection Attendees { get; set; } = []; 25 | } 26 | -------------------------------------------------------------------------------- /Application/Activities/DTOs/BaseActivityDto.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Application.Activities.DTOs; 4 | 5 | public class BaseActivityDto 6 | { 7 | public string Title { get; set; } = ""; 8 | public DateTime Date { get; set; } 9 | public string Description { get; set; } = string.Empty; 10 | public string Category { get; set; } = ""; 11 | 12 | // location props 13 | public string City { get; set; } = ""; 14 | public string Venue { get; set; } = ""; 15 | public double Latitude { get; set; } 16 | public double Longitude { get; set; } 17 | } 18 | -------------------------------------------------------------------------------- /Application/Activities/DTOs/CommentDto.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Application.Activities.DTOs; 4 | 5 | public class CommentDto 6 | { 7 | public required string Id { get; set; } 8 | public required string Body { get; set; } 9 | public DateTime CreatedAt { get; set; } 10 | public required string UserId { get; set; } 11 | public required string DisplayName { get; set; } 12 | public string? ImageUrl { get; set; } 13 | } 14 | -------------------------------------------------------------------------------- /Application/Activities/DTOs/CreateActivityDto.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.ComponentModel.DataAnnotations; 3 | 4 | namespace Application.Activities.DTOs; 5 | 6 | public class CreateActivityDto : BaseActivityDto 7 | { 8 | } 9 | -------------------------------------------------------------------------------- /Application/Activities/DTOs/EditActivityDto.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Application.Activities.DTOs; 4 | 5 | public class EditActivityDto : BaseActivityDto 6 | { 7 | public string Id { get; set; } = ""; 8 | } 9 | -------------------------------------------------------------------------------- /Application/Activities/Queries/ActivityParams.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Application.Core; 3 | 4 | namespace Application.Activities.Queries; 5 | 6 | public class ActivityParams : PaginationParams 7 | { 8 | public string? Filter { get; set; } 9 | public DateTime StartDate { get; set; } = DateTime.UtcNow; 10 | } 11 | -------------------------------------------------------------------------------- /Application/Activities/Queries/GetActivityDetails.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Application.Activities.DTOs; 3 | using Application.Core; 4 | using Application.Interfaces; 5 | using AutoMapper; 6 | using AutoMapper.QueryableExtensions; 7 | using Domain; 8 | using MediatR; 9 | using Microsoft.EntityFrameworkCore; 10 | using Persistence; 11 | 12 | namespace Application.Activities.Queries; 13 | 14 | public class GetActivityDetails 15 | { 16 | public class Query : IRequest> 17 | { 18 | public required string Id { get; set; } 19 | } 20 | 21 | public class Handler(AppDbContext context, IMapper mapper, IUserAccessor userAccessor) 22 | : IRequestHandler> 23 | { 24 | public async Task> Handle(Query request, CancellationToken cancellationToken) 25 | { 26 | var activity = await context.Activities 27 | .ProjectTo(mapper.ConfigurationProvider, 28 | new {currentUserId = userAccessor.GetUserId()}) 29 | .FirstOrDefaultAsync(x => request.Id == x.Id, cancellationToken); 30 | 31 | if (activity == null) return Result.Failure("Activity not found", 404); 32 | 33 | return Result.Success(activity); 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Application/Activities/Queries/GetActivityList.cs: -------------------------------------------------------------------------------- 1 | using Application.Activities.DTOs; 2 | using Application.Core; 3 | using Application.Interfaces; 4 | using AutoMapper; 5 | using AutoMapper.QueryableExtensions; 6 | using MediatR; 7 | using Microsoft.EntityFrameworkCore; 8 | using Persistence; 9 | 10 | namespace Application.Activities.Queries; 11 | 12 | public class GetActivityList 13 | { 14 | public class Query : IRequest>> 15 | { 16 | public required ActivityParams Params { get; set; } 17 | } 18 | 19 | public class Handler(AppDbContext context, IMapper mapper, IUserAccessor userAccessor) : 20 | IRequestHandler>> 21 | { 22 | public async Task>> Handle(Query request, CancellationToken cancellationToken) 23 | { 24 | var query = context.Activities 25 | .OrderBy(x => x.Date) 26 | .Where(x => x.Date >= (request.Params.Cursor ?? request.Params.StartDate)) 27 | .AsQueryable(); 28 | 29 | if (!string.IsNullOrEmpty(request.Params.Filter)) 30 | { 31 | query = request.Params.Filter switch 32 | { 33 | "isGoing" => query.Where(x => 34 | x.Attendees.Any(a => a.UserId == userAccessor.GetUserId())), 35 | "isHost" => query.Where(x => 36 | x.Attendees.Any(a => a.IsHost && a.UserId == userAccessor.GetUserId())), 37 | _ => query 38 | }; 39 | } 40 | 41 | var projectedActivities = query.ProjectTo(mapper.ConfigurationProvider, 42 | new {currentUserId = userAccessor.GetUserId()}); 43 | 44 | var activities = await projectedActivities 45 | .Take(request.Params.PageSize + 1) 46 | .ToListAsync(cancellationToken); 47 | 48 | DateTime? nextCursor = null; 49 | if (activities.Count > request.Params.PageSize) 50 | { 51 | nextCursor = activities.Last().Date; 52 | activities.RemoveAt(activities.Count - 1); 53 | } 54 | 55 | return Result>.Success( 56 | new PagedList 57 | { 58 | Items = activities, 59 | NextCursor = nextCursor 60 | } 61 | ); 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Application/Activities/Queries/GetComments.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Application.Activities.DTOs; 3 | using Application.Core; 4 | using AutoMapper; 5 | using AutoMapper.QueryableExtensions; 6 | using MediatR; 7 | using Microsoft.EntityFrameworkCore; 8 | using Persistence; 9 | 10 | namespace Application.Activities.Queries; 11 | 12 | public class GetComments 13 | { 14 | public class Query : IRequest>> 15 | { 16 | public required string ActivityId { get; set; } 17 | } 18 | 19 | public class Handler(AppDbContext context, IMapper mapper) 20 | : IRequestHandler>> 21 | { 22 | public async Task>> Handle(Query request, CancellationToken cancellationToken) 23 | { 24 | var comments = await context.Comments 25 | .Where(x => x.ActivityId == request.ActivityId) 26 | .OrderByDescending(x => x.CreatedAt) 27 | .ProjectTo(mapper.ConfigurationProvider) 28 | .ToListAsync(cancellationToken); 29 | 30 | return Result>.Success(comments); 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Application/Activities/Validators/BaseActivityValidator.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Application.Activities.DTOs; 3 | using FluentValidation; 4 | 5 | namespace Application.Activities.Validators; 6 | 7 | public class BaseActivityValidator : AbstractValidator where TDto 8 | : BaseActivityDto 9 | { 10 | public BaseActivityValidator(Func selector) 11 | { 12 | RuleFor(x => selector(x).Title) 13 | .NotEmpty().WithMessage("Title is required") 14 | .MaximumLength(100).WithMessage("Title must not exceed 100 characters"); 15 | RuleFor(x => selector(x).Description) 16 | .NotEmpty().WithMessage("Description is required"); 17 | RuleFor(x => selector(x).Date) 18 | .GreaterThan(DateTime.UtcNow).WithMessage("Date must be in the future"); 19 | RuleFor(x => selector(x).Category) 20 | .NotEmpty().WithMessage("Category is required"); 21 | RuleFor(x => selector(x).City) 22 | .NotEmpty().WithMessage("City is required"); 23 | RuleFor(x => selector(x).Venue) 24 | .NotEmpty().WithMessage("Venue is required"); 25 | RuleFor(x => selector(x).Latitude) 26 | .NotEmpty().WithMessage("Latitude is required") 27 | .InclusiveBetween(-90, 90).WithMessage("Latitude must be between -90 and 90"); 28 | RuleFor(x => selector(x).Longitude) 29 | .NotEmpty().WithMessage("Longitude is required") 30 | .InclusiveBetween(-180, 180).WithMessage("Longitude must be between -180 and 180"); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Application/Activities/Validators/CreateActivityValidator.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Application.Activities.Commands; 3 | using Application.Activities.DTOs; 4 | using FluentValidation; 5 | 6 | namespace Application.Activities.Validators; 7 | 8 | public class CreateActivityValidator : BaseActivityValidator 9 | { 10 | public CreateActivityValidator() : base(x => x.ActivityDto) 11 | { 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Application/Activities/Validators/EditActivityValidator.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Application.Activities.Commands; 3 | using Application.Activities.DTOs; 4 | using FluentValidation; 5 | 6 | namespace Application.Activities.Validators; 7 | 8 | public class EditActivityValidator : BaseActivityValidator 9 | { 10 | public EditActivityValidator() : base(x => x.ActivityDto) 11 | { 12 | RuleFor(x => x.ActivityDto.Id) 13 | .NotEmpty().WithMessage("Id is required"); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Application/Application.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net9.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /Application/Core/AppException.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Application.Core; 4 | 5 | public class AppException(int statusCode, string message, string? details) 6 | { 7 | public int StatusCode { get; set; } = statusCode; 8 | public string Message { get; set; } = message; 9 | public string? Details { get; set; } = details; 10 | } 11 | -------------------------------------------------------------------------------- /Application/Core/MappingProfiles.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Application.Activities.DTOs; 3 | using Application.Profiles.DTOs; 4 | using AutoMapper; 5 | using Domain; 6 | 7 | namespace Application.Core; 8 | 9 | public class MappingProfiles : Profile 10 | { 11 | public MappingProfiles() 12 | { 13 | string? currentUserId = null; 14 | CreateMap(); 15 | CreateMap(); 16 | CreateMap(); 17 | CreateMap() 18 | .ForMember(d => d.HostDisplayName, o => o.MapFrom(s => 19 | s.Attendees.FirstOrDefault(x => x.IsHost)!.User.DisplayName)) 20 | .ForMember(d => d.HostId, o => o.MapFrom(s => 21 | s.Attendees.FirstOrDefault(x => x.IsHost)!.User.Id)); 22 | CreateMap() 23 | .ForMember(d => d.DisplayName, o => o.MapFrom(s => s.User.DisplayName)) 24 | .ForMember(d => d.Bio, o => o.MapFrom(s => s.User.Bio)) 25 | .ForMember(d => d.ImageUrl, o => o.MapFrom(s => s.User.ImageUrl)) 26 | .ForMember(d => d.Id, o => o.MapFrom(s => s.User.Id)) 27 | .ForMember(d => d.FollowersCount, o => o.MapFrom(s => s.User.Followers.Count)) 28 | .ForMember(d => d.FollowingCount, o => o.MapFrom(s => s.User.Followings.Count)) 29 | .ForMember(d => d.Following, o => o.MapFrom(s => 30 | s.User.Followers.Any(x => x.Observer.Id == currentUserId))); 31 | CreateMap() 32 | .ForMember(d => d.FollowersCount, o => o.MapFrom(s => s.Followers.Count)) 33 | .ForMember(d => d.FollowingCount, o => o.MapFrom(s => s.Followings.Count)) 34 | .ForMember(d => d.Following, o => o.MapFrom(s => 35 | s.Followers.Any(x => x.Observer.Id == currentUserId))); 36 | CreateMap() 37 | .ForMember(d => d.DisplayName, o => o.MapFrom(s => s.User.DisplayName)) 38 | .ForMember(d => d.UserId, o => o.MapFrom(s => s.User.Id)) 39 | .ForMember(d => d.ImageUrl, o => o.MapFrom(s => s.User.ImageUrl)); 40 | CreateMap(); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Application/Core/PagedList.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Application.Core; 4 | 5 | public class PagedList 6 | { 7 | public List Items { get; set; } = []; 8 | public TCursor? NextCursor { get; set; } 9 | } 10 | -------------------------------------------------------------------------------- /Application/Core/PaginationParams.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Application.Core; 4 | 5 | public class PaginationParams 6 | { 7 | private const int MaxPageSize = 50; 8 | public TCursor? Cursor { get; set; } 9 | private int _pageSize = 3; 10 | public int PageSize 11 | { 12 | get => _pageSize; 13 | set => _pageSize = (value > MaxPageSize) ? MaxPageSize : value; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Application/Core/Result.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Application.Core; 4 | 5 | public class Result 6 | { 7 | public bool IsSuccess { get; set; } 8 | public T? Value { get; set; } 9 | public string? Error { get; set; } 10 | public int Code { get; set; } 11 | 12 | public static Result Success(T value) => new() {IsSuccess = true, Value = value}; 13 | public static Result Failure(string error, int code) => new() 14 | { 15 | IsSuccess = false, 16 | Error = error, 17 | Code = code 18 | }; 19 | } 20 | -------------------------------------------------------------------------------- /Application/Core/ValidationBehavior.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using FluentValidation; 3 | using MediatR; 4 | 5 | namespace Application.Core; 6 | 7 | public class ValidationBehavior(IValidator? validator = null) 8 | : IPipelineBehavior where TRequest : notnull 9 | { 10 | public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) 11 | { 12 | if (validator == null) return await next(); 13 | 14 | var validationResult = await validator.ValidateAsync(request, cancellationToken); 15 | 16 | if (!validationResult.IsValid) 17 | { 18 | throw new ValidationException(validationResult.Errors); 19 | } 20 | 21 | return await next(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Application/Interfaces/IPhotoService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using CloudinaryDotNet.Actions; 3 | using Microsoft.AspNetCore.Http; 4 | 5 | namespace Application.Interfaces; 6 | 7 | public interface IPhotoService 8 | { 9 | Task UploadPhoto(IFormFile file); 10 | Task DeletePhoto(string publicId); 11 | } 12 | -------------------------------------------------------------------------------- /Application/Interfaces/IUserAccessor.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Domain; 3 | 4 | namespace Application.Interfaces; 5 | 6 | public interface IUserAccessor 7 | { 8 | string GetUserId(); 9 | Task GetUserAsync(); 10 | Task GetUserWithPhotosAsync(); 11 | } 12 | -------------------------------------------------------------------------------- /Application/Profiles/Commands/AddPhoto.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Application.Core; 3 | using Application.Interfaces; 4 | using Domain; 5 | using MediatR; 6 | using Microsoft.AspNetCore.Http; 7 | using Persistence; 8 | 9 | namespace Application.Profiles.Commands; 10 | 11 | public class AddPhoto 12 | { 13 | public class Command : IRequest> 14 | { 15 | public required IFormFile File { get; set; } 16 | } 17 | 18 | public class Handler(IUserAccessor userAccessor, AppDbContext context, 19 | IPhotoService photoService) : IRequestHandler> 20 | { 21 | public async Task> Handle(Command request, CancellationToken cancellationToken) 22 | { 23 | var uploadResult = await photoService.UploadPhoto(request.File); 24 | 25 | if (uploadResult == null) return Result.Failure("Failed to upload photo", 400); 26 | if (uploadResult.Error != null) return Result.Failure(uploadResult.Error.Message, 400); 27 | 28 | var user = await userAccessor.GetUserAsync(); 29 | 30 | var photo = new Photo 31 | { 32 | Url = uploadResult.SecureUrl.AbsoluteUri, 33 | PublicId = uploadResult.PublicId, 34 | UserId = user.Id 35 | }; 36 | 37 | user.ImageUrl ??= photo.Url; 38 | 39 | context.Photos.Add(photo); 40 | 41 | var result = await context.SaveChangesAsync(cancellationToken) > 0; 42 | 43 | return result 44 | ? Result.Success(photo) 45 | : Result.Failure("Problem saving photo to DB", 400); 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Application/Profiles/Commands/DeletePhoto.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Application.Core; 3 | using Application.Interfaces; 4 | using MediatR; 5 | using Persistence; 6 | 7 | namespace Application.Profiles.Commands; 8 | 9 | public class DeletePhoto 10 | { 11 | public class Command : IRequest> 12 | { 13 | public required string PhotoId { get; set; } 14 | } 15 | 16 | public class Handler(AppDbContext context, IUserAccessor userAccessor, 17 | IPhotoService photoService) : IRequestHandler> 18 | { 19 | public async Task> Handle(Command request, CancellationToken cancellationToken) 20 | { 21 | var user = await userAccessor.GetUserWithPhotosAsync(); 22 | 23 | var photo = user.Photos.FirstOrDefault(x => x.Id == request.PhotoId); 24 | 25 | if (photo == null) return Result.Failure("Cannot find photo", 400); 26 | 27 | if (photo.Url == user.ImageUrl) 28 | return Result.Failure("Cannot delete main photo", 400); 29 | 30 | var deleteResult = await photoService.DeletePhoto(photo.PublicId); 31 | 32 | if (deleteResult.Error != null) 33 | return Result.Failure(deleteResult.Error.Message, 400); 34 | 35 | user.Photos.Remove(photo); 36 | 37 | var result = await context.SaveChangesAsync(cancellationToken) > 0; 38 | 39 | return result 40 | ? Result.Success(Unit.Value) 41 | : Result.Failure("Problem deleting photo", 400); 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Application/Profiles/Commands/EditProfile.cs: -------------------------------------------------------------------------------- 1 | using Application.Core; 2 | using Application.Interfaces; 3 | using MediatR; 4 | using Microsoft.EntityFrameworkCore; 5 | using Persistence; 6 | 7 | namespace Application.Profiles.Commands; 8 | 9 | public class EditProfile 10 | { 11 | public class Command : IRequest> 12 | { 13 | public string DisplayName { get; set; } = string.Empty; 14 | public string Bio { get; set; } = string.Empty; 15 | } 16 | 17 | public class Handler(AppDbContext context, IUserAccessor userAccessor) : IRequestHandler> 18 | { 19 | public async Task> Handle(Command request, CancellationToken cancellationToken) 20 | { 21 | var user = await userAccessor.GetUserAsync(); 22 | 23 | user.DisplayName = request.DisplayName; 24 | user.Bio = request.Bio; 25 | 26 | context.Entry(user).State = EntityState.Modified; 27 | 28 | var result = await context.SaveChangesAsync(cancellationToken) > 0; 29 | 30 | return result 31 | ? Result.Success(Unit.Value) 32 | : Result.Failure("Failed to update profile", 400); 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Application/Profiles/Commands/FollowToggle.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Application.Core; 3 | using Application.Interfaces; 4 | using Domain; 5 | using MediatR; 6 | using Persistence; 7 | 8 | namespace Application.Profiles.Commands; 9 | 10 | public class FollowToggle 11 | { 12 | public class Command : IRequest> 13 | { 14 | public required string TargetUserId { get; set; } 15 | } 16 | 17 | public class Handler(AppDbContext context, IUserAccessor userAccessor) 18 | : IRequestHandler> 19 | { 20 | public async Task> Handle(Command request, CancellationToken cancellationToken) 21 | { 22 | var observer = await userAccessor.GetUserAsync(); 23 | var target = await context.Users.FindAsync([request.TargetUserId], 24 | cancellationToken); 25 | 26 | if (target == null) return Result.Failure("Target user not found", 400); 27 | 28 | var following = await context.UserFollowings 29 | .FindAsync([observer.Id, target.Id], cancellationToken); 30 | 31 | if (following == null) context.UserFollowings.Add(new UserFollowing 32 | { 33 | ObserverId = observer.Id, 34 | TargetId = target.Id 35 | }); 36 | else context.UserFollowings.Remove(following); 37 | 38 | return await context.SaveChangesAsync(cancellationToken) > 0 39 | ? Result.Success(Unit.Value) 40 | : Result.Failure("Problem updating following", 400); 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Application/Profiles/Commands/SetMainPhoto.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Application.Core; 3 | using Application.Interfaces; 4 | using MediatR; 5 | using Persistence; 6 | 7 | namespace Application.Profiles.Commands; 8 | 9 | public class SetMainPhoto 10 | { 11 | public class Command : IRequest> 12 | { 13 | public required string PhotoId { get; set; } 14 | } 15 | 16 | public class Handler(AppDbContext context, IUserAccessor userAccessor) 17 | : IRequestHandler> 18 | { 19 | public async Task> Handle(Command request, CancellationToken cancellationToken) 20 | { 21 | var user = await userAccessor.GetUserWithPhotosAsync(); 22 | 23 | var photo = user.Photos.FirstOrDefault(x => x.Id == request.PhotoId); 24 | 25 | if (photo == null) return Result.Failure("Cannot find photo", 400); 26 | 27 | user.ImageUrl = photo.Url; 28 | 29 | var result = await context.SaveChangesAsync(cancellationToken) > 0; 30 | 31 | return result 32 | ? Result.Success(Unit.Value) 33 | : Result.Failure("Problem updating photo", 400); 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Application/Profiles/DTOs/UserActivityDto.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Application.Profiles.DTOs; 4 | 5 | public class UserActivityDto 6 | { 7 | public required string Id { get; set; } 8 | public required string Title { get; set; } 9 | public required string Category { get; set; } 10 | public DateTime Date { get; set; } 11 | } 12 | -------------------------------------------------------------------------------- /Application/Profiles/DTOs/UserProfile.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Application.Profiles.DTOs; 4 | 5 | public class UserProfile 6 | { 7 | public required string Id { get; set; } 8 | public required string DisplayName { get; set; } 9 | public string? Bio { get; set; } 10 | public string? ImageUrl { get; set; } 11 | public bool Following { get; set; } 12 | public int FollowersCount { get; set; } 13 | public int FollowingCount { get; set; } 14 | } 15 | -------------------------------------------------------------------------------- /Application/Profiles/Queries/GetFollowings.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Application.Core; 3 | using Application.Interfaces; 4 | using Application.Profiles.DTOs; 5 | using AutoMapper; 6 | using AutoMapper.QueryableExtensions; 7 | using MediatR; 8 | using Microsoft.EntityFrameworkCore; 9 | using Persistence; 10 | 11 | namespace Application.Profiles.Queries; 12 | 13 | public class GetFollowings 14 | { 15 | public class Query : IRequest>> 16 | { 17 | public string Predicate { get; set; } = "followers"; 18 | public required string UserId { get; set; } 19 | } 20 | 21 | public class Handler(AppDbContext context, IMapper mapper, IUserAccessor userAccessor) 22 | : IRequestHandler>> 23 | { 24 | public async Task>> Handle(Query request, CancellationToken cancellationToken) 25 | { 26 | var profiles = new List(); 27 | 28 | switch (request.Predicate) 29 | { 30 | case "followers": 31 | profiles = await context.UserFollowings.Where(x => x.TargetId == request.UserId) 32 | .Select(x => x.Observer) 33 | .ProjectTo(mapper.ConfigurationProvider, 34 | new {currentUserId = userAccessor.GetUserId()}) 35 | .ToListAsync(cancellationToken); 36 | break; 37 | case "followings": 38 | profiles = await context.UserFollowings.Where(x => x.ObserverId == request.UserId) 39 | .Select(x => x.Target) 40 | .ProjectTo(mapper.ConfigurationProvider, 41 | new {currentUserId = userAccessor.GetUserId()}) 42 | .ToListAsync(cancellationToken); 43 | break; 44 | } 45 | 46 | return Result>.Success(profiles); 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Application/Profiles/Queries/GetProfile.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Application.Core; 3 | using Application.Interfaces; 4 | using Application.Profiles.DTOs; 5 | using AutoMapper; 6 | using AutoMapper.QueryableExtensions; 7 | using MediatR; 8 | using Microsoft.EntityFrameworkCore; 9 | using Persistence; 10 | 11 | namespace Application.Profiles.Queries; 12 | 13 | public class GetProfile 14 | { 15 | public class Query : IRequest> 16 | { 17 | public required string UserId { get; set; } 18 | } 19 | 20 | public class Handler(AppDbContext context, IMapper mapper, IUserAccessor userAccessor) 21 | : IRequestHandler> 22 | { 23 | public async Task> Handle(Query request, CancellationToken cancellationToken) 24 | { 25 | var profile = await context.Users 26 | .ProjectTo(mapper.ConfigurationProvider, 27 | new {currentUserId = userAccessor.GetUserId()}) 28 | .SingleOrDefaultAsync(x => x.Id == request.UserId, cancellationToken); 29 | 30 | return profile == null 31 | ? Result.Failure("Profile not found", 404) 32 | : Result.Success(profile); 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Application/Profiles/Queries/GetProfilePhotos.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Application.Core; 3 | using Domain; 4 | using MediatR; 5 | using Microsoft.EntityFrameworkCore; 6 | using Persistence; 7 | 8 | namespace Application.Profiles.Queries; 9 | 10 | public class GetProfilePhotos 11 | { 12 | public class Query : IRequest>> 13 | { 14 | public required string UserId { get; set; } 15 | } 16 | 17 | public class Handler(AppDbContext context) : IRequestHandler>> 18 | { 19 | public async Task>> Handle(Query request, CancellationToken cancellationToken) 20 | { 21 | var photos = await context.Users 22 | .Where(x => x.Id == request.UserId) 23 | .SelectMany(x => x.Photos) 24 | .ToListAsync(cancellationToken); 25 | 26 | return Result>.Success(photos); 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Application/Profiles/Queries/GetUserActivities.cs: -------------------------------------------------------------------------------- 1 | using Application.Core; 2 | using Application.Profiles.DTOs; 3 | using AutoMapper; 4 | using AutoMapper.QueryableExtensions; 5 | using MediatR; 6 | using Microsoft.EntityFrameworkCore; 7 | using Persistence; 8 | 9 | namespace Application.Profiles.Queries; 10 | 11 | public class GetUserActivities 12 | { 13 | public class Query : IRequest>> 14 | { 15 | public required string UserId { get; set; } 16 | public required string Filter { get; set; } 17 | } 18 | 19 | public class Handler(AppDbContext context, IMapper mapper) : IRequestHandler>> 20 | { 21 | public async Task>> Handle(Query request, CancellationToken cancellationToken) 22 | { 23 | var query = context.ActivityAttendees 24 | .Where(u => u.User.Id == request.UserId) 25 | .OrderBy(a => a.Activity.Date) 26 | .Select(x => x.Activity) 27 | .AsQueryable(); 28 | 29 | var today = DateTime.UtcNow; 30 | 31 | query = request.Filter switch 32 | { 33 | "past" => query.Where(a => a.Date <= today 34 | && a.Attendees.Any(x => x.UserId == request.UserId)), 35 | "hosting" => query.Where(a => a.Attendees.Any(x => x.IsHost && x.UserId == request.UserId)), 36 | _ => query.Where(a => a.Date >= today 37 | && a.Attendees.Any(x => x.UserId == request.UserId)) 38 | }; 39 | 40 | var projectedActivities = query 41 | .ProjectTo(mapper.ConfigurationProvider); 42 | 43 | var activities = await projectedActivities.ToListAsync(cancellationToken); 44 | 45 | return Result>.Success(activities); 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Application/Profiles/Validators/EditProfileValidator.cs: -------------------------------------------------------------------------------- 1 | using Application.Profiles.Commands; 2 | using FluentValidation; 3 | 4 | namespace Application.Profiles.Validators; 5 | 6 | public class EditProfileValidator : AbstractValidator 7 | { 8 | public EditProfileValidator() 9 | { 10 | RuleFor(x => x.DisplayName).NotEmpty(); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Domain/Activity.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.EntityFrameworkCore; 3 | 4 | namespace Domain; 5 | 6 | [Index(nameof(Date))] 7 | public class Activity 8 | { 9 | public string Id { get; set; } = Guid.NewGuid().ToString(); 10 | public required string Title { get; set; } 11 | public DateTime Date { get; set; } 12 | public required string Description { get; set; } 13 | public required string Category { get; set; } 14 | public bool IsCancelled { get; set; } 15 | 16 | // location props 17 | public required string City { get; set; } 18 | public required string Venue { get; set; } 19 | public double Latitude { get; set; } 20 | public double Longitude { get; set; } 21 | 22 | // navigation properties 23 | public ICollection Attendees { get; set; } = []; 24 | public ICollection Comments { get; set; } = []; 25 | } 26 | -------------------------------------------------------------------------------- /Domain/ActivityAttendee.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Domain; 4 | 5 | public class ActivityAttendee 6 | { 7 | public string? UserId { get; set; } 8 | public User User { get; set; } = null!; 9 | public string? ActivityId { get; set; } 10 | public Activity Activity { get; set; } = null!; 11 | public bool IsHost { get; set; } 12 | public DateTime DateJoined { get; set; } = DateTime.UtcNow; 13 | } 14 | -------------------------------------------------------------------------------- /Domain/Comment.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Domain; 4 | 5 | public class Comment 6 | { 7 | public string Id { get; set; } = Guid.NewGuid().ToString(); 8 | public required string Body { get; set; } 9 | public DateTime CreatedAt { get; set; } = DateTime.UtcNow; 10 | 11 | // Nav properties 12 | public required string UserId { get; set; } 13 | public User User { get; set; } = null!; 14 | 15 | public required string ActivityId { get; set; } 16 | public Activity Activity { get; set; } = null!; 17 | } 18 | -------------------------------------------------------------------------------- /Domain/Domain.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net9.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /Domain/Photo.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text.Json.Serialization; 3 | 4 | namespace Domain; 5 | 6 | public class Photo 7 | { 8 | public string Id { get; set; } = Guid.NewGuid().ToString(); 9 | public required string Url { get; set; } 10 | public required string PublicId { get; set; } 11 | 12 | // nav properties 13 | public required string UserId { get; set; } 14 | 15 | [JsonIgnore] 16 | public User User { get; set; } = null!; 17 | } 18 | -------------------------------------------------------------------------------- /Domain/User.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.AspNetCore.Identity; 3 | 4 | namespace Domain; 5 | 6 | public class User : IdentityUser 7 | { 8 | public string? DisplayName { get; set; } 9 | public string? Bio { get; set; } 10 | public string? ImageUrl { get; set; } 11 | 12 | // nav properties 13 | public ICollection Activities { get; set; } = []; 14 | public ICollection Photos { get; set; } = []; 15 | public ICollection Followings { get; set; } = []; 16 | public ICollection Followers { get; set; } = []; 17 | } 18 | -------------------------------------------------------------------------------- /Domain/UserFollowing.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Domain; 4 | 5 | public class UserFollowing 6 | { 7 | public required string ObserverId { get; set; } 8 | public User Observer { get; set; } = null!; // Follower 9 | public required string TargetId { get; set; } 10 | public User Target { get; set; } = null!; // Followee 11 | } 12 | -------------------------------------------------------------------------------- /Infrastructure/Email/EmailSender.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Domain; 3 | using Microsoft.AspNetCore.Identity; 4 | using Microsoft.Extensions.Configuration; 5 | using Resend; 6 | 7 | namespace Infrastructure.Email; 8 | 9 | public class EmailSender(IResend resend, IConfiguration config) : IEmailSender 10 | { 11 | public async Task SendConfirmationLinkAsync(User user, string email, string confirmationLink) 12 | { 13 | var subject = "Confirm your email address"; 14 | var body = $@" 15 |

Hi {user.DisplayName}

16 |

Please confirm your email by clicking the link below

17 |

Click here to verify email

18 |

Thanks

19 | "; 20 | 21 | await SendMailAsync(email, subject, body); 22 | } 23 | 24 | public async Task SendPasswordResetCodeAsync(User user, string email, string resetCode) 25 | { 26 | var subject = "Reset your password"; 27 | var body = $@" 28 |

Hi {user.DisplayName},

29 |

Please click this link to reset your password

30 |

31 | Click to reset your password 32 |

33 |

If you did not request this, you can ignore this email 34 |

35 | "; 36 | 37 | await SendMailAsync(email, subject, body); 38 | } 39 | 40 | public Task SendPasswordResetLinkAsync(User user, string email, string resetLink) 41 | { 42 | throw new NotImplementedException(); 43 | } 44 | 45 | private async Task SendMailAsync(string email, string subject, string body) 46 | { 47 | var message = new EmailMessage 48 | { 49 | From = "no-reply@resend.trycatchlearn.com", 50 | Subject = subject, 51 | HtmlBody = body 52 | }; 53 | message.To.Add(email); 54 | 55 | Console.WriteLine(message.HtmlBody); 56 | 57 | await resend.EmailSendAsync(message); 58 | // await Task.CompletedTask; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Infrastructure/Infrastructure.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net9.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /Infrastructure/Photos/CloudinarySettings.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Infrastructure.Photos; 4 | 5 | public class CloudinarySettings 6 | { 7 | public required string CloudName { get; set; } 8 | public required string ApiKey { get; set; } 9 | public required string ApiSecret { get; set; } 10 | } 11 | -------------------------------------------------------------------------------- /Infrastructure/Photos/PhotoService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Application.Interfaces; 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 PhotoService : IPhotoService 11 | { 12 | private readonly Cloudinary _cloudinary; 13 | 14 | public PhotoService(IOptions config) 15 | { 16 | var account = new Account( 17 | config.Value.CloudName, 18 | config.Value.ApiKey, 19 | config.Value.ApiSecret 20 | ); 21 | 22 | _cloudinary = new Cloudinary(account); 23 | } 24 | 25 | public async Task DeletePhoto(string publicId) 26 | { 27 | var deleteParams = new DeletionParams(publicId); 28 | 29 | return await _cloudinary.DestroyAsync(deleteParams); 30 | } 31 | 32 | public async Task UploadPhoto(IFormFile file) 33 | { 34 | if (file.Length <= 0) return null; 35 | 36 | await using var stream = file.OpenReadStream(); 37 | 38 | var uploadParams = new ImageUploadParams 39 | { 40 | File = new FileDescription(file.FileName, stream), 41 | Folder = "Reactivities2025" 42 | }; 43 | 44 | var uploadResult = await _cloudinary.UploadAsync(uploadParams); 45 | 46 | return uploadResult; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Infrastructure/Security/IsHostRequirement.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Security.Claims; 3 | using Microsoft.AspNetCore.Authorization; 4 | using Microsoft.AspNetCore.Http; 5 | using Microsoft.AspNetCore.Routing; 6 | using Microsoft.EntityFrameworkCore; 7 | using Persistence; 8 | 9 | namespace Infrastructure.Security; 10 | 11 | public class IsHostRequirement : IAuthorizationRequirement 12 | { 13 | } 14 | 15 | public class IsHostRequirementHandler(AppDbContext dbContext, IHttpContextAccessor httpContextAccessor) 16 | : AuthorizationHandler 17 | { 18 | protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, IsHostRequirement requirement) 19 | { 20 | var userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier); 21 | if (userId == null) return; 22 | 23 | var httpContext = httpContextAccessor.HttpContext; 24 | 25 | if (httpContext?.GetRouteValue("id") is not string activityId) return; 26 | 27 | var attendee = await dbContext.ActivityAttendees 28 | .AsNoTracking() 29 | .SingleOrDefaultAsync(x => x.UserId == userId && x.ActivityId == activityId); 30 | 31 | if (attendee == null) return; 32 | 33 | if (attendee.IsHost) context.Succeed(requirement); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Infrastructure/Security/UserAccessor.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Security.Claims; 3 | using Application.Interfaces; 4 | using Domain; 5 | using Microsoft.AspNetCore.Http; 6 | using Microsoft.EntityFrameworkCore; 7 | using Persistence; 8 | 9 | namespace Infrastructure.Security; 10 | 11 | public class UserAccessor(IHttpContextAccessor httpContextAccessor, AppDbContext dbContext) 12 | : IUserAccessor 13 | { 14 | public async Task GetUserAsync() 15 | { 16 | return await dbContext.Users.FindAsync(GetUserId()) 17 | ?? throw new UnauthorizedAccessException("No user is logged in"); 18 | } 19 | 20 | public string GetUserId() 21 | { 22 | return httpContextAccessor.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier) 23 | ?? throw new Exception("No user found"); 24 | } 25 | 26 | public async Task GetUserWithPhotosAsync() 27 | { 28 | var userId = GetUserId(); 29 | 30 | return await dbContext.Users 31 | .Include(x => x.Photos) 32 | .FirstOrDefaultAsync(x => x.Id == userId) 33 | ?? throw new UnauthorizedAccessException("No user is logged in"); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Persistence/AppDbContext.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Domain; 3 | using Microsoft.AspNetCore.Identity.EntityFrameworkCore; 4 | using Microsoft.EntityFrameworkCore; 5 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 6 | 7 | namespace Persistence; 8 | 9 | public class AppDbContext(DbContextOptions options) : IdentityDbContext(options) 10 | { 11 | public required DbSet Activities { get; set; } 12 | public required DbSet ActivityAttendees { get; set; } 13 | public required DbSet Photos { get; set; } 14 | public required DbSet Comments { get; set; } 15 | public required DbSet UserFollowings { get; set; } 16 | 17 | protected override void OnModelCreating(ModelBuilder builder) 18 | { 19 | base.OnModelCreating(builder); 20 | 21 | builder.Entity(x => x.HasKey(a => new { a.ActivityId, a.UserId })); 22 | 23 | builder.Entity() 24 | .HasOne(x => x.User) 25 | .WithMany(x => x.Activities) 26 | .HasForeignKey(x => x.UserId); 27 | 28 | builder.Entity() 29 | .HasOne(x => x.Activity) 30 | .WithMany(x => x.Attendees) 31 | .HasForeignKey(x => x.ActivityId); 32 | 33 | builder.Entity(x => 34 | { 35 | x.HasKey(k => new {k.ObserverId, k.TargetId}); 36 | 37 | x.HasOne(o => o.Observer) 38 | .WithMany(f => f.Followings) 39 | .HasForeignKey(o => o.ObserverId) 40 | .OnDelete(DeleteBehavior.Cascade); 41 | 42 | x.HasOne(o => o.Target) 43 | .WithMany(f => f.Followers) 44 | .HasForeignKey(o => o.TargetId) 45 | .OnDelete(DeleteBehavior.NoAction); 46 | }); 47 | 48 | var dateTimeConverter = new ValueConverter( 49 | v => v.ToUniversalTime(), 50 | v => DateTime.SpecifyKind(v, DateTimeKind.Utc) 51 | ); 52 | 53 | foreach (var entityType in builder.Model.GetEntityTypes()) 54 | { 55 | foreach (var property in entityType.GetProperties()) 56 | { 57 | if (property.ClrType == typeof(DateTime)) 58 | { 59 | property.SetValueConverter(dateTimeConverter); 60 | } 61 | } 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Persistence/Persistence.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net9.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Reactivities Project Repository 2 | 3 | Welcome to the brand new version of the Reactivities app created for the Udemy training course available [here](https://www.udemy.com/course/complete-guide-to-building-an-app-with-net-core-and-react). 4 | 5 | This has been rewritten from scratch to take advantage of and to make it (hopefully) a bit more futureproof. This app is built using .Net 9 and React 19 6 | 7 | # Running the project 8 | 9 | You can see a live demo of this project [here](https://reactivities-course.azurewebsites.net/). 10 | 11 | To get into the app you will need to sign up with a valid email account or just use GitHub login as email verification is part of the app functionality in the published version of the app. 12 | 13 | You can also run this app locally. The easiest way to do this without needing a database server is to use the version of the app before publishing which does not require a valid email address or Sql Server. Most of the functionality will work except for the photo upload which would require you to sign up to Cloudinary (free) and use your own API keys here. You need to have the following installed on your computer for this to work: 14 | 15 | 1. .Net SDK v9 16 | 2. NodeJS (at least version 18+ or 20+) 17 | 3. git (to be able to clone the project repo) 18 | 19 | Once you have these then you can do the following: 20 | 1. Clone the project in a User folder on your computer by running: 21 | 22 | ```bash 23 | # you will of course need git installed to run this 24 | git clone https://github.com/TryCatchLearn/Reactivities.git 25 | cd Reactivities 26 | ``` 27 | 2. Checkout a version of the project that uses Sqlite and does not require email confirmation: 28 | ```bash 29 | git checkout 684e26a 30 | ``` 31 | 3. Restore the packages by running: 32 | 33 | ```bash 34 | # From the solution folder (Reactivities) 35 | dotnet restore 36 | 37 | # Change directory to client to run the npm install. Only necessary if you want to run 38 | # the react app in development mode using the Vite dev server 39 | cd client 40 | npm install 41 | ``` 42 | 43 | 4. If you wish for the photo upload to work create a file called appsettings.json in the Reactivities/API folder and copy/paste the following configuration. 44 | 45 | ```json 46 | { 47 | "Logging": { 48 | "LogLevel": { 49 | "Default": "Information", 50 | "Microsoft.AspNetCore": "Warning" 51 | } 52 | }, 53 | "CloudinarySettings": { 54 | "CloudName": "REPLACEME", 55 | "ApiKey": "REPLACEME", 56 | "ApiSecret": "REPLACEME" 57 | }, 58 | "AllowedHosts": "*" 59 | } 60 | ``` 61 | 5. Create an account (free of charge, no credit card required) at https://cloudinary.com and then replace the Cloudinary keys in the appsettings.json file with your own cloudinary keys. 62 | 63 | 6. You can then run the app and browse to it locally by running: 64 | 65 | ```bash 66 | # run this from the API folder in one terminal/command prompt 67 | cd API 68 | dotnet run 69 | 70 | # open another terminal/command prompt tab and run the following 71 | cd client 72 | npm run dev 73 | 74 | ``` 75 | 76 | 7. You can then browse to the app on https://localhost:3000 and login with either of the test users: 77 | 78 | email: bob@test.com or tom@test.com or jane@test.com 79 | 80 | password: Pa$$w0rd 81 | 82 | # Legacy repositories 83 | 84 | This repo contains the latest version code for the course released in February 2025. If you want to see the historical and legacy code for prior versions of the course then please visit: 85 | 86 | [.Net 7/React 18](https://github.com/TryCatchLearn/Reactivities-net7react18) 87 | 88 | [.Net 5/React 17](https://github.com/TryCatchLearn/Reactivities-v6) 89 | -------------------------------------------------------------------------------- /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", "{C4274245-CD6B-417A-86DB-40F9CAF8BA76}" 7 | EndProject 8 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Application", "Application\Application.csproj", "{F23C988E-DA19-4315-9010-9A5CD245EC69}" 9 | EndProject 10 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Domain", "Domain\Domain.csproj", "{F40783ED-9E43-4AAE-ABFF-37006E821186}" 11 | EndProject 12 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Persistence", "Persistence\Persistence.csproj", "{BAD9A5A1-CC75-4D11-AEAE-8296E377803F}" 13 | EndProject 14 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Infrastructure", "Infrastructure\Infrastructure.csproj", "{97EC6052-FB8A-4143-8075-B8A84DD45F39}" 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 | {C4274245-CD6B-417A-86DB-40F9CAF8BA76}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 26 | {C4274245-CD6B-417A-86DB-40F9CAF8BA76}.Debug|Any CPU.Build.0 = Debug|Any CPU 27 | {C4274245-CD6B-417A-86DB-40F9CAF8BA76}.Release|Any CPU.ActiveCfg = Release|Any CPU 28 | {C4274245-CD6B-417A-86DB-40F9CAF8BA76}.Release|Any CPU.Build.0 = Release|Any CPU 29 | {F23C988E-DA19-4315-9010-9A5CD245EC69}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 30 | {F23C988E-DA19-4315-9010-9A5CD245EC69}.Debug|Any CPU.Build.0 = Debug|Any CPU 31 | {F23C988E-DA19-4315-9010-9A5CD245EC69}.Release|Any CPU.ActiveCfg = Release|Any CPU 32 | {F23C988E-DA19-4315-9010-9A5CD245EC69}.Release|Any CPU.Build.0 = Release|Any CPU 33 | {F40783ED-9E43-4AAE-ABFF-37006E821186}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 34 | {F40783ED-9E43-4AAE-ABFF-37006E821186}.Debug|Any CPU.Build.0 = Debug|Any CPU 35 | {F40783ED-9E43-4AAE-ABFF-37006E821186}.Release|Any CPU.ActiveCfg = Release|Any CPU 36 | {F40783ED-9E43-4AAE-ABFF-37006E821186}.Release|Any CPU.Build.0 = Release|Any CPU 37 | {BAD9A5A1-CC75-4D11-AEAE-8296E377803F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 38 | {BAD9A5A1-CC75-4D11-AEAE-8296E377803F}.Debug|Any CPU.Build.0 = Debug|Any CPU 39 | {BAD9A5A1-CC75-4D11-AEAE-8296E377803F}.Release|Any CPU.ActiveCfg = Release|Any CPU 40 | {BAD9A5A1-CC75-4D11-AEAE-8296E377803F}.Release|Any CPU.Build.0 = Release|Any CPU 41 | {97EC6052-FB8A-4143-8075-B8A84DD45F39}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 42 | {97EC6052-FB8A-4143-8075-B8A84DD45F39}.Debug|Any CPU.Build.0 = Debug|Any CPU 43 | {97EC6052-FB8A-4143-8075-B8A84DD45F39}.Release|Any CPU.ActiveCfg = Release|Any CPU 44 | {97EC6052-FB8A-4143-8075-B8A84DD45F39}.Release|Any CPU.Build.0 = Release|Any CPU 45 | EndGlobalSection 46 | EndGlobal 47 | -------------------------------------------------------------------------------- /client/.env.development: -------------------------------------------------------------------------------- 1 | VITE_API_URL=https://localhost:5001/api 2 | VITE_COMMENTS_URL=https://localhost:5001/comments 3 | VITE_GIHUB_CLIENT_ID=Ov23lio5ka7NaNTownQM 4 | VITE_REDIRECT_URL=https://localhost:3000/auth-callback -------------------------------------------------------------------------------- /client/.env.production: -------------------------------------------------------------------------------- 1 | VITE_API_URL=/api 2 | VITE_COMMENTS_URL=/comments 3 | VITE_GIHUB_CLIENT_ID=Ov23lifPYWRxh7GPksbO 4 | VITE_REDIRECT_URL=https://reactivities-course.azurewebsites.net/auth-callback -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | # React + TypeScript + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | 10 | ## Expanding the ESLint configuration 11 | 12 | If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: 13 | 14 | - Configure the top-level `parserOptions` property like this: 15 | 16 | ```js 17 | export default tseslint.config({ 18 | languageOptions: { 19 | // other options... 20 | parserOptions: { 21 | project: ['./tsconfig.node.json', './tsconfig.app.json'], 22 | tsconfigRootDir: import.meta.dirname, 23 | }, 24 | }, 25 | }) 26 | ``` 27 | 28 | - Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked` 29 | - Optionally add `...tseslint.configs.stylisticTypeChecked` 30 | - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config: 31 | 32 | ```js 33 | // eslint.config.js 34 | import react from 'eslint-plugin-react' 35 | 36 | export default tseslint.config({ 37 | // Set the react version 38 | settings: { react: { version: '18.3' } }, 39 | plugins: { 40 | // Add the react plugin 41 | react, 42 | }, 43 | rules: { 44 | // other rules... 45 | // Enable its recommended rules 46 | ...react.configs.recommended.rules, 47 | ...react.configs['jsx-runtime'].rules, 48 | }, 49 | }) 50 | ``` 51 | -------------------------------------------------------------------------------- /client/eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | import tseslint from 'typescript-eslint' 6 | 7 | export default tseslint.config( 8 | { ignores: ['dist'] }, 9 | { 10 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 11 | files: ['**/*.{ts,tsx}'], 12 | languageOptions: { 13 | ecmaVersion: 2020, 14 | globals: globals.browser, 15 | }, 16 | plugins: { 17 | 'react-hooks': reactHooks, 18 | 'react-refresh': reactRefresh, 19 | }, 20 | rules: { 21 | ...reactHooks.configs.recommended.rules, 22 | 'react-refresh/only-export-components': [ 23 | 'warn', 24 | { allowConstantExport: true }, 25 | ], 26 | }, 27 | }, 28 | ) 29 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Reactivities 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc -b && vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@emotion/react": "^11.14.0", 14 | "@emotion/styled": "^11.14.0", 15 | "@fontsource/roboto": "^5.1.1", 16 | "@hookform/resolvers": "^3.10.0", 17 | "@microsoft/signalr": "^8.0.7", 18 | "@mui/icons-material": "^6.3.0", 19 | "@mui/material": "^6.3.0", 20 | "@mui/x-date-pickers": "^7.23.6", 21 | "@tanstack/react-query": "^5.62.16", 22 | "@tanstack/react-query-devtools": "^5.62.16", 23 | "axios": "^1.7.9", 24 | "date-fns": "^4.1.0", 25 | "leaflet": "^1.9.4", 26 | "mobx": "^6.13.5", 27 | "mobx-react-lite": "^4.1.0", 28 | "react": "^19.0.0", 29 | "react-calendar": "^5.1.0", 30 | "react-cropper": "^2.3.3", 31 | "react-dom": "^19.0.0", 32 | "react-dropzone": "^14.3.5", 33 | "react-hook-form": "^7.54.2", 34 | "react-intersection-observer": "^9.15.1", 35 | "react-leaflet": "^5.0.0", 36 | "react-router": "^7.1.1", 37 | "react-toastify": "^11.0.2", 38 | "zod": "^3.24.1" 39 | }, 40 | "devDependencies": { 41 | "@eslint/js": "^9.17.0", 42 | "@types/leaflet": "^1.9.16", 43 | "@types/react": "^18.3.18", 44 | "@types/react-dom": "^18.3.5", 45 | "@vitejs/plugin-react-swc": "^3.5.0", 46 | "eslint": "^9.17.0", 47 | "eslint-plugin-react-hooks": "^5.0.0", 48 | "eslint-plugin-react-refresh": "^0.4.16", 49 | "globals": "^15.14.0", 50 | "typescript": "~5.6.2", 51 | "typescript-eslint": "^8.18.2", 52 | "vite": "^6.0.5", 53 | "vite-plugin-mkcert": "^1.17.6" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /client/public/images/categoryImages/culture.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/client/public/images/categoryImages/culture.jpg -------------------------------------------------------------------------------- /client/public/images/categoryImages/drinks.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/client/public/images/categoryImages/drinks.jpg -------------------------------------------------------------------------------- /client/public/images/categoryImages/film.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/client/public/images/categoryImages/film.jpg -------------------------------------------------------------------------------- /client/public/images/categoryImages/food.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/client/public/images/categoryImages/food.jpg -------------------------------------------------------------------------------- /client/public/images/categoryImages/music.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/client/public/images/categoryImages/music.jpg -------------------------------------------------------------------------------- /client/public/images/categoryImages/travel.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/client/public/images/categoryImages/travel.jpg -------------------------------------------------------------------------------- /client/public/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/client/public/images/logo.png -------------------------------------------------------------------------------- /client/public/images/placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/client/public/images/placeholder.png -------------------------------------------------------------------------------- /client/public/images/user.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Reactivities/1652139d93c0898e5c0904215daef0c5eaa8abad/client/public/images/user.png -------------------------------------------------------------------------------- /client/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/app/layout/App.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Container, CssBaseline } from "@mui/material"; 2 | import NavBar from "./NavBar"; 3 | import { Outlet, ScrollRestoration, useLocation } from "react-router"; 4 | import HomePage from "../../features/home/HomePage"; 5 | 6 | function App() { 7 | const location = useLocation(); 8 | 9 | return ( 10 | 11 | 12 | 13 | {location.pathname === '/' ? : ( 14 | <> 15 | 16 | 17 | 18 | 19 | 20 | )} 21 | 22 | ) 23 | } 24 | 25 | export default App 26 | -------------------------------------------------------------------------------- /client/src/app/layout/NavBar.tsx: -------------------------------------------------------------------------------- 1 | import { Group } from "@mui/icons-material"; 2 | import { Box, AppBar, Toolbar, Typography, Container, MenuItem, CircularProgress } from "@mui/material"; 3 | import { NavLink } from "react-router"; 4 | import MenuItemLink from "../shared/components/MenuItemLink"; 5 | import { useStore } from "../../lib/hooks/useStore"; 6 | import { Observer } from "mobx-react-lite"; 7 | import { useAccount } from "../../lib/hooks/useAccount"; 8 | import UserMenu from "./UserMenu"; 9 | 10 | export default function NavBar() { 11 | const { uiStore } = useStore(); 12 | const { currentUser } = useAccount(); 13 | 14 | return ( 15 | 16 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | Reactivities 27 | 28 | 29 | {() => uiStore.isLoading ? ( 30 | 40 | ) : null} 41 | 42 | 43 | 44 | 45 | 46 | Activities 47 | 48 | 49 | Counter 50 | 51 | 52 | Errors 53 | 54 | 55 | 56 | {currentUser ? ( 57 | 58 | ) : ( 59 | <> 60 | Login 61 | Register 62 | 63 | )} 64 | 65 | 66 | 67 | 68 | 69 | ) 70 | } -------------------------------------------------------------------------------- /client/src/app/layout/UserMenu.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Button from '@mui/material/Button'; 3 | import Menu from '@mui/material/Menu'; 4 | import MenuItem from '@mui/material/MenuItem'; 5 | import { useState } from 'react'; 6 | import { Avatar, Box, Divider, ListItemIcon, ListItemText } from '@mui/material'; 7 | import { useAccount } from '../../lib/hooks/useAccount'; 8 | import { Link } from 'react-router'; 9 | import { Add, Logout, Password, Person } from '@mui/icons-material'; 10 | 11 | export default function UserMenu() { 12 | const { currentUser, logoutUser } = useAccount(); 13 | const [anchorEl, setAnchorEl] = useState(null); 14 | const open = Boolean(anchorEl); 15 | 16 | const handleClick = (event: React.MouseEvent) => { 17 | setAnchorEl(event.currentTarget); 18 | }; 19 | 20 | const handleClose = () => { 21 | setAnchorEl(null); 22 | }; 23 | 24 | return ( 25 | <> 26 | 40 | 49 | 50 | 51 | 52 | 53 | Create Activity 54 | 55 | 56 | 57 | 58 | 59 | My profile 60 | 61 | 62 | 63 | 64 | 65 | Change password 66 | 67 | 68 | { 69 | logoutUser.mutate(); 70 | handleClose(); 71 | }}> 72 | 73 | 74 | 75 | Logout 76 | 77 | 78 | 79 | ); 80 | } 81 | -------------------------------------------------------------------------------- /client/src/app/layout/styles.css: -------------------------------------------------------------------------------- 1 | .react-calendar { 2 | width: 100% !important; 3 | border: none !important; 4 | } -------------------------------------------------------------------------------- /client/src/app/router/RequireAuth.tsx: -------------------------------------------------------------------------------- 1 | import { Navigate, Outlet, useLocation } from "react-router"; 2 | import { useAccount } from "../../lib/hooks/useAccount" 3 | import { Typography } from "@mui/material"; 4 | 5 | export default function RequireAuth() { 6 | const { currentUser, loadingUserInfo } = useAccount(); 7 | const location = useLocation(); 8 | 9 | if (loadingUserInfo) return Loading... 10 | 11 | if (!currentUser) return 12 | 13 | return ( 14 | 15 | ) 16 | } -------------------------------------------------------------------------------- /client/src/app/router/Routes.tsx: -------------------------------------------------------------------------------- 1 | import { createBrowserRouter, Navigate } from "react-router"; 2 | import App from "../layout/App"; 3 | import HomePage from "../../features/home/HomePage"; 4 | import ActivityDashboard from "../../features/activities/dashboard/ActivityDashboard"; 5 | import ActivityForm from "../../features/activities/form/ActivityForm"; 6 | import ActivityDetailPage from "../../features/activities/details/ActivityDetailPage"; 7 | import Counter from "../../features/counter/Counter"; 8 | import TestErrors from "../../features/errors/TestErrors"; 9 | import NotFound from "../../features/errors/NotFound"; 10 | import ServerError from "../../features/errors/ServerError"; 11 | import LoginForm from "../../features/account/LoginForm"; 12 | import RequireAuth from "./RequireAuth"; 13 | import RegisterForm from "../../features/account/RegisterForm"; 14 | import ProfilePage from "../../features/profiles/ProfilePage"; 15 | import VerifyEmail from "../../features/account/VerifyEmail"; 16 | import ChangePasswordForm from "../../features/account/ChangePasswordForm"; 17 | import ForgotPasswordForm from "../../features/account/ForgotPasswordForm"; 18 | import ResetPasswordForm from "../../features/account/ResetPasswordForm"; 19 | import AuthCallback from "../../features/account/AuthCallback"; 20 | 21 | export const router = createBrowserRouter([ 22 | { 23 | path: '/', 24 | element: , 25 | children: [ 26 | {element: , children: [ 27 | { path: 'activities', element: }, 28 | { path: 'activities/:id', element: }, 29 | { path: 'createActivity', element: }, 30 | { path: 'manage/:id', element: }, 31 | { path: 'profiles/:id', element: }, 32 | { path: 'change-password', element: }, 33 | ]}, 34 | { path: '', element: }, 35 | { path: 'counter', element: }, 36 | { path: 'errors', element: }, 37 | { path: 'not-found', element: }, 38 | { path: 'server-error', element: }, 39 | { path: 'login', element: }, 40 | { path: 'register', element: }, 41 | { path: 'confirm-email', element: }, 42 | { path: 'forgot-password', element: }, 43 | { path: 'reset-password', element: }, 44 | { path: 'auth-callback', element: }, 45 | { path: '*', element: }, 46 | ] 47 | } 48 | ]) -------------------------------------------------------------------------------- /client/src/app/shared/components/AvatarPopover.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Popover from '@mui/material/Popover'; 3 | import { useState } from 'react'; 4 | import { Avatar } from '@mui/material'; 5 | import { Link } from 'react-router'; 6 | import ProfileCard from '../../../features/profiles/ProfileCard'; 7 | 8 | type Props = { 9 | profile: Profile 10 | } 11 | 12 | export default function AvatarPopover({ profile }: Props) { 13 | const [anchorEl, setAnchorEl] = useState(null); 14 | 15 | const handlePopoverOpen = (event: React.MouseEvent) => { 16 | setAnchorEl(event.currentTarget); 17 | }; 18 | 19 | const handlePopoverClose = () => { 20 | setAnchorEl(null); 21 | }; 22 | 23 | const open = Boolean(anchorEl); 24 | 25 | return ( 26 | <> 27 | 39 | 55 | 56 | 57 | 58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /client/src/app/shared/components/DateTimeInput.tsx: -------------------------------------------------------------------------------- 1 | import { FieldValues, useController, UseControllerProps } from "react-hook-form" 2 | import {DateTimePicker, DateTimePickerProps} from '@mui/x-date-pickers'; 3 | 4 | type Props = {} & UseControllerProps & DateTimePickerProps 5 | 6 | export default function DateTimeInput(props: Props) { 7 | const { field, fieldState } = useController({ ...props }); 8 | 9 | return ( 10 | { 14 | field.onChange(new Date(value!)) 15 | }} 16 | sx={{width: '100%'}} 17 | slotProps={{ 18 | textField: { 19 | onBlur: field.onBlur, 20 | error: !!fieldState.error, 21 | helperText: fieldState.error?.message 22 | } 23 | }} 24 | /> 25 | ) 26 | } -------------------------------------------------------------------------------- /client/src/app/shared/components/DeleteButton.tsx: -------------------------------------------------------------------------------- 1 | import { Delete, DeleteOutline } from "@mui/icons-material" 2 | import { Box, Button } from "@mui/material" 3 | 4 | export default function DeleteButton() { 5 | return ( 6 | 7 | 29 | 30 | ) 31 | } -------------------------------------------------------------------------------- /client/src/app/shared/components/LocationInput.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo, useState } from "react"; 2 | import { FieldValues, useController, UseControllerProps } from "react-hook-form" 3 | import { Box, debounce, List, ListItemButton, TextField, Typography } from "@mui/material"; 4 | import axios from "axios"; 5 | 6 | type Props = { 7 | label: string 8 | } & UseControllerProps 9 | 10 | export default function LocationInput(props: Props) { 11 | const { field, fieldState } = useController({ ...props }); 12 | const [loading, setLoading] = useState(false); 13 | const [suggestions, setSuggestions] = useState([]); 14 | const [inputValue, setInputValue] = useState(field.value || ''); 15 | 16 | useEffect(() => { 17 | if (field.value && typeof field.value === 'object') { 18 | setInputValue(field.value.venue || ''); 19 | } else { 20 | setInputValue(field.value || ''); 21 | } 22 | }, [field.value]) 23 | 24 | const locationUrl = 'https://api.locationiq.com/v1/autocomplete?key=pk.eac4765ae48c85d19b8b20a979534bf7&limit=5&dedupe=1&' 25 | 26 | const fetchSuggestions = useMemo( 27 | () => debounce(async (query: string) => { 28 | if (!query || query.length < 3) { 29 | setSuggestions([]); 30 | return; 31 | } 32 | 33 | setLoading(true); 34 | 35 | try { 36 | const res = await axios.get(`${locationUrl}q=${query}`); 37 | setSuggestions(res.data) 38 | } catch (error) { 39 | console.log(error); 40 | } finally { 41 | setLoading(false); 42 | } 43 | }, 500), [locationUrl] 44 | ); 45 | 46 | const handleChange = async (value: string) => { 47 | field.onChange(value); 48 | await fetchSuggestions(value); 49 | } 50 | 51 | const handleSelect = (location: LocationIQSuggestion) => { 52 | const city = location.address?.city || location.address?.town || location.address?.village; 53 | const venue = location.display_name; 54 | const latitude = location.lat; 55 | const longitude = location.lon; 56 | 57 | setInputValue(venue); 58 | field.onChange({city, venue, latitude, longitude}); 59 | setSuggestions([]); 60 | } 61 | 62 | return ( 63 | 64 | handleChange(e.target.value)} 68 | fullWidth 69 | variant="outlined" 70 | error={!!fieldState.error} 71 | helperText={fieldState.error?.message} 72 | /> 73 | {loading && Loading...} 74 | {suggestions.length > 0 && ( 75 | 76 | {suggestions.map(suggestion => ( 77 | handleSelect(suggestion)} 81 | > 82 | {suggestion.display_name} 83 | 84 | ))} 85 | 86 | )} 87 | 88 | ) 89 | } -------------------------------------------------------------------------------- /client/src/app/shared/components/MapComponent.tsx: -------------------------------------------------------------------------------- 1 | import {MapContainer, Popup, TileLayer, Marker} from 'react-leaflet'; 2 | import 'leaflet/dist/leaflet.css'; 3 | import {Icon} from 'leaflet'; 4 | import markerIconPng from 'leaflet/dist/images/marker-icon.png'; 5 | 6 | type Props = { 7 | position: [number, number]; 8 | venue: string 9 | } 10 | 11 | export default function MapComponent({position, venue}: Props) { 12 | return ( 13 | 14 | 17 | 18 | 19 | {venue} 20 | 21 | 22 | 23 | ) 24 | } -------------------------------------------------------------------------------- /client/src/app/shared/components/MenuItemLink.tsx: -------------------------------------------------------------------------------- 1 | import { MenuItem } from "@mui/material"; 2 | import { ReactNode } from "react"; 3 | import { NavLink } from "react-router"; 4 | 5 | export default function MenuItemLink({children, to}: {children: ReactNode, to: string}) { 6 | return ( 7 | 20 | {children} 21 | 22 | ) 23 | } -------------------------------------------------------------------------------- /client/src/app/shared/components/PhotoUploadWidget.tsx: -------------------------------------------------------------------------------- 1 | import { CloudUpload } from "@mui/icons-material"; 2 | import { Box, Button, Grid2, Typography } from "@mui/material"; 3 | import { useCallback, useEffect, useRef, useState } from "react"; 4 | import { useDropzone } from 'react-dropzone' 5 | import Cropper, { ReactCropperElement } from "react-cropper"; 6 | import "cropperjs/dist/cropper.css"; 7 | 8 | type Props = { 9 | uploadPhoto: (file: Blob) => void 10 | loading: boolean 11 | } 12 | 13 | export default function PhotoUploadWidget({uploadPhoto, loading}: Props) { 14 | const [files, setFiles] = useState([]); 15 | const cropperRef = useRef(null); 16 | 17 | useEffect(() => { 18 | return () => { 19 | files.forEach(file => URL.revokeObjectURL(file.preview)) 20 | } 21 | }, [files]); 22 | 23 | const onDrop = useCallback((acceptedFiles: File[]) => { 24 | setFiles(acceptedFiles.map(file => Object.assign(file, { 25 | preview: URL.createObjectURL(file as Blob) 26 | }))) 27 | }, []); 28 | 29 | const onCrop = useCallback(() => { 30 | const cropper = cropperRef.current?.cropper; 31 | cropper?.getCroppedCanvas().toBlob(blob => { 32 | uploadPhoto(blob as Blob) 33 | }) 34 | }, [uploadPhoto]) 35 | 36 | const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop }) 37 | 38 | return ( 39 | 40 | 41 | Step 1 - Add photo 42 | 52 | 53 | 54 | Drop image here 55 | 56 | 57 | 58 | Step 2 - Resize image 59 | {files[0]?.preview && 60 | } 71 | 72 | 73 | 74 | {files[0]?.preview && ( 75 | <> 76 | Step 3 - Preview & upload 77 |
81 | 90 | 91 | )} 92 | 93 | 94 | ) 95 | } -------------------------------------------------------------------------------- /client/src/app/shared/components/SelectInput.tsx: -------------------------------------------------------------------------------- 1 | import { FormControl, FormHelperText, InputLabel, MenuItem, Select } from "@mui/material"; 2 | import { SelectInputProps } from "@mui/material/Select/SelectInput"; 3 | import { FieldValues, useController, UseControllerProps } from "react-hook-form" 4 | 5 | type Props = { 6 | items: {text: string, value: string}[]; 7 | label: string; 8 | } & UseControllerProps & Partial 9 | 10 | export default function SelectInput(props: Props) { 11 | const {field, fieldState} = useController({...props}); 12 | 13 | return ( 14 | 15 | {props.label} 16 | 27 | {fieldState.error?.message} 28 | 29 | ) 30 | } -------------------------------------------------------------------------------- /client/src/app/shared/components/StarButton.tsx: -------------------------------------------------------------------------------- 1 | import { Star, StarBorder } from "@mui/icons-material" 2 | import { Box, Button } from "@mui/material" 3 | 4 | type Props = { 5 | selected: boolean 6 | } 7 | 8 | export default function StarButton({selected}: Props) { 9 | return ( 10 | 11 | 33 | 34 | ) 35 | } -------------------------------------------------------------------------------- /client/src/app/shared/components/StyledButton.tsx: -------------------------------------------------------------------------------- 1 | import { Button, ButtonProps, styled } from "@mui/material"; 2 | import { LinkProps } from "react-router"; 3 | 4 | type StyledButtonProps = ButtonProps & Partial 5 | 6 | const StyledButton = styled(Button)(({theme}) => ({ 7 | '&.Mui-disabled': { 8 | backgroundColor: theme.palette.grey[600], 9 | color: theme.palette.text.disabled 10 | } 11 | })) 12 | 13 | export default StyledButton; -------------------------------------------------------------------------------- /client/src/app/shared/components/TextInput.tsx: -------------------------------------------------------------------------------- 1 | import { TextField, TextFieldProps } from "@mui/material"; 2 | import { FieldValues, useController, UseControllerProps, useFormContext } from "react-hook-form" 3 | 4 | type Props = {} & UseControllerProps & TextFieldProps 5 | 6 | export default function TextInput({control, ...props}: Props) { 7 | const formContext = useFormContext(); 8 | const effectiveControl = control || formContext?.control; 9 | 10 | if (!effectiveControl) { 11 | throw new Error('Text input must be used within a form provider or passed as props') 12 | } 13 | 14 | const {field, fieldState} = useController({...props, control: effectiveControl}); 15 | 16 | return ( 17 | 26 | ) 27 | } -------------------------------------------------------------------------------- /client/src/features/account/AccountFormWrapper.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Button, Paper, Typography } from "@mui/material"; 2 | import { ReactNode } from "react"; 3 | import { FieldValues, FormProvider, Resolver, useForm } from "react-hook-form"; 4 | 5 | type Props = { 6 | title: string 7 | icon: ReactNode 8 | onSubmit: (data: TFormData) => Promise 9 | children: ReactNode 10 | submitButtonText: string 11 | resolver?: Resolver 12 | reset?: boolean 13 | } 14 | 15 | export default function AccountFormWrapper({ 16 | title, 17 | icon, 18 | onSubmit, 19 | children, 20 | submitButtonText, 21 | resolver, 22 | reset 23 | }: Props) { 24 | const methods = useForm({ resolver, mode: 'onTouched' }) 25 | 26 | const formSubmit = async (data: TFormData) => { 27 | await onSubmit(data); 28 | if (reset) methods.reset(); 29 | } 30 | 31 | return ( 32 | 33 | 46 | 48 | {icon} 49 | {title} 50 | 51 | {children} 52 | 60 | 61 | 62 | 63 | ) 64 | } -------------------------------------------------------------------------------- /client/src/features/account/AuthCallback.tsx: -------------------------------------------------------------------------------- 1 | import { useNavigate, useSearchParams } from "react-router" 2 | import { useAccount } from "../../lib/hooks/useAccount"; 3 | import { useEffect, useRef, useState } from "react"; 4 | import { Box, CircularProgress, Paper, Typography } from "@mui/material"; 5 | import { GitHub } from "@mui/icons-material"; 6 | 7 | export default function AuthCallback() { 8 | const [params] = useSearchParams(); 9 | const navigate = useNavigate(); 10 | const {fetchGithubToken} = useAccount(); 11 | const code = params.get('code'); 12 | const [loading, setLoading] = useState(true); 13 | const fetched = useRef(false); 14 | 15 | useEffect(() => { 16 | if (!code || fetched.current) return; 17 | fetched.current = true; 18 | 19 | fetchGithubToken.mutateAsync(code) 20 | .then(() => navigate('/activities')) 21 | .catch((error) => { 22 | console.log(error); 23 | setLoading(false); 24 | }) 25 | }, [code, fetchGithubToken, navigate]); 26 | 27 | if (!code) return Problem authenticating with GitHub 28 | 29 | return ( 30 | 44 | 45 | 46 | Logging in with GitHub 47 | 48 | {loading 49 | ? 50 | : Problem siging in with github 51 | } 52 | 53 | ) 54 | } -------------------------------------------------------------------------------- /client/src/features/account/ChangePasswordForm.tsx: -------------------------------------------------------------------------------- 1 | import { Password } from "@mui/icons-material"; 2 | import { changePasswordSchema, ChangePasswordSchema } from "../../lib/schemas/changePasswordSchema" 3 | import AccountFormWrapper from "./AccountFormWrapper"; 4 | import { zodResolver } from "@hookform/resolvers/zod"; 5 | import TextInput from "../../app/shared/components/TextInput"; 6 | import { useAccount } from "../../lib/hooks/useAccount"; 7 | import { toast } from "react-toastify"; 8 | 9 | export default function ChangePasswordForm() { 10 | const {changePassword} = useAccount(); 11 | const onSubmit = async (data: ChangePasswordSchema) => { 12 | try { 13 | await changePassword.mutateAsync(data, { 14 | onSuccess: () => toast.success('Your password has been changed') 15 | }); 16 | } catch (error) { 17 | console.log(error); 18 | } 19 | } 20 | 21 | return ( 22 | 23 | title='Change password' 24 | icon={} 25 | onSubmit={onSubmit} 26 | submitButtonText="Update password" 27 | resolver={zodResolver(changePasswordSchema)} 28 | reset={true} 29 | > 30 | 31 | 32 | 33 | 34 | ) 35 | } -------------------------------------------------------------------------------- /client/src/features/account/ForgotPasswordForm.tsx: -------------------------------------------------------------------------------- 1 | import { FieldValues } from "react-hook-form"; 2 | import { useAccount } from "../../lib/hooks/useAccount" 3 | import { toast } from "react-toastify"; 4 | import { useNavigate } from "react-router"; 5 | import AccountFormWrapper from "./AccountFormWrapper"; 6 | import { LockOpen } from "@mui/icons-material"; 7 | import TextInput from "../../app/shared/components/TextInput"; 8 | 9 | export default function ForgotPasswordForm() { 10 | const {forgotPassword} = useAccount(); 11 | const navigate = useNavigate(); 12 | 13 | const onSubmit = async (data: FieldValues) => { 14 | try { 15 | await forgotPassword.mutateAsync(data.email, { 16 | onSuccess: () => { 17 | toast.success('Password reset requested - please check your email'); 18 | navigate('/login') 19 | } 20 | }) 21 | } catch (error) { 22 | console.log(error); 23 | } 24 | } 25 | 26 | return ( 27 | } 30 | submitButtonText="Request password reset link" 31 | onSubmit={onSubmit} 32 | > 33 | 34 | 35 | 36 | ) 37 | } -------------------------------------------------------------------------------- /client/src/features/account/RegisterForm.tsx: -------------------------------------------------------------------------------- 1 | import { useForm } from "react-hook-form"; 2 | import { useAccount } from "../../lib/hooks/useAccount"; 3 | import { zodResolver } from "@hookform/resolvers/zod"; 4 | import { Box, Button, Paper, Typography } from "@mui/material"; 5 | import { LockOpen } from "@mui/icons-material"; 6 | import TextInput from "../../app/shared/components/TextInput"; 7 | import { Link } from "react-router"; 8 | import { registerSchema, RegisterSchema } from "../../lib/schemas/registerSchema"; 9 | import { useState } from "react"; 10 | import RegisterSuccess from "./RegisterSuccess"; 11 | 12 | export default function RegisterForm() { 13 | const { registerUser } = useAccount(); 14 | const [registerSuccess, setRegisterSuccess] = useState(false); 15 | const { control, handleSubmit, watch, setError, formState: { isValid, isSubmitting } } = useForm({ 16 | mode: 'onTouched', 17 | resolver: zodResolver(registerSchema) 18 | }); 19 | const email = watch('email'); 20 | 21 | const onSubmit = async (data: RegisterSchema) => { 22 | await registerUser.mutateAsync(data, { 23 | onSuccess: () => setRegisterSuccess(true), 24 | onError: (error) => { 25 | if (Array.isArray(error)) { 26 | error.forEach(err => { 27 | if (err.includes('Email')) setError('email', { message: err }); 28 | else if (err.includes('Password')) setError('password', { message: err }) 29 | }) 30 | } 31 | } 32 | }); 33 | } 34 | 35 | return ( 36 | <> 37 | {registerSuccess ? ( 38 | 39 | ) : ( 40 | 53 | 55 | 56 | Register 57 | 58 | 59 | 60 | 61 | 69 | 70 | Already have an account? 71 | 72 | Sign in 73 | 74 | 75 | 76 | )} 77 | 78 | 79 | ) 80 | } -------------------------------------------------------------------------------- /client/src/features/account/RegisterSuccess.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Paper, Typography } from "@mui/material"; 2 | import { useAccount } from "../../lib/hooks/useAccount"; 3 | import { Check } from "@mui/icons-material"; 4 | 5 | type Props = { 6 | email?: string; 7 | } 8 | 9 | export default function RegisterSuccess({email}: Props) { 10 | const {resendConfirmationEmail} = useAccount(); 11 | 12 | if (!email) return null; 13 | 14 | return ( 15 | 25 | 26 | 27 | You have successfully registered! 28 | 29 | 30 | Please check your email to confirm your account 31 | 32 | 35 | 36 | ) 37 | } -------------------------------------------------------------------------------- /client/src/features/account/ResetPasswordForm.tsx: -------------------------------------------------------------------------------- 1 | import { useNavigate, useSearchParams } from "react-router" 2 | import { useAccount } from "../../lib/hooks/useAccount"; 3 | import { Typography } from "@mui/material"; 4 | import { toast } from "react-toastify"; 5 | import AccountFormWrapper from "./AccountFormWrapper"; 6 | import { zodResolver } from "@hookform/resolvers/zod"; 7 | import { LockOpen } from "@mui/icons-material"; 8 | import TextInput from "../../app/shared/components/TextInput"; 9 | import { resetPasswordSchema, ResetPasswordSchema } from "../../lib/schemas/resetPasswordSchema"; 10 | 11 | export default function ResetPasswordForm() { 12 | const [params] = useSearchParams(); 13 | const {resetPassword} = useAccount(); 14 | const navigate = useNavigate(); 15 | 16 | const email = params.get('email'); 17 | const code = params.get('code'); 18 | 19 | if (!email || !code) return Invalid reset password code 20 | 21 | const onSubmit = async (data: ResetPasswordSchema) => { 22 | try { 23 | await resetPassword.mutateAsync({email, 24 | resetCode: code, newPassword: data.newPassword}, { 25 | onSuccess: () => { 26 | toast.success('Password reset successfully - you can now sign in'); 27 | navigate('/login'); 28 | } 29 | }) 30 | } catch (error) { 31 | console.log(error); 32 | } 33 | } 34 | 35 | return ( 36 | 37 | title='Reset your password' 38 | submitButtonText="Reset password" 39 | onSubmit={onSubmit} 40 | resolver={zodResolver(resetPasswordSchema)} 41 | icon={} 42 | > 43 | 44 | 45 | 46 | ) 47 | } -------------------------------------------------------------------------------- /client/src/features/account/VerifyEmail.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from "react"; 2 | import { useAccount } from "../../lib/hooks/useAccount" 3 | import { Link, useSearchParams } from "react-router"; 4 | import { Box, Button, Divider, Paper, Typography } from "@mui/material"; 5 | import { EmailRounded } from "@mui/icons-material"; 6 | 7 | export default function VerifyEmail() { 8 | const { verifyEmail, resendConfirmationEmail } = useAccount(); 9 | const [status, setStatus] = useState('verifying'); 10 | const [searchParams] = useSearchParams(); 11 | const userId = searchParams.get('userId'); 12 | const code = searchParams.get('code'); 13 | const hasRun = useRef(false); 14 | 15 | useEffect(() => { 16 | if (code && userId && !hasRun.current) { 17 | hasRun.current = true; 18 | verifyEmail.mutateAsync({ userId, code }) 19 | .then(() => setStatus('verified')) 20 | .catch(() => setStatus('failed')) 21 | } 22 | }, [code, userId, verifyEmail]) 23 | 24 | const getBody = () => { 25 | switch (status) { 26 | case 'verifying': 27 | return Verifying... 28 | case 'failed': 29 | return ( 30 | 31 | 32 | Verification failed. You can try resending the verify link to your email 33 | 34 | 40 | 41 | ); 42 | case 'verified': 43 | return ( 44 | 45 | 46 | Email has been verified - you can now login 47 | 48 | 51 | 52 | ); 53 | } 54 | } 55 | 56 | return ( 57 | 67 | 68 | 69 | Email verification 70 | 71 | 72 | {getBody()} 73 | 74 | ) 75 | } -------------------------------------------------------------------------------- /client/src/features/activities/dashboard/ActivityCard.tsx: -------------------------------------------------------------------------------- 1 | import { AccessTime, Place } from "@mui/icons-material"; 2 | import { Avatar, Box, Button, Card, CardContent, CardHeader, Chip, Divider, Typography } from "@mui/material" 3 | import { Link } from "react-router"; 4 | import { formatDate } from "../../../lib/util/util"; 5 | import AvatarPopover from "../../../app/shared/components/AvatarPopover"; 6 | 7 | type Props = { 8 | activity: Activity 9 | } 10 | 11 | export default function ActivityCard({ activity }: Props) { 12 | const label = activity.isHost ? 'You are hosting' : 'You are going'; 13 | const color = activity.isHost ? 'secondary' : activity.isGoing ? 'warning' : 'default'; 14 | 15 | return ( 16 | 17 | 18 | } 24 | title={activity.title} 25 | titleTypographyProps={{ 26 | fontWeight: 'bold', 27 | fontSize: 20 28 | }} 29 | subheader={ 30 | <> 31 | Hosted by{' '} 32 | 33 | {activity.hostDisplayName} 34 | 35 | 36 | } 37 | /> 38 | 39 | {(activity.isHost || activity.isGoing) && } 40 | {activity.isCancelled && } 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | {formatDate(activity.date)} 52 | 53 | 54 | 55 | 56 | {activity.venue} 57 | 58 | 59 | 60 | {activity.attendees.map(att => ( 61 | 62 | ))} 63 | 64 | 65 | 66 | 67 | {activity.description} 68 | 69 | 78 | 79 | 80 | ) 81 | } -------------------------------------------------------------------------------- /client/src/features/activities/dashboard/ActivityDashboard.tsx: -------------------------------------------------------------------------------- 1 | import { Grid2 } from "@mui/material"; 2 | import ActivityList from "./ActivityList"; 3 | import ActivityFilters from "./ActivityFilters"; 4 | 5 | export default function ActivityDashboard() { 6 | return ( 7 | 8 | 9 | 10 | 11 | 19 | 20 | 21 | 22 | ) 23 | } -------------------------------------------------------------------------------- /client/src/features/activities/dashboard/ActivityFilters.tsx: -------------------------------------------------------------------------------- 1 | import { FilterList, Event } from "@mui/icons-material"; 2 | import { Box, ListItemText, MenuItem, MenuList, Paper, Typography } from "@mui/material"; 3 | import 'react-calendar/dist/Calendar.css'; 4 | import Calendar from "react-calendar"; 5 | import { useStore } from "../../../lib/hooks/useStore"; 6 | import { observer } from "mobx-react-lite"; 7 | 8 | const ActivityFilters = observer(function ActivityFilters() { 9 | const { activityStore: {setFilter, setStartDate, filter, startDate}} = useStore(); 10 | 11 | return ( 12 | 13 | 14 | 15 | 18 | 19 | Filters 20 | 21 | 22 | setFilter('all')} 25 | > 26 | 27 | 28 | setFilter('isGoing')} 31 | > 32 | 33 | 34 | setFilter('isHost')} 37 | > 38 | 39 | 40 | 41 | 42 | 43 | 44 | 46 | 47 | Select date 48 | 49 | setStartDate(date as Date)} 52 | /> 53 | 54 | 55 | ) 56 | }) 57 | 58 | export default ActivityFilters; -------------------------------------------------------------------------------- /client/src/features/activities/dashboard/ActivityList.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Typography } from "@mui/material"; 2 | import ActivityCard from "./ActivityCard"; 3 | import { useActivities } from "../../../lib/hooks/useActivities"; 4 | import { useInView } from "react-intersection-observer"; 5 | import { useEffect } from "react"; 6 | import { observer } from "mobx-react-lite"; 7 | 8 | const ActivityList = observer(function ActivityList() { 9 | const { activitiesGroup, isLoading, hasNextPage, fetchNextPage } = useActivities(); 10 | const {ref, inView} = useInView({ 11 | threshold: 0.5 12 | }); 13 | 14 | useEffect(() => { 15 | if (inView && hasNextPage) { 16 | fetchNextPage(); 17 | } 18 | }, [inView, hasNextPage, fetchNextPage]) 19 | 20 | if (isLoading) return Loading... 21 | 22 | if (!activitiesGroup) return No activities found 23 | 24 | return ( 25 | 26 | {activitiesGroup.pages.map((activities, index) => ( 27 | 34 | {activities.items.map(activity => ( 35 | 39 | ))} 40 | 41 | ))} 42 | 43 | 44 | ) 45 | }); 46 | 47 | export default ActivityList; -------------------------------------------------------------------------------- /client/src/features/activities/details/ActivityDetailPage.tsx: -------------------------------------------------------------------------------- 1 | import { Grid2, Typography } from "@mui/material" 2 | import { useParams } from "react-router"; 3 | import { useActivities } from "../../../lib/hooks/useActivities"; 4 | import ActivityDetailsHeader from "./ActivityDetailsHeader"; 5 | import ActivityDetailsInfo from "./ActivityDetailsInfo"; 6 | import ActivityDetailsChat from "./ActivityDetailsChat"; 7 | import ActivityDetailsSidebar from "./ActivityDetailsSidebar"; 8 | 9 | export default function ActivityDetailPage() { 10 | const {id} = useParams(); 11 | const {activity, isLoadingActivity} = useActivities(id); 12 | 13 | if (isLoadingActivity) return Loading... 14 | 15 | if (!activity) return Activity not found 16 | 17 | return ( 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | ) 29 | } -------------------------------------------------------------------------------- /client/src/features/activities/details/ActivityDetailsInfo.tsx: -------------------------------------------------------------------------------- 1 | import { CalendarToday, Info, Place } from "@mui/icons-material"; 2 | import { Box, Button, Divider, Grid2, Paper, Typography } from "@mui/material"; 3 | import { formatDate } from "../../../lib/util/util"; 4 | import { useState } from "react"; 5 | import MapComponent from "../../../app/shared/components/MapComponent"; 6 | 7 | type Props = { 8 | activity: Activity 9 | } 10 | 11 | export default function ActivityDetailsInfo({activity}: Props) { 12 | const [mapOpen, setMapOpen] = useState(false); 13 | 14 | return ( 15 | 16 | 17 | 18 | 19 | 20 | 21 | {activity.description} 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | {formatDate(activity.date)} 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | {activity.venue}, {activity.city} 42 | 43 | 46 | 47 | 48 | {mapOpen && ( 49 | 50 | 54 | 55 | )} 56 | 57 | ) 58 | } -------------------------------------------------------------------------------- /client/src/features/activities/details/ActivityDetailsSidebar.tsx: -------------------------------------------------------------------------------- 1 | import { Paper, Typography, List, ListItem, Chip, ListItemAvatar, Avatar, ListItemText, Grid2 } from "@mui/material"; 2 | import { Link } from "react-router"; 3 | 4 | type Props = { 5 | activity: Activity 6 | } 7 | 8 | export default function ActivityDetailsSidebar({ activity }: Props) { 9 | return ( 10 | <> 11 | 20 | 21 | {activity.attendees.length} people going 22 | 23 | 24 | 25 | {activity.attendees.map(attendee => ( 26 | 27 | 28 | 29 | 30 | 31 | 37 | 38 | 39 | 40 | {attendee.displayName} 41 | 42 | {attendee.following && ( 43 | 44 | Following 45 | 46 | )} 47 | 48 | 49 | 50 | 51 | 52 | {activity.hostId === attendee.id && ( 53 | 59 | )} 60 | 61 | 62 | 63 | ))} 64 | 65 | 66 | 67 | ); 68 | } 69 | -------------------------------------------------------------------------------- /client/src/features/activities/form/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/src/features/counter/Counter.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Button, ButtonGroup, List, ListItemText, Paper, Typography } from "@mui/material"; 2 | import { useStore } from "../../lib/hooks/useStore" 3 | import { observer } from 'mobx-react-lite'; 4 | 5 | const Counter = observer(function Counter() { 6 | const { counterStore } = useStore(); 7 | 8 | return ( 9 | 10 | 11 | {counterStore.title} 12 | The count is: {counterStore.count} 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | Counter events ({counterStore.eventCount}) 21 | 22 | {counterStore.events.map((event, index) => ( 23 | {event} 24 | ))} 25 | 26 | 27 | 28 | ) 29 | }); 30 | 31 | export default Counter; -------------------------------------------------------------------------------- /client/src/features/errors/NotFound.tsx: -------------------------------------------------------------------------------- 1 | import { SearchOff } from "@mui/icons-material"; 2 | import { Button, Paper, Typography } from "@mui/material"; 3 | import { Link } from "react-router"; 4 | 5 | export default function NotFound() { 6 | return ( 7 | 17 | 18 | 19 | Oops - we could not find what you are looking for 20 | 21 | 24 | 25 | ) 26 | } -------------------------------------------------------------------------------- /client/src/features/errors/ServerError.tsx: -------------------------------------------------------------------------------- 1 | import { Divider, Paper, Typography } from "@mui/material"; 2 | import { useLocation } from "react-router" 3 | 4 | export default function ServerError() { 5 | const { state } = useLocation(); 6 | 7 | return ( 8 | 9 | {state.error ? ( 10 | <> 11 | 12 | {state.error?.message || 'There has been an error'} 13 | 14 | 15 | 16 | {state.error?.details || 'Internal server error'} 17 | 18 | 19 | ) : ( 20 | Server error 21 | )} 22 | 23 | ) 24 | } -------------------------------------------------------------------------------- /client/src/features/errors/TestErrors.tsx: -------------------------------------------------------------------------------- 1 | import { Alert, Button, ButtonGroup, Typography } from '@mui/material'; 2 | import { useMutation } from '@tanstack/react-query'; 3 | import agent from "../../lib/api/agent.ts"; 4 | import {useState} from "react"; 5 | 6 | export default function TestErrors() { 7 | const [validationErrors, setValidationErrors] = useState([]); 8 | 9 | const { mutate } = useMutation({ 10 | mutationFn: async ({ path, method = 'get' }: { path: string; method: string }) => { 11 | if (method === 'post') await agent.post(path, {}); 12 | else await agent.get(path); 13 | }, 14 | onError: (err) => { 15 | if (Array.isArray(err)) { 16 | setValidationErrors(err); 17 | } else { 18 | setValidationErrors([]); 19 | } 20 | }, 21 | }); 22 | 23 | const handleError = (path: string, method = 'get') => { 24 | mutate({path, method}); 25 | }; 26 | 27 | return ( 28 | <> 29 | Test errors component 30 | 31 | 32 | 35 | 38 | 41 | 44 | 47 | 48 | 49 | {validationErrors.map((err, i) => ( 50 | 51 | {err} 52 | 53 | ))} 54 | 55 | ); 56 | } -------------------------------------------------------------------------------- /client/src/features/home/HomePage.tsx: -------------------------------------------------------------------------------- 1 | import { Group } from "@mui/icons-material"; 2 | import { Box, Button, Paper, Typography } from "@mui/material"; 3 | import { Link } from "react-router"; 4 | 5 | export default function HomePage() { 6 | return ( 7 | 20 | 24 | 25 | 26 | Reactivities 27 | 28 | 29 | 30 | Welcome to reactivities 31 | 32 | 41 | 42 | ) 43 | } -------------------------------------------------------------------------------- /client/src/features/profiles/ProfileAbout.tsx: -------------------------------------------------------------------------------- 1 | import { useParams } from "react-router" 2 | import { useProfile } from "../../lib/hooks/useProfile"; 3 | import { Box, Button, Divider, Typography } from "@mui/material"; 4 | import { useState } from "react"; 5 | import ProfileEdit from "./ProfileEditForm"; 6 | 7 | export default function ProfileAbout() { 8 | const { id } = useParams(); 9 | const { profile, isCurrentUser } = useProfile(id); 10 | const [editMode, setEditMode] = useState(false); 11 | 12 | return ( 13 | 14 | 15 | About {profile?.displayName} 16 | {isCurrentUser && 17 | } 20 | 21 | 22 | 23 | {editMode ? ( 24 | 25 | ) : ( 26 | 27 | {profile?.bio || 'No description added yet'} 28 | 29 | )} 30 | 31 | 32 | ) 33 | } -------------------------------------------------------------------------------- /client/src/features/profiles/ProfileActivities.tsx: -------------------------------------------------------------------------------- 1 | import { SyntheticEvent, useEffect, useState } from "react"; 2 | import { Box, Card, CardContent, CardMedia, Grid2, Tab, Tabs, Typography } from "@mui/material"; 3 | import { Link, useParams } from "react-router"; 4 | import { format } from "date-fns"; 5 | import { useProfile } from "../../lib/hooks/useProfile.ts"; 6 | 7 | export default function ProfileActivities() { 8 | const [activeTab, setActiveTab] = useState(0); 9 | const { id } = useParams(); 10 | const { userActivities, setFilter, loadingUserActivities } = useProfile(id); 11 | 12 | useEffect(() => { 13 | setFilter('future') 14 | }, [setFilter]) 15 | 16 | const tabs = [ 17 | { menuItem: 'Future Events', key: 'future' }, 18 | { menuItem: 'Past Events', key: 'past' }, 19 | { menuItem: 'Hosting', key: 'hosting' } 20 | ]; 21 | 22 | const handleTabChange = (_: SyntheticEvent, newValue: number) => { 23 | setActiveTab(newValue); 24 | setFilter(tabs[newValue].key); 25 | }; 26 | 27 | return ( 28 | 29 | 30 | 31 | 35 | {tabs.map((tab, index) => ( 36 | 37 | ))} 38 | 39 | 40 | 41 | {(!userActivities || userActivities.length === 0) && !loadingUserActivities ? ( 42 | 43 | No activities to show 44 | 45 | ) : null} 46 | 47 | {userActivities && userActivities.map((activity: Activity) => ( 48 | 49 | 50 | 51 | 58 | 59 | 60 | {activity.title} 61 | 62 | 68 | {format(activity.date, 'do LLL yyyy')} 69 | {format(activity.date, 'h:mm a')} 70 | 71 | 72 | 73 | 74 | 75 | ))} 76 | 77 | 78 | ) 79 | } -------------------------------------------------------------------------------- /client/src/features/profiles/ProfileCard.tsx: -------------------------------------------------------------------------------- 1 | import { Person } from "@mui/icons-material"; 2 | import { Box, Card, CardContent, CardMedia, Chip, Divider, Typography } from "@mui/material"; 3 | import { Link } from "react-router"; 4 | 5 | type Props = { 6 | profile: Profile 7 | } 8 | 9 | export default function ProfileCard({ profile }: Props) { 10 | return ( 11 | 12 | 20 | 26 | 27 | 28 | {profile.displayName} 29 | {profile.bio && ( 30 | 38 | {profile.bio} 39 | 40 | )} 41 | 42 | 43 | {profile.following && } 45 | 46 | 47 | 48 | 49 | 50 | {profile.followersCount} Followers 51 | 52 | 53 | 54 | ) 55 | } -------------------------------------------------------------------------------- /client/src/features/profiles/ProfileContent.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Paper, Tab, Tabs } from "@mui/material"; 2 | import { SyntheticEvent, useState } from "react" 3 | import ProfilePhotos from "./ProfilePhotos"; 4 | import ProfileAbout from "./ProfileAbout"; 5 | import ProfileFollowings from "./ProfileFollowings"; 6 | import ProfileActivities from "./ProfileActivities"; 7 | 8 | export default function ProfileContent() { 9 | const [value, setValue] = useState(0); 10 | 11 | const handleChange = (_: SyntheticEvent, newValue: number) => { 12 | setValue(newValue); 13 | } 14 | 15 | const tabContent = [ 16 | {label: 'About', content: }, 17 | {label: 'Photos', content: }, 18 | {label: 'Events', content: }, 19 | {label: 'Followers', content: }, 20 | {label: 'Following', content: }, 21 | ] 22 | 23 | return ( 24 | 32 | 38 | {tabContent.map((tab, index) => ( 39 | 40 | ))} 41 | 42 | 43 | {tabContent[value].content} 44 | 45 | 46 | ) 47 | } -------------------------------------------------------------------------------- /client/src/features/profiles/ProfileEditForm.tsx: -------------------------------------------------------------------------------- 1 | import { useForm } from "react-hook-form"; 2 | import { editProfileSchema, EditProfileSchema } from "../../lib/schemas/editProfileSchema.ts"; 3 | import { zodResolver } from "@hookform/resolvers/zod"; 4 | import { useProfile } from "../../lib/hooks/useProfile.ts"; 5 | import { useEffect } from "react"; 6 | import { Box, Button } from "@mui/material"; 7 | import TextInput from "../../app/shared/components/TextInput.tsx"; 8 | import { useParams } from "react-router"; 9 | 10 | type Props = { 11 | setEditMode: (editMode: boolean) => void; 12 | } 13 | 14 | export default function ProfileEdit({ setEditMode }: Props) { 15 | const { id } = useParams(); 16 | const { updateProfile, profile } = useProfile(id); 17 | const { control, handleSubmit, reset, formState: { isDirty, isValid } } = useForm({ 18 | resolver: zodResolver(editProfileSchema), 19 | mode: 'onTouched' 20 | }); 21 | 22 | const onSubmit = (data: EditProfileSchema) => { 23 | updateProfile.mutate(data, { 24 | onSuccess: () => setEditMode(false) 25 | }); 26 | } 27 | 28 | useEffect(() => { 29 | reset({ 30 | displayName: profile?.displayName, 31 | bio: profile?.bio || '' 32 | }); 33 | }, [profile, reset]); 34 | 35 | return ( 36 | 44 | 45 | 52 | 59 | 60 | ); 61 | } -------------------------------------------------------------------------------- /client/src/features/profiles/ProfileFollowings.tsx: -------------------------------------------------------------------------------- 1 | import { useParams } from "react-router" 2 | import { useProfile } from "../../lib/hooks/useProfile"; 3 | import { Box, Divider, Typography } from "@mui/material"; 4 | import ProfileCard from "./ProfileCard"; 5 | 6 | type Props = { 7 | activeTab: number 8 | } 9 | 10 | export default function ProfileFollowings({ activeTab }: Props) { 11 | const {id} = useParams(); 12 | const predicate = activeTab === 3 ? 'followers' : 'followings'; 13 | const {profile, followings, loadingFollowings} = useProfile(id, predicate); 14 | 15 | return ( 16 | 17 | 18 | 19 | {activeTab === 3 ? `People following ${profile?.displayName}` 20 | : `People ${profile?.displayName} is following`} 21 | 22 | 23 | 24 | {loadingFollowings ? Loading... : ( 25 | 26 | {followings?.map(profile => ( 27 | 28 | ))} 29 | 30 | )} 31 | 32 | ) 33 | } -------------------------------------------------------------------------------- /client/src/features/profiles/ProfileHeader.tsx: -------------------------------------------------------------------------------- 1 | import { Avatar, Box, Button, Chip, Divider, Grid2, Paper, Stack, Typography } from "@mui/material"; 2 | import { useParams } from "react-router"; 3 | import { useProfile } from "../../lib/hooks/useProfile"; 4 | 5 | export default function ProfileHeader() { 6 | const { id } = useParams(); 7 | const { isCurrentUser, profile, updateFollowing } = useProfile(id); 8 | 9 | if (!profile) return null; 10 | 11 | return ( 12 | 13 | 14 | 15 | 16 | 21 | 22 | {profile.displayName} 23 | {profile.following && } 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | Followers 37 | {profile.followersCount} 38 | 39 | 40 | Following 41 | {profile.followingCount} 42 | 43 | 44 | {!isCurrentUser && 45 | <> 46 | 47 | 56 | 57 | } 58 | 59 | 60 | 61 | 62 | 63 | ) 64 | } -------------------------------------------------------------------------------- /client/src/features/profiles/ProfilePage.tsx: -------------------------------------------------------------------------------- 1 | import { Grid2, Typography } from "@mui/material"; 2 | import ProfileHeader from "./ProfileHeader"; 3 | import ProfileContent from "./ProfileContent"; 4 | import { useParams } from "react-router"; 5 | import { useProfile } from "../../lib/hooks/useProfile"; 6 | 7 | export default function ProfilePage() { 8 | const {id} = useParams(); 9 | const {profile, loadingProfile} = useProfile(id); 10 | 11 | if (loadingProfile) return Loading profile... 12 | 13 | if (!profile) return Profile not found 14 | 15 | return ( 16 | 17 | 18 | 19 | 20 | 21 | 22 | ) 23 | } -------------------------------------------------------------------------------- /client/src/lib/api/agent.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { store } from "../stores/store"; 3 | import { toast } from "react-toastify"; 4 | import { router } from "../../app/router/Routes"; 5 | 6 | const sleep = (delay: number) => { 7 | return new Promise(resolve => { 8 | setTimeout(resolve, delay) 9 | }); 10 | } 11 | 12 | const agent = axios.create({ 13 | baseURL: import.meta.env.VITE_API_URL, 14 | withCredentials: true 15 | }); 16 | 17 | agent.interceptors.request.use(config => { 18 | store.uiStore.isBusy(); 19 | return config; 20 | }) 21 | 22 | agent.interceptors.response.use( 23 | async response => { 24 | if (import.meta.env.DEV) await sleep(1000); 25 | store.uiStore.isIdle() 26 | return response; 27 | }, 28 | async error => { 29 | if (import.meta.env.DEV) await sleep(1000); 30 | store.uiStore.isIdle(); 31 | 32 | const { status, data } = error.response; 33 | switch (status) { 34 | case 400: 35 | if (data.errors) { 36 | const modalStateErrors = []; 37 | for (const key in data.errors) { 38 | if (data.errors[key]) { 39 | modalStateErrors.push(data.errors[key]); 40 | } 41 | } 42 | throw modalStateErrors.flat(); 43 | } else { 44 | toast.error(data); 45 | } 46 | break; 47 | case 401: 48 | if (data.detail === 'NotAllowed') { 49 | throw new Error(data.detail) 50 | } else { 51 | toast.error('Unauthorised'); 52 | } 53 | break; 54 | case 404: 55 | router.navigate('/not-found'); 56 | break; 57 | case 500: 58 | router.navigate('/server-error', {state: {error: data}}) 59 | break; 60 | default: 61 | break; 62 | } 63 | 64 | return Promise.reject(error); 65 | } 66 | ); 67 | 68 | export default agent; -------------------------------------------------------------------------------- /client/src/lib/hooks/useComments.ts: -------------------------------------------------------------------------------- 1 | import { useLocalObservable } from "mobx-react-lite" 2 | import {HubConnection, HubConnectionBuilder, HubConnectionState} from '@microsoft/signalr'; 3 | import { useEffect, useRef } from "react"; 4 | import { runInAction } from "mobx"; 5 | 6 | export const useComments = (activityId?: string) => { 7 | const created = useRef(false); 8 | const commentStore = useLocalObservable(() => ({ 9 | comments: [] as ChatComment[], 10 | hubConnection: null as HubConnection | null, 11 | 12 | createHubConnection(activityId: string) { 13 | if (!activityId) return; 14 | 15 | this.hubConnection = new HubConnectionBuilder() 16 | .withUrl(`${import.meta.env.VITE_COMMENTS_URL}?activityId=${activityId}`, { 17 | withCredentials: true 18 | }) 19 | .withAutomaticReconnect() 20 | .build(); 21 | 22 | this.hubConnection.start().catch(error => 23 | console.log('Error establishing connection: ', error)); 24 | 25 | this.hubConnection.on('LoadComments', comments => { 26 | runInAction(() => { 27 | this.comments = comments 28 | }) 29 | }); 30 | 31 | this.hubConnection.on('ReceiveComment', comment => { 32 | runInAction(() => { 33 | this.comments.unshift(comment); 34 | }) 35 | }) 36 | }, 37 | 38 | stopHubConnection() { 39 | if (this.hubConnection?.state === HubConnectionState.Connected) { 40 | this.hubConnection.stop().catch(error => 41 | console.log('Error stopping connection: ', error)) 42 | } 43 | } 44 | })); 45 | 46 | useEffect(() => { 47 | if (activityId && !created.current) { 48 | commentStore.createHubConnection(activityId); 49 | created.current = true 50 | } 51 | 52 | return () => { 53 | commentStore.stopHubConnection(); 54 | commentStore.comments = []; 55 | } 56 | }, [activityId, commentStore]) 57 | 58 | return { 59 | commentStore 60 | } 61 | } -------------------------------------------------------------------------------- /client/src/lib/hooks/useStore.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | import { StoreContext } from "../stores/store"; 3 | 4 | export function useStore() { 5 | return useContext(StoreContext); 6 | } -------------------------------------------------------------------------------- /client/src/lib/schemas/activitySchema.ts: -------------------------------------------------------------------------------- 1 | import {z} from 'zod'; 2 | import { requiredString } from '../util/util'; 3 | 4 | export const activitySchema = z.object({ 5 | title: requiredString('Title'), 6 | description: requiredString('Description'), 7 | category: requiredString('Category'), 8 | date: z.coerce.date({ 9 | message: 'Date is required' 10 | }), 11 | location: z.object({ 12 | venue: requiredString('Venue'), 13 | city: z.string().optional(), 14 | latitude: z.coerce.number(), 15 | longitude: z.coerce.number() 16 | }) 17 | }) 18 | 19 | export type ActivitySchema = z.infer; -------------------------------------------------------------------------------- /client/src/lib/schemas/changePasswordSchema.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { requiredString } from "../util/util"; 3 | 4 | export const changePasswordSchema = z.object({ 5 | currentPassword: requiredString('currentPassword'), 6 | newPassword: requiredString('newPassword'), 7 | confirmPassword: requiredString('confirmPassword') 8 | }) 9 | .refine((data) => data.newPassword === data.confirmPassword, { 10 | message: 'Passwords must match', 11 | path: ['confirmPassword'] 12 | }); 13 | 14 | export type ChangePasswordSchema = z.infer; -------------------------------------------------------------------------------- /client/src/lib/schemas/editProfileSchema.ts: -------------------------------------------------------------------------------- 1 | import {z} from "zod"; 2 | import {requiredString} from "../util/util.ts"; 3 | 4 | export const editProfileSchema = z.object({ 5 | displayName: requiredString('Display Name'), 6 | bio: z.string().optional() 7 | }); 8 | 9 | export type EditProfileSchema = z.infer; -------------------------------------------------------------------------------- /client/src/lib/schemas/loginSchema.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const loginSchema = z.object({ 4 | email: z.string().email(), 5 | password: z.string().min(6) 6 | }) 7 | 8 | export type LoginSchema = z.infer; -------------------------------------------------------------------------------- /client/src/lib/schemas/registerSchema.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { requiredString } from "../util/util"; 3 | 4 | export const registerSchema = z.object({ 5 | email: z.string().email(), 6 | displayName: requiredString('displayName'), 7 | password: requiredString('password') 8 | }) 9 | 10 | export type RegisterSchema = z.infer; -------------------------------------------------------------------------------- /client/src/lib/schemas/resetPasswordSchema.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { requiredString } from "../util/util"; 3 | 4 | export const resetPasswordSchema = z.object({ 5 | newPassword: requiredString('newPassword'), 6 | confirmPassword: requiredString('confirmPassword') 7 | }) 8 | .refine((data) => data.newPassword === data.confirmPassword, { 9 | message: 'Passwords must match', 10 | path: ['confirmPassword'] 11 | }); 12 | 13 | export type ResetPasswordSchema = z.infer; -------------------------------------------------------------------------------- /client/src/lib/stores/activityStore.ts: -------------------------------------------------------------------------------- 1 | import { makeAutoObservable } from "mobx"; 2 | 3 | export class ActivityStore { 4 | filter = 'all'; 5 | startDate = new Date().toISOString(); 6 | 7 | constructor() { 8 | makeAutoObservable(this) 9 | } 10 | 11 | setFilter = (filter: string) => { 12 | this.filter = filter 13 | } 14 | 15 | setStartDate = (date: Date) => { 16 | this.startDate = date.toISOString(); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /client/src/lib/stores/counterStore.ts: -------------------------------------------------------------------------------- 1 | import {makeAutoObservable} from 'mobx'; 2 | 3 | export default class CounterStore { 4 | title = 'Counter store'; 5 | count = 42; 6 | events: string[] = [ 7 | `Initial count is ${this.count}` 8 | ] 9 | 10 | constructor() { 11 | makeAutoObservable(this) 12 | } 13 | 14 | increment = (amount = 1) => { 15 | this.count += amount; 16 | this.events.push(`Incremented by ${amount} - count is now ${this.count}`); 17 | } 18 | 19 | decrement = (amount = 1) => { 20 | this.count -= amount 21 | this.events.push(`Decremented by ${amount} - count is now ${this.count}`); 22 | } 23 | 24 | get eventCount() { 25 | return this.events.length 26 | } 27 | } -------------------------------------------------------------------------------- /client/src/lib/stores/store.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | import CounterStore from "./counterStore"; 3 | import { UiStore } from "./uiStore"; 4 | import { ActivityStore } from "./activityStore"; 5 | 6 | interface Store { 7 | counterStore: CounterStore 8 | uiStore: UiStore 9 | activityStore: ActivityStore 10 | } 11 | 12 | export const store: Store = { 13 | counterStore: new CounterStore(), 14 | uiStore: new UiStore(), 15 | activityStore: new ActivityStore() 16 | } 17 | 18 | export const StoreContext = createContext(store); -------------------------------------------------------------------------------- /client/src/lib/stores/uiStore.ts: -------------------------------------------------------------------------------- 1 | import { makeAutoObservable } from "mobx"; 2 | 3 | export class UiStore { 4 | isLoading = false; 5 | 6 | constructor() { 7 | makeAutoObservable(this) 8 | } 9 | 10 | isBusy() { 11 | this.isLoading = true 12 | } 13 | 14 | isIdle() { 15 | this.isLoading = false 16 | } 17 | } -------------------------------------------------------------------------------- /client/src/lib/types/index.d.ts: -------------------------------------------------------------------------------- 1 | type PagedList = { 2 | items: T[], 3 | nextCursor: TCursor 4 | } 5 | 6 | type ResetPassword = { 7 | email: string 8 | resetCode: string 9 | newPassword: string 10 | } 11 | 12 | type Activity = { 13 | id: string 14 | title: string 15 | date: Date 16 | description: string 17 | category: string 18 | isCancelled: boolean 19 | city: string 20 | venue: string 21 | latitude: number 22 | longitude: number 23 | attendees: Profile[] 24 | isGoing: boolean 25 | isHost: boolean 26 | hostId: string 27 | hostDisplayName: string 28 | hostImageUrl?: string 29 | } 30 | 31 | type Profile = { 32 | id: string 33 | displayName: string 34 | bio?: string 35 | imageUrl?: string 36 | followersCount?: number 37 | followingCount?: number 38 | following?: boolean 39 | } 40 | 41 | type Photo = { 42 | id: string 43 | url: string 44 | } 45 | 46 | type User = { 47 | id: string 48 | email: string 49 | displayName: string 50 | imageUrl?: string 51 | } 52 | 53 | type ChatComment = { 54 | id: string 55 | createdAt: Date 56 | body: string 57 | userId: string 58 | displayName: string 59 | imageUrl?: string 60 | } 61 | 62 | type LocationIQSuggestion = { 63 | place_id: string 64 | osm_id: string 65 | osm_type: string 66 | licence: string 67 | lat: string 68 | lon: string 69 | boundingbox: string[] 70 | class: string 71 | type: string 72 | display_name: string 73 | display_place: string 74 | display_address: string 75 | address: LocationIQAddress 76 | } 77 | 78 | type LocationIQAddress = { 79 | name: string 80 | house_number: string 81 | road: string 82 | suburb?: string 83 | town?: string 84 | village?: string 85 | city?: string 86 | county: string 87 | state: string 88 | postcode: string 89 | country: string 90 | country_code: string 91 | neighbourhood?: string 92 | } 93 | -------------------------------------------------------------------------------- /client/src/lib/util/util.ts: -------------------------------------------------------------------------------- 1 | import { DateArg, format, formatDistanceToNow } from "date-fns"; 2 | import { z } from "zod"; 3 | 4 | export function formatDate(date: DateArg) { 5 | return format(date, 'dd MMM yyyy h:mm a') 6 | } 7 | 8 | export function timeAgo(date: DateArg) { 9 | return formatDistanceToNow(date) + ' ago' 10 | } 11 | 12 | export const requiredString = (fieldName: string) => z 13 | .string({ required_error: `${fieldName} is required` }) 14 | .min(1, { message: `${fieldName} is required` }) -------------------------------------------------------------------------------- /client/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import './app/layout/styles.css' 4 | import '@fontsource/roboto/300.css'; 5 | import '@fontsource/roboto/400.css'; 6 | import '@fontsource/roboto/500.css'; 7 | import '@fontsource/roboto/700.css'; 8 | import 'react-toastify/dist/ReactToastify.css'; 9 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; 10 | import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; 11 | import { RouterProvider } from 'react-router'; 12 | import { router } from './app/router/Routes.tsx'; 13 | import { store, StoreContext } from './lib/stores/store.ts'; 14 | import { ToastContainer } from 'react-toastify'; 15 | import { LocalizationProvider } from '@mui/x-date-pickers'; 16 | import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFnsV3'; 17 | 18 | const queryClient = new QueryClient(); 19 | 20 | createRoot(document.getElementById('root')!).render( 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | , 33 | ) 34 | -------------------------------------------------------------------------------- /client/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /client/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 4 | "target": "ES2020", 5 | "useDefineForClassFields": true, 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "module": "ESNext", 8 | "skipLibCheck": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "isolatedModules": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | "jsx": "react-jsx", 17 | 18 | /* Linting */ 19 | "strict": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "noUncheckedSideEffectImports": true 24 | }, 25 | "include": ["src"] 26 | } 27 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /client/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2022", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "isolatedModules": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUncheckedSideEffectImports": true 22 | }, 23 | "include": ["vite.config.ts"] 24 | } 25 | -------------------------------------------------------------------------------- /client/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react-swc' 3 | import mkcert from 'vite-plugin-mkcert'; 4 | 5 | // https://vite.dev/config/ 6 | export default defineConfig({ 7 | build: { 8 | outDir: '../API/wwwroot', 9 | chunkSizeWarningLimit: 1500, 10 | emptyOutDir: true 11 | }, 12 | server: { 13 | port: 3000 14 | }, 15 | plugins: [react(), mkcert()], 16 | }) 17 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | sql: 3 | image: mcr.microsoft.com/mssql/server:2022-latest 4 | environment: 5 | ACCEPT_EULA: "Y" 6 | MSSQL_SA_PASSWORD: "Password@1" 7 | ports: 8 | - "1433:1433" 9 | volumes: 10 | - sql-data:/var/opt/mssql 11 | platform: "linux/amd64" 12 | volumes: 13 | sql-data: --------------------------------------------------------------------------------