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 |
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 |
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 |
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