├── .gitignore ├── .idea ├── .gitignore ├── Collaborative-Note-Tool.iml ├── modules.xml ├── swagger-settings.xml └── vcs.xml ├── Dockerfile ├── Jenkinsfile ├── LICENSE ├── README.md ├── backend ├── .gitignore ├── Dockerfile ├── build-backend.sh ├── docker-compose.yml ├── package-lock.json ├── package.json ├── run-backend-docker.sh ├── src │ ├── app.module.ts │ ├── app.test.ts │ ├── auth │ │ ├── auth.controller.ts │ │ ├── auth.module.ts │ │ ├── auth.resolver.ts │ │ ├── auth.schema.ts │ │ ├── auth.service.ts │ │ └── jwt.strategy.ts │ ├── dto │ │ ├── create-note.input.ts │ │ └── update-note.input.ts │ ├── main.ts │ ├── notes │ │ ├── notes.controller.ts │ │ ├── notes.module.ts │ │ ├── notes.resolver.ts │ │ ├── notes.schema.ts │ │ └── notes.service.ts │ ├── profile │ │ ├── profile.controller.ts │ │ ├── profile.module.ts │ │ ├── profile.resolver.ts │ │ ├── profile.schema.ts │ │ └── profile.service.ts │ ├── schema.gql │ ├── supabase │ │ ├── supabase.module.ts │ │ └── supabase.service.ts │ └── types │ │ └── authenticated-request.ts ├── tsconfig.json └── vercel.json ├── build-all.sh ├── clean-all.sh ├── docker-compose.yml ├── frontend ├── .gitignore ├── Dockerfile ├── build-frontend.sh ├── docker-compose.yml ├── eslint.config.js ├── index.html ├── package-lock.json ├── package.json ├── public │ ├── OIP.jpg │ ├── OIP10.webp │ ├── OIP11.webp │ ├── OIP12.webp │ ├── OIP13.webp │ ├── OIP14.webp │ ├── OIP15.webp │ ├── OIP16.webp │ ├── OIP17.webp │ ├── OIP18.webp │ ├── OIP19.webp │ ├── OIP2.webp │ ├── OIP20.webp │ ├── OIP3.png │ ├── OIP4.png │ ├── OIP5.png │ ├── OIP6.webp │ ├── OIP7.webp │ ├── OIP8.webp │ ├── OIP9.webp │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ ├── manifest.json │ └── vite.svg ├── run-frontend-docker.sh ├── src │ ├── App.css │ ├── App.test.tsx │ ├── App.tsx │ ├── assets │ │ ├── OIP.jpg │ │ ├── OIP10.webp │ │ ├── OIP11.webp │ │ ├── OIP12.webp │ │ ├── OIP13.webp │ │ ├── OIP14.webp │ │ ├── OIP15.webp │ │ ├── OIP16.webp │ │ ├── OIP17.webp │ │ ├── OIP18.webp │ │ ├── OIP19.webp │ │ ├── OIP2.webp │ │ ├── OIP20.webp │ │ ├── OIP3.png │ │ ├── OIP4.png │ │ ├── OIP5.png │ │ ├── OIP6.webp │ │ ├── OIP7.webp │ │ ├── OIP8.webp │ │ ├── OIP9.webp │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-512x512.png │ │ ├── apple-touch-icon.png │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── favicon.ico │ │ ├── manifest.json │ │ ├── react.svg │ │ └── vite.svg │ ├── components │ │ ├── LoadingOverlay.tsx │ │ └── PasswordField.tsx │ ├── global.css │ ├── index.css │ ├── layout │ │ ├── Footer.tsx │ │ ├── Layout.tsx │ │ ├── Navbar.tsx │ │ └── ResponsiveDrawer.tsx │ ├── main.tsx │ ├── routes │ │ ├── ForgotPasswordPage.tsx │ │ ├── HomePage.tsx │ │ ├── LoginPage.tsx │ │ ├── NotFoundPage.tsx │ │ ├── NotesPage.tsx │ │ ├── ProfilePage.tsx │ │ └── RegisterPage.tsx │ ├── theme │ │ ├── ThemeContext.tsx │ │ ├── ThemeProviderWrapper.tsx │ │ └── index.ts │ └── vite-env.d.ts ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json ├── vercel.json └── vite.config.ts ├── img ├── add-note.png ├── api-docs.png ├── edit-note.png ├── footer.png ├── graphql.png ├── home-dark.png ├── home.png ├── login-dark.png ├── login.png ├── note-details.png ├── notes-dark.png ├── notes.png ├── profile-dark.png ├── profile.png ├── register-dark.png ├── register.png ├── reset-password-dark.png ├── reset-password.png ├── responsive.png └── schema.png ├── jenkins_cicd.sh ├── kubernetes ├── backend-deployment.yaml ├── backend-service.yaml ├── configmap.yaml ├── frontend-deployment.yaml └── frontend-service.yaml ├── nginx ├── Dockerfile ├── README.md ├── docker-compose.yml ├── nginx.conf └── start_nginx.sh ├── openapi.yaml ├── package-lock.json ├── package.json ├── start-all.sh ├── stop-all.sh └── vercel.json /.gitignore: -------------------------------------------------------------------------------- 1 | /backend/.env 2 | /backend/node_modules/ 3 | /node_modules/ 4 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | # Datasource local storage ignored files 7 | /dataSources/ 8 | /dataSources.local.xml 9 | -------------------------------------------------------------------------------- /.idea/Collaborative-Note-Tool.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/swagger-settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Stage 1: Backend Build 2 | FROM node:18-alpine AS backend-builder 3 | WORKDIR /app/backend 4 | 5 | # Copy backend files 6 | COPY backend/package*.json ./ 7 | RUN npm install --include=dev 8 | COPY backend/ . 9 | RUN npm run build 10 | 11 | # Stage 2: Frontend Build 12 | FROM node:18-alpine AS frontend-builder 13 | WORKDIR /app/frontend 14 | 15 | # Copy frontend files 16 | COPY frontend/package*.json ./ 17 | RUN npm install 18 | COPY frontend/ . 19 | RUN npm run build 20 | 21 | # Stage 3: Production Image 22 | FROM nginx:alpine AS production 23 | WORKDIR /app 24 | 25 | # Copy backend build artifacts 26 | WORKDIR /app/backend 27 | COPY --from=backend-builder /app/backend/dist ./dist 28 | COPY --from=backend-builder /app/backend/package*.json ./ 29 | RUN npm install --omit=dev 30 | 31 | # Copy frontend build artifacts 32 | WORKDIR /usr/share/nginx/html 33 | COPY --from=frontend-builder /app/frontend/dist . 34 | 35 | # Expose ports for both services 36 | EXPOSE 4000 80 37 | 38 | # Start the backend and frontend servers 39 | CMD ["sh", "-c", "node /app/backend/dist/main.js & nginx -g 'daemon off;'"] 40 | -------------------------------------------------------------------------------- /Jenkinsfile: -------------------------------------------------------------------------------- 1 | pipeline { 2 | agent any 3 | stages { 4 | stage('Install Dependencies') { 5 | steps { 6 | sh 'npm install' 7 | } 8 | } 9 | stage('Build') { 10 | steps { 11 | sh 'npm run build' 12 | } 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Son Nguyen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | .vercel 2 | /dist/ 3 | -------------------------------------------------------------------------------- /backend/Dockerfile: -------------------------------------------------------------------------------- 1 | # Stage 1: Build 2 | FROM node:18-alpine AS builder 3 | 4 | # Set working directory 5 | WORKDIR /app 6 | 7 | # Copy package.json and package-lock.json 8 | COPY package*.json ./ 9 | 10 | # Install dependencies 11 | RUN npm install --include=dev 12 | 13 | # Copy the rest of the application 14 | COPY . . 15 | 16 | # Build the application 17 | RUN npm run build 18 | 19 | # Stage 2: Production 20 | FROM node:18-alpine 21 | 22 | # Set working directory 23 | WORKDIR /app 24 | 25 | # Copy only necessary files from the builder stage 26 | COPY --from=builder /app/dist ./dist 27 | COPY --from=builder /app/package*.json ./ 28 | 29 | # Install only production dependencies 30 | RUN npm install --omit=dev 31 | 32 | # Expose the application port 33 | EXPOSE 4000 34 | 35 | # Start the application 36 | CMD ["node", "dist/main.js"] 37 | -------------------------------------------------------------------------------- /backend/build-backend.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # Step 1: Install dependencies 5 | echo "Installing backend dependencies..." 6 | npm install 7 | 8 | # Step 2: Build the backend 9 | echo "Building the backend..." 10 | npm run build 11 | 12 | # Step 3: Start the backend 13 | echo "Starting the backend server..." 14 | npm run start 15 | -------------------------------------------------------------------------------- /backend/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | backend: 5 | build: 6 | context: . 7 | dockerfile: Dockerfile 8 | ports: 9 | - "4000:4000" # Map the container port to the host port 10 | environment: 11 | NODE_ENV: production 12 | PORT: 4000 13 | JWT_SECRET: your_jwt_secret 14 | DATABASE_URL: your_database_url 15 | restart: unless-stopped 16 | volumes: 17 | - .:/app 18 | - /app/node_modules 19 | -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "backend", 3 | "version": "1.0.0", 4 | "description": "Backend for CollabNote App", 5 | "main": "dist/main.js", 6 | "scripts": { 7 | "build": "tsc", 8 | "start": "node dist/main.js", 9 | "start:dev": "nodemon --watch src --exec ts-node src/main.ts", 10 | "test": "npm run start:dev" 11 | }, 12 | "keywords": [ 13 | "nestjs", 14 | "supabase", 15 | "jwt", 16 | "passport", 17 | "notes", 18 | "collabnote", 19 | "backend" 20 | ], 21 | "repository": { 22 | "type": "git", 23 | "url": "git+https://github.com/hoangsonww/CollabNote-Fullstack-App.git" 24 | }, 25 | "bugs": { 26 | "url": "https://github.com/hoangsonww/CollabNote-Fullstack-App/issues" 27 | }, 28 | "author": "Son Nguyen", 29 | "license": "MIT", 30 | "dependencies": { 31 | "@nestjs/apollo": "^13.0.2", 32 | "@nestjs/common": "^11.0.4", 33 | "@nestjs/config": "^4.0.0", 34 | "@nestjs/core": "^11.0.4", 35 | "@nestjs/graphql": "^13.0.2", 36 | "@nestjs/jwt": "^11.0.0", 37 | "@nestjs/passport": "^11.0.4", 38 | "@nestjs/platform-express": "^11.0.5", 39 | "@nestjs/swagger": "^11.0.3", 40 | "@supabase/supabase-js": "^2.48.0", 41 | "apollo-server-express": "^3.13.0", 42 | "bcrypt": "^5.1.1", 43 | "graphql": "^16.10.0", 44 | "passport": "^0.7.0", 45 | "passport-jwt": "^4.0.1", 46 | "reflect-metadata": "^0.2.2", 47 | "rxjs": "^7.8.1", 48 | "swagger-ui-dist": "^5.18.2", 49 | "swagger-ui-express": "^5.0.1" 50 | }, 51 | "devDependencies": { 52 | "@types/bcrypt": "^5.0.2", 53 | "@types/node": "^22.10.7", 54 | "@types/passport-jwt": "^4.0.1", 55 | "@types/swagger-ui-express": "^4.1.7", 56 | "nodemon": "^3.1.9", 57 | "ts-node": "^10.9.2", 58 | "typescript": "^5.7.3" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /backend/run-backend-docker.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # Step 1: Build Docker image 5 | echo "Building Docker image for the backend..." 6 | docker build -t collabnote-backend . 7 | 8 | # Step 2: Run Docker container 9 | echo "Running Docker container for the backend..." 10 | docker run -p 4000:4000 --env-file .env collabnote-backend 11 | -------------------------------------------------------------------------------- /backend/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common"; 2 | import { ConfigModule } from "@nestjs/config"; 3 | import { GraphQLModule } from "@nestjs/graphql"; 4 | import { ApolloDriver, ApolloDriverConfig } from "@nestjs/apollo"; 5 | import { join } from "path"; 6 | 7 | import { AuthModule } from "./auth/auth.module"; 8 | import { NotesModule } from "./notes/notes.module"; 9 | import { ProfileModule } from "./profile/profile.module"; 10 | import { SupabaseModule } from "./supabase/supabase.module"; 11 | 12 | @Module({ 13 | imports: [ 14 | ConfigModule.forRoot({ 15 | isGlobal: true, 16 | }), 17 | GraphQLModule.forRoot({ 18 | driver: ApolloDriver, 19 | autoSchemaFile: join(process.cwd(), "src/schema.gql"), 20 | playground: true, 21 | debug: true, 22 | }), 23 | AuthModule, 24 | NotesModule, 25 | SupabaseModule, 26 | ProfileModule, 27 | ], 28 | }) 29 | /** 30 | * Main application module 31 | */ 32 | export class AppModule {} 33 | -------------------------------------------------------------------------------- /backend/src/app.test.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import { Test, TestingModule } from "@nestjs/testing"; 3 | import { NotesService } from "./notes.service"; 4 | import { SupabaseService } from "../supabase/supabase.service"; 5 | import { BadRequestException, NotFoundException } from "@nestjs/common"; 6 | 7 | /** 8 | * Tests for the NotesService 9 | */ 10 | describe("NotesService", () => { 11 | let service: NotesService; 12 | let supabaseService: Partial>; 13 | 14 | beforeEach(async () => { 15 | supabaseService = { 16 | getClient: jest.fn().mockReturnValue({ 17 | from: jest.fn().mockReturnThis(), 18 | select: jest.fn().mockReturnThis(), 19 | insert: jest.fn().mockReturnThis(), 20 | update: jest.fn().mockReturnThis(), 21 | delete: jest.fn().mockReturnThis(), 22 | eq: jest.fn().mockReturnThis(), 23 | or: jest.fn().mockReturnThis(), 24 | ilike: jest.fn().mockReturnThis(), 25 | contains: jest.fn().mockReturnThis(), 26 | single: jest.fn().mockReturnThis(), 27 | maybeSingle: jest.fn().mockReturnThis(), 28 | order: jest.fn().mockReturnThis(), 29 | }), 30 | }; 31 | 32 | const module: TestingModule = await Test.createTestingModule({ 33 | providers: [ 34 | NotesService, 35 | { provide: SupabaseService, useValue: supabaseService }, 36 | ], 37 | }).compile(); 38 | 39 | service = module.get(NotesService); 40 | }); 41 | 42 | describe("getUserNotes", () => { 43 | it("should fetch user notes with search and tag filter", async () => { 44 | const mockNotes = [{ id: 1, title: "Note 1", content: "Content" }]; 45 | supabaseService.getClient().from().select.mockResolvedValue({ 46 | data: mockNotes, 47 | error: null, 48 | }); 49 | 50 | const result = await service.getUserNotes(1, "Note", "tag"); 51 | expect(result).toEqual(mockNotes); 52 | expect(supabaseService.getClient().from).toHaveBeenCalledWith("notes"); 53 | expect(supabaseService.getClient().from().select).toHaveBeenCalled(); 54 | expect(supabaseService.getClient().from().ilike).toHaveBeenCalledWith( 55 | "title", 56 | "%Note%", 57 | ); 58 | expect(supabaseService.getClient().from().contains).toHaveBeenCalledWith( 59 | "tags", 60 | ["tag"], 61 | ); 62 | }); 63 | 64 | it("should throw an error if fetching notes fails", async () => { 65 | supabaseService 66 | .getClient() 67 | .from() 68 | .select.mockResolvedValue({ 69 | data: null, 70 | error: new Error("Database error"), 71 | }); 72 | 73 | await expect(service.getUserNotes(1)).rejects.toThrow("Database error"); 74 | }); 75 | }); 76 | 77 | describe("createNote", () => { 78 | it("should create a new note", async () => { 79 | const newNote = { id: 1, title: "New Note", content: "Content" }; 80 | supabaseService 81 | .getClient() 82 | .from() 83 | .insert.mockResolvedValue({ 84 | data: [newNote], 85 | error: null, 86 | }); 87 | 88 | const result = await service.createNote({ 89 | userId: 1, 90 | title: "New Note", 91 | content: "Content", 92 | }); 93 | 94 | expect(result).toEqual([newNote]); 95 | expect(supabaseService.getClient().from().insert).toHaveBeenCalled(); 96 | }); 97 | 98 | it("should throw BadRequestException on error", async () => { 99 | supabaseService 100 | .getClient() 101 | .from() 102 | .insert.mockResolvedValue({ 103 | data: null, 104 | error: { message: "Insert failed" }, 105 | }); 106 | 107 | await expect( 108 | service.createNote({ 109 | userId: 1, 110 | title: "New Note", 111 | content: "Content", 112 | }), 113 | ).rejects.toThrow(BadRequestException); 114 | }); 115 | }); 116 | 117 | describe("updateNote", () => { 118 | it("should update an existing note", async () => { 119 | const existingNote = { 120 | id: 1, 121 | user_id: 1, 122 | shared_with_user_ids: [], 123 | }; 124 | supabaseService 125 | .getClient() 126 | .from() 127 | .select.mockResolvedValue({ 128 | data: [existingNote], 129 | error: null, 130 | }); 131 | supabaseService 132 | .getClient() 133 | .from() 134 | .update.mockResolvedValue({ 135 | data: [existingNote], 136 | error: null, 137 | }); 138 | 139 | const result = await service.updateNote(1, 1, { title: "Updated Title" }); 140 | expect(result).toEqual([existingNote]); 141 | expect(supabaseService.getClient().from().update).toHaveBeenCalled(); 142 | }); 143 | 144 | it("should throw NotFoundException if note does not exist", async () => { 145 | supabaseService.getClient().from().select.mockResolvedValue({ 146 | data: [], 147 | error: null, 148 | }); 149 | 150 | await expect(service.updateNote(1, 1, {})).rejects.toThrow( 151 | NotFoundException, 152 | ); 153 | }); 154 | 155 | it("should throw BadRequestException if user is not allowed to update", async () => { 156 | const existingNote = { 157 | id: 1, 158 | user_id: 2, 159 | shared_with_user_ids: [], 160 | }; 161 | supabaseService 162 | .getClient() 163 | .from() 164 | .select.mockResolvedValue({ 165 | data: [existingNote], 166 | error: null, 167 | }); 168 | 169 | await expect(service.updateNote(1, 1, {})).rejects.toThrow( 170 | BadRequestException, 171 | ); 172 | }); 173 | }); 174 | 175 | describe("getUserIdFromUsername", () => { 176 | it("should return user ID for valid username", async () => { 177 | supabaseService 178 | .getClient() 179 | .from() 180 | .maybeSingle.mockResolvedValue({ 181 | data: { id: 1 }, 182 | error: null, 183 | }); 184 | 185 | const result = await service.getUserIdFromUsername("testuser"); 186 | expect(result).toBe(1); 187 | }); 188 | 189 | it("should throw NotFoundException for invalid username", async () => { 190 | supabaseService.getClient().from().maybeSingle.mockResolvedValue({ 191 | data: null, 192 | error: null, 193 | }); 194 | 195 | await expect( 196 | service.getUserIdFromUsername("invaliduser"), 197 | ).rejects.toThrow(NotFoundException); 198 | }); 199 | }); 200 | 201 | describe("shareNoteWithUser", () => { 202 | it("should share a note with another user", async () => { 203 | const existingNote = { 204 | id: 1, 205 | user_id: 1, 206 | shared_with_user_ids: [], 207 | }; 208 | supabaseService.getClient().from().select.mockResolvedValue({ 209 | data: existingNote, 210 | error: null, 211 | }); 212 | supabaseService 213 | .getClient() 214 | .from() 215 | .update.mockResolvedValue({ 216 | data: [{ id: 1 }], 217 | error: null, 218 | }); 219 | 220 | supabaseService 221 | .getClient() 222 | .from() 223 | .maybeSingle.mockResolvedValue({ 224 | data: { id: 2 }, 225 | error: null, 226 | }); 227 | 228 | const result = await service.shareNoteWithUser(1, 1, "testuser"); 229 | expect(result).toEqual([{ id: 1 }]); 230 | }); 231 | }); 232 | }); 233 | -------------------------------------------------------------------------------- /backend/src/auth/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Post, Body } from "@nestjs/common"; 2 | import { ApiTags, ApiOperation, ApiResponse, ApiBody } from "@nestjs/swagger"; 3 | import { AuthService } from "./auth.service"; 4 | 5 | @ApiTags("Authentication") 6 | @Controller("auth") 7 | /** 8 | * Controller for handling authentication-related endpoints 9 | */ 10 | export class AuthController { 11 | /** 12 | * Constructor for the AuthController 13 | * 14 | * @param authService The AuthService instance 15 | */ 16 | constructor(private readonly authService: AuthService) {} 17 | 18 | @Post("register") 19 | @ApiOperation({ summary: "Register a new user" }) // Summary for the endpoint 20 | @ApiResponse({ status: 201, description: "User successfully registered" }) 21 | @ApiResponse({ status: 400, description: "Bad request" }) 22 | @ApiBody({ 23 | description: "User registration data", 24 | schema: { 25 | type: "object", 26 | properties: { 27 | username: { type: "string", description: "The username of the user" }, 28 | email: { type: "string", description: "The email of the user" }, 29 | password: { type: "string", description: "The password of the user" }, 30 | }, 31 | }, 32 | }) 33 | /** 34 | * Register a new user 35 | * 36 | * @param body The request body 37 | */ 38 | register( 39 | @Body() body: { username: string; email: string; password: string }, 40 | ) { 41 | return this.authService.register(body.username, body.email, body.password); 42 | } 43 | 44 | @Post("login") 45 | @ApiOperation({ summary: "Login an existing user" }) 46 | @ApiResponse({ status: 200, description: "User successfully logged in" }) 47 | @ApiResponse({ status: 401, description: "Invalid credentials" }) 48 | @ApiBody({ 49 | description: "User login data", 50 | schema: { 51 | type: "object", 52 | properties: { 53 | email: { type: "string", description: "The email of the user" }, 54 | password: { type: "string", description: "The password of the user" }, 55 | }, 56 | }, 57 | }) 58 | /** 59 | * Login an existing user 60 | * 61 | * @param body The request body 62 | */ 63 | login(@Body() body: { email: string; password: string }) { 64 | return this.authService.login(body.email, body.password); 65 | } 66 | 67 | @Post("check-email-exists") 68 | @ApiOperation({ summary: "Check if an email exists" }) 69 | @ApiResponse({ status: 200, description: "Email check completed" }) 70 | @ApiResponse({ status: 400, description: "Bad request" }) 71 | @ApiBody({ 72 | description: "Email to check", 73 | schema: { 74 | type: "object", 75 | properties: { 76 | email: { type: "string", description: "The email to check" }, 77 | }, 78 | }, 79 | }) 80 | /** 81 | * Check if an email exists 82 | * 83 | * @param body The request body 84 | */ 85 | checkEmail(@Body() body: { email: string }) { 86 | return this.authService.checkEmailExists(body.email); 87 | } 88 | 89 | @Post("reset-password") 90 | @ApiOperation({ summary: "Reset a user's password" }) 91 | @ApiResponse({ status: 200, description: "Password successfully reset" }) 92 | @ApiResponse({ status: 400, description: "Bad request" }) 93 | @ApiBody({ 94 | description: "Password reset data", 95 | schema: { 96 | type: "object", 97 | properties: { 98 | email: { type: "string", description: "The email of the user" }, 99 | newPassword: { type: "string", description: "The new password" }, 100 | confirmPassword: { 101 | type: "string", 102 | description: "Confirmation of the new password", 103 | }, 104 | }, 105 | }, 106 | }) 107 | /** 108 | * Reset a user's password 109 | * 110 | * @param body The request body 111 | */ 112 | resetPassword( 113 | @Body() 114 | body: { 115 | email: string; 116 | newPassword: string; 117 | confirmPassword: string; 118 | }, 119 | ) { 120 | return this.authService.resetPassword( 121 | body.email, 122 | body.newPassword, 123 | body.confirmPassword, 124 | ); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /backend/src/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common"; 2 | import { AuthService } from "./auth.service"; 3 | import { AuthController } from "./auth.controller"; 4 | import { JwtModule } from "@nestjs/jwt"; 5 | import { ConfigService } from "@nestjs/config"; 6 | import { SupabaseModule } from "../supabase/supabase.module"; 7 | import { JwtStrategy } from "./jwt.strategy"; 8 | import { AuthResolver } from "./auth.resolver"; 9 | 10 | @Module({ 11 | imports: [ 12 | SupabaseModule, 13 | JwtModule.registerAsync({ 14 | useFactory: (config: ConfigService) => ({ 15 | secret: config.get("JWT_SECRET"), 16 | signOptions: { 17 | expiresIn: config.get("JWT_EXPIRES_IN"), 18 | }, 19 | }), 20 | inject: [ConfigService], 21 | }), 22 | ], 23 | providers: [AuthService, JwtStrategy, AuthResolver], 24 | controllers: [AuthController], 25 | }) 26 | /** 27 | * Module for handling authentication-related functionality 28 | */ 29 | export class AuthModule {} 30 | -------------------------------------------------------------------------------- /backend/src/auth/auth.resolver.ts: -------------------------------------------------------------------------------- 1 | import { Resolver, Mutation, Args } from "@nestjs/graphql"; 2 | import { AuthService } from "./auth.service"; 3 | import { AuthResponse } from "./auth.schema"; 4 | 5 | @Resolver() 6 | /** 7 | * The resolver for the authentication module - built for GraphQL 8 | */ 9 | export class AuthResolver { 10 | /** 11 | * Constructor for the AuthResolver 12 | * 13 | * @param authService The AuthService instance 14 | */ 15 | constructor(private readonly authService: AuthService) {} 16 | 17 | @Mutation(() => AuthResponse) 18 | /** 19 | * Registers a new user 20 | */ 21 | async register( 22 | @Args("username") username: string, 23 | @Args("email") email: string, 24 | @Args("password") password: string, 25 | ) { 26 | await this.authService.register(username, email, password); 27 | return { message: "User registered successfully" }; 28 | } 29 | 30 | @Mutation(() => AuthResponse) 31 | /** 32 | * Logs in an existing user 33 | */ 34 | async login( 35 | @Args("email") email: string, 36 | @Args("password") password: string, 37 | ) { 38 | const token = await this.authService.login(email, password); 39 | return { message: "Login successful", accessToken: token.access_token }; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /backend/src/auth/auth.schema.ts: -------------------------------------------------------------------------------- 1 | import { ObjectType, Field } from "@nestjs/graphql"; 2 | 3 | @ObjectType() 4 | /** 5 | * Object type for the response of the authentication process 6 | */ 7 | export class AuthResponse { 8 | @Field() 9 | /** 10 | * The message of the response 11 | */ 12 | message: string; 13 | 14 | @Field({ nullable: true }) 15 | /** 16 | * The access token for the user 17 | */ 18 | accessToken?: string; 19 | } 20 | -------------------------------------------------------------------------------- /backend/src/auth/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Injectable, 3 | BadRequestException, 4 | NotFoundException, 5 | UnauthorizedException, 6 | } from "@nestjs/common"; 7 | import { SupabaseService } from "../supabase/supabase.service"; 8 | import { JwtService } from "@nestjs/jwt"; 9 | import * as bcrypt from "bcrypt"; 10 | 11 | @Injectable() 12 | /** 13 | * Service for handling authentication-related functionality 14 | */ 15 | export class AuthService { 16 | /** 17 | * Constructor for the AuthService 18 | * 19 | * @param supabaseService The SupabaseService instance 20 | * @param jwtService The JwtService instance 21 | */ 22 | constructor( 23 | private readonly supabaseService: SupabaseService, 24 | private readonly jwtService: JwtService, 25 | ) {} 26 | 27 | /** 28 | * Register a new user 29 | * 30 | * @param username The username of the user 31 | * @param email The email of the user 32 | * @param password The password of the user 33 | */ 34 | async register(username: string, email: string, password: string) { 35 | const { data: existingUsers } = await this.supabaseService 36 | .getClient() 37 | .from("users") 38 | .select() 39 | .or(`email.eq.${email},username.eq.${username}`); 40 | 41 | if (existingUsers && existingUsers.length > 0) { 42 | throw new BadRequestException("User or email already exists"); 43 | } 44 | 45 | const hashedPassword = await bcrypt.hash(password, 10); 46 | const { error: insertError } = await this.supabaseService 47 | .getClient() 48 | .from("users") 49 | .insert([{ username, email, password: hashedPassword }]); 50 | 51 | if (insertError) { 52 | throw new BadRequestException(insertError.message); 53 | } 54 | 55 | return { message: "User registered successfully" }; 56 | } 57 | 58 | /** 59 | * Logs in an existing user 60 | * 61 | * @param email The email of the user 62 | * @param password The password of the user 63 | */ 64 | async login(email: string, password: string) { 65 | const { data: users } = await this.supabaseService 66 | .getClient() 67 | .from("users") 68 | .select() 69 | .eq("email", email); 70 | 71 | if (!users || users.length === 0) { 72 | throw new UnauthorizedException("Invalid credentials"); 73 | } 74 | 75 | const user = users[0]; 76 | const passwordMatch = await bcrypt.compare(password, user.password); 77 | 78 | if (!passwordMatch) { 79 | throw new UnauthorizedException("Invalid credentials"); 80 | } 81 | 82 | const payload = { 83 | sub: user.id, 84 | username: user.username, 85 | email: user.email, 86 | }; 87 | 88 | const token = this.jwtService.sign(payload); 89 | 90 | return { access_token: token }; 91 | } 92 | 93 | /** 94 | * Checks if an email exists 95 | * 96 | * @param email The email to check 97 | */ 98 | async checkEmailExists(email: string) { 99 | const { data: users } = await this.supabaseService 100 | .getClient() 101 | .from("users") 102 | .select() 103 | .eq("email", email); 104 | 105 | if (!users || users.length === 0) { 106 | return { exists: false }; 107 | } 108 | 109 | return { exists: true }; 110 | } 111 | 112 | /** 113 | * Resets a user's password 114 | * 115 | * @param email The email of the user 116 | * @param newPassword The new password 117 | * @param confirmPassword The confirmed new password 118 | */ 119 | async resetPassword( 120 | email: string, 121 | newPassword: string, 122 | confirmPassword: string, 123 | ) { 124 | if (newPassword !== confirmPassword) { 125 | throw new BadRequestException("Passwords do not match"); 126 | } 127 | 128 | const { data: users } = await this.supabaseService 129 | .getClient() 130 | .from("users") 131 | .select() 132 | .eq("email", email); 133 | 134 | if (!users || users.length === 0) { 135 | throw new NotFoundException("Email not found"); 136 | } 137 | 138 | const user = users[0]; 139 | const hashedPassword = await bcrypt.hash(newPassword, 10); 140 | 141 | const { error: updateErr } = await this.supabaseService 142 | .getClient() 143 | .from("users") 144 | .update({ password: hashedPassword }) 145 | .eq("id", user.id); 146 | 147 | if (updateErr) { 148 | throw new BadRequestException(updateErr.message); 149 | } 150 | 151 | return { message: "Password has been reset successfully" }; 152 | } 153 | 154 | /** 155 | * Validates a user 156 | * 157 | * @param userId The ID of the user 158 | */ 159 | async validateUser(userId: number) { 160 | const { data: users } = await this.supabaseService 161 | .getClient() 162 | .from("users") 163 | .select() 164 | .eq("id", userId); 165 | 166 | if (!users || users.length === 0) { 167 | return null; 168 | } 169 | 170 | return users[0]; 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /backend/src/auth/jwt.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@nestjs/common"; 2 | import { PassportStrategy } from "@nestjs/passport"; 3 | import { 4 | ExtractJwt, 5 | Strategy, 6 | StrategyOptionsWithoutRequest, 7 | } from "passport-jwt"; 8 | import { ConfigService } from "@nestjs/config"; 9 | import { AuthService } from "./auth.service"; 10 | 11 | @Injectable() 12 | /** 13 | * Strategy for handling JWT-based authentication 14 | */ 15 | export class JwtStrategy extends PassportStrategy(Strategy) { 16 | /** 17 | * Constructor for the JwtStrategy 18 | * 19 | * @param authService The AuthService instance 20 | * @param config The ConfigService instance 21 | */ 22 | constructor( 23 | private readonly authService: AuthService, 24 | private readonly config: ConfigService, 25 | ) { 26 | // Provide a definite string for secretOrKey (fallback if undefined) 27 | const secretKey: string = 28 | config.get("JWT_SECRET") || "fallbackSecret"; 29 | 30 | // Use StrategyOptionsWithoutRequest if we do NOT want req in validate() 31 | const options: StrategyOptionsWithoutRequest = { 32 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 33 | ignoreExpiration: false, 34 | secretOrKey: secretKey, 35 | }; 36 | 37 | super(options); 38 | } 39 | 40 | /** 41 | * Validates the payload of the JWT 42 | * 43 | * @param payload The payload of the JWT 44 | */ 45 | async validate(payload: any) { 46 | // payload.sub is the user ID 47 | const user = await this.authService.validateUser(payload.sub); 48 | 49 | if (!user) { 50 | return null; // or throw an UnauthorizedException 51 | } 52 | 53 | return user; // attaches to req.user 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /backend/src/dto/create-note.input.ts: -------------------------------------------------------------------------------- 1 | import { InputType, Field, Int } from "@nestjs/graphql"; 2 | 3 | @InputType() 4 | /** 5 | * Input type for creating a new note 6 | */ 7 | export class CreateNoteInput { 8 | @Field(() => Int) 9 | /** 10 | * The ID of the user who created the note 11 | */ 12 | userId: number; 13 | 14 | @Field() 15 | /** 16 | * The title of the note 17 | */ 18 | title: string; 19 | 20 | @Field() 21 | /** 22 | * The content of the note 23 | */ 24 | content: string; 25 | 26 | @Field(() => [String], { nullable: true }) 27 | /** 28 | * The tags associated with the note 29 | */ 30 | tags?: string[]; 31 | 32 | @Field({ nullable: true }) 33 | /** 34 | * The due date of the note 35 | */ 36 | dueDate?: string; 37 | 38 | @Field({ nullable: true }) 39 | /** 40 | * The color of the note 41 | */ 42 | color?: string; 43 | } 44 | -------------------------------------------------------------------------------- /backend/src/dto/update-note.input.ts: -------------------------------------------------------------------------------- 1 | import { InputType, Field, PartialType, Int } from "@nestjs/graphql"; 2 | import { CreateNoteInput } from "./create-note.input"; 3 | 4 | @InputType() 5 | /** 6 | * Input type for updating an existing note 7 | */ 8 | export class UpdateNoteInput extends PartialType(CreateNoteInput) { 9 | @Field(() => Int) 10 | /** 11 | * The ID of the note to update 12 | */ 13 | id: number; 14 | } 15 | -------------------------------------------------------------------------------- /backend/src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from "@nestjs/core"; 2 | import { AppModule } from "./app.module"; 3 | import { ConfigService } from "@nestjs/config"; 4 | import { Logger } from "@nestjs/common"; 5 | import { Request, Response, NextFunction } from "express"; 6 | import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger"; 7 | import { NestExpressApplication } from "@nestjs/platform-express"; 8 | 9 | /** 10 | * Bootstrap the NestJS application 11 | */ 12 | async function bootstrap() { 13 | const app = await NestFactory.create(AppModule); 14 | const configService = app.get(ConfigService); 15 | const logger = new Logger("Bootstrap"); 16 | 17 | // Enable CORS for all origins 18 | app.enableCors({ 19 | origin: true, 20 | credentials: true, 21 | }); 22 | 23 | // Middleware to log all incoming requests 24 | app.use((req: Request, res: Response, next: NextFunction) => { 25 | logger.log(`Incoming Request: ${req.method} ${req.url}`); 26 | next(); 27 | }); 28 | 29 | // Redirect `/` to `/api` only for root path 30 | app.use("/", (req: Request, res: Response, next: NextFunction) => { 31 | if (req.path === "/") { 32 | res.redirect("/api"); 33 | } else { 34 | next(); 35 | } 36 | }); 37 | 38 | // Swagger Setup with Enhanced Metadata 39 | const swaggerConfig = new DocumentBuilder() 40 | .setTitle("CollabNote API") 41 | .setDescription( 42 | "Comprehensive API documentation for the CollabNote application, an intuitive collaborative notes platform.", 43 | ) 44 | .setVersion("1.0.0") 45 | .addBearerAuth() 46 | .setContact( 47 | "Son Nguyen", 48 | "https://github.com/hoangsonww", 49 | "hoangson091104@gmail.com", 50 | ) 51 | .setLicense("MIT", "https://opensource.org/licenses/MIT") 52 | .addServer( 53 | "https://collabnote-fullstack-app.onrender.com", 54 | "Production server", 55 | ) 56 | .addServer("http://localhost:4000", "Development server") 57 | .build(); 58 | 59 | const document = SwaggerModule.createDocument(app, swaggerConfig); 60 | 61 | // Set up Swagger documentation route 62 | SwaggerModule.setup("api", app, document, { 63 | customSiteTitle: "CollabNote API Documentation", 64 | }); 65 | 66 | // Start up the NestJS application 67 | const port = configService.get("PORT", 4000); 68 | await app.listen(port, () => { 69 | logger.log(`NestJS Backend running on port ${port}`); 70 | logger.log(`Swagger API documentation available at /api`); 71 | }); 72 | } 73 | 74 | bootstrap(); 75 | -------------------------------------------------------------------------------- /backend/src/notes/notes.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Get, 4 | Post, 5 | Body, 6 | Request, 7 | UseGuards, 8 | Param, 9 | Patch, 10 | Delete, 11 | Query, 12 | } from "@nestjs/common"; 13 | import { NotesService } from "./notes.service"; 14 | import { AuthGuard } from "@nestjs/passport"; 15 | import { AuthenticatedRequest } from "../types/authenticated-request"; 16 | import { 17 | ApiTags, 18 | ApiOperation, 19 | ApiResponse, 20 | ApiBody, 21 | ApiQuery, 22 | ApiParam, 23 | ApiBearerAuth, 24 | } from "@nestjs/swagger"; 25 | 26 | @ApiTags("Notes") 27 | @Controller("notes") 28 | @ApiBearerAuth() 29 | /** 30 | * Controller for handling note-related endpoints 31 | */ 32 | export class NotesController { 33 | /** 34 | * Constructor for the NotesController 35 | * 36 | * @param notesService The NotesService instance 37 | */ 38 | constructor(private readonly notesService: NotesService) {} 39 | 40 | @UseGuards(AuthGuard("jwt")) 41 | @Get() 42 | @ApiOperation({ summary: "Retrieve user notes" }) 43 | @ApiQuery({ 44 | name: "search", 45 | required: false, 46 | description: "Search term to filter notes", 47 | }) 48 | @ApiQuery({ 49 | name: "tag", 50 | required: false, 51 | description: "Tag to filter notes", 52 | }) 53 | @ApiResponse({ 54 | status: 200, 55 | description: "List of user notes retrieved successfully", 56 | }) 57 | @ApiResponse({ status: 401, description: "Unauthorized access" }) 58 | /** 59 | * Retrieve notes for the authenticated user 60 | */ 61 | async getNotes( 62 | @Request() req: AuthenticatedRequest, 63 | @Query("search") search?: string, 64 | @Query("tag") tag?: string, 65 | ) { 66 | return this.notesService.getUserNotes(req.user.id, search, tag); 67 | } 68 | 69 | @UseGuards(AuthGuard("jwt")) 70 | @Post() 71 | @ApiOperation({ summary: "Create a new note" }) 72 | @ApiResponse({ status: 201, description: "Note created successfully" }) 73 | @ApiResponse({ status: 400, description: "Invalid input" }) 74 | @ApiBody({ 75 | description: "Details of the new note", 76 | schema: { 77 | type: "object", 78 | properties: { 79 | title: { type: "string", description: "Title of the note" }, 80 | content: { type: "string", description: "Content of the note" }, 81 | tags: { 82 | type: "array", 83 | items: { type: "string" }, 84 | description: "Tags associated with the note", 85 | }, 86 | dueDate: { 87 | type: "string", 88 | format: "date-time", 89 | description: "Due date of the note (optional)", 90 | }, 91 | color: { 92 | type: "string", 93 | description: "Color associated with the note (optional)", 94 | }, 95 | }, 96 | }, 97 | }) 98 | /** 99 | * Create a new note for the authenticated user 100 | */ 101 | async createNote( 102 | @Request() req: AuthenticatedRequest, 103 | @Body() 104 | body: { 105 | title: string; 106 | content: string; 107 | tags?: string[]; 108 | dueDate?: string; 109 | color?: string; 110 | }, 111 | ) { 112 | return this.notesService.createNote({ 113 | userId: req.user.id, 114 | title: body.title, 115 | content: body.content, 116 | tags: body.tags, 117 | dueDate: body.dueDate, 118 | color: body.color, 119 | }); 120 | } 121 | 122 | @UseGuards(AuthGuard("jwt")) 123 | @Patch(":id") 124 | @ApiOperation({ summary: "Update a note" }) 125 | @ApiResponse({ status: 200, description: "Note updated successfully" }) 126 | @ApiResponse({ status: 404, description: "Note not found" }) 127 | @ApiParam({ name: "id", description: "ID of the note to update" }) 128 | /** 129 | * Update a note for the authenticated user 130 | */ 131 | async updateNote( 132 | @Request() req: AuthenticatedRequest, 133 | @Param("id") id: string, 134 | @Body() updates: any, 135 | ) { 136 | return this.notesService.updateNote(+id, req.user.id, updates); 137 | } 138 | 139 | @UseGuards(AuthGuard("jwt")) 140 | @Post(":id/share") 141 | @ApiOperation({ summary: "Share a note with another user" }) 142 | @ApiResponse({ status: 200, description: "Note shared successfully" }) 143 | @ApiResponse({ status: 404, description: "Note not found or user not found" }) 144 | @ApiParam({ name: "id", description: "ID of the note to share" }) 145 | @ApiBody({ 146 | description: "Target username of the user to share the note with", 147 | schema: { 148 | type: "object", 149 | properties: { 150 | targetUsername: { 151 | type: "string", 152 | description: "Username of the user to share the note with", 153 | }, 154 | }, 155 | }, 156 | }) 157 | /** 158 | * Share a note with another user 159 | */ 160 | async shareNote( 161 | @Request() req: AuthenticatedRequest, 162 | @Param("id") id: string, 163 | @Body() body: { targetUsername: string }, 164 | ) { 165 | return this.notesService.shareNoteWithUser( 166 | +id, 167 | req.user.id, 168 | body.targetUsername, 169 | ); 170 | } 171 | 172 | @UseGuards(AuthGuard("jwt")) 173 | @Delete(":id") 174 | @ApiOperation({ summary: "Delete a note" }) 175 | @ApiResponse({ status: 200, description: "Note deleted successfully" }) 176 | @ApiResponse({ status: 404, description: "Note not found" }) 177 | @ApiParam({ name: "id", description: "ID of the note to delete" }) 178 | /** 179 | * Delete a note for the authenticated user 180 | */ 181 | async removeNoteForUser( 182 | @Request() req: AuthenticatedRequest, 183 | @Param("id") id: string, 184 | ) { 185 | return this.notesService.removeNoteForUser(+id, req.user.id); 186 | } 187 | 188 | @UseGuards(AuthGuard("jwt")) 189 | @Post("reorder") 190 | @ApiOperation({ summary: "Reorder user notes" }) 191 | @ApiResponse({ status: 200, description: "Notes reordered successfully" }) 192 | @ApiResponse({ status: 400, description: "Invalid input" }) 193 | @ApiBody({ 194 | description: "New order of note IDs", 195 | schema: { 196 | type: "object", 197 | properties: { 198 | noteOrder: { 199 | type: "array", 200 | items: { type: "number" }, 201 | description: "Array of note IDs in the desired order", 202 | }, 203 | }, 204 | }, 205 | }) 206 | /** 207 | * Reorder notes for the authenticated user 208 | */ 209 | async reorderNotes( 210 | @Request() req: AuthenticatedRequest, 211 | @Body() body: { noteOrder: number[] }, 212 | ) { 213 | return this.notesService.reorderNotes(req.user.id, body.noteOrder); 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /backend/src/notes/notes.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common"; 2 | import { NotesService } from "./notes.service"; 3 | import { NotesController } from "./notes.controller"; 4 | import { SupabaseModule } from "../supabase/supabase.module"; 5 | import { NotesResolver } from "./notes.resolver"; 6 | 7 | @Module({ 8 | imports: [SupabaseModule], 9 | providers: [NotesService, NotesResolver], 10 | controllers: [NotesController], 11 | }) 12 | /** 13 | * Module for handling note-related functionality 14 | */ 15 | export class NotesModule {} 16 | -------------------------------------------------------------------------------- /backend/src/notes/notes.resolver.ts: -------------------------------------------------------------------------------- 1 | import { Resolver, Query, Mutation, Args, Int } from "@nestjs/graphql"; 2 | import { NotesService } from "./notes.service"; 3 | import { Note } from "./notes.schema"; 4 | import { CreateNoteInput } from "../dto/create-note.input"; 5 | import { UpdateNoteInput } from "../dto/update-note.input"; 6 | 7 | @Resolver(() => Note) 8 | /** 9 | * Resolver for note-related functionality 10 | */ 11 | export class NotesResolver { 12 | /** 13 | * Constructor for the NotesResolver 14 | * 15 | * @param notesService The NotesService instance 16 | */ 17 | constructor(private readonly notesService: NotesService) {} 18 | 19 | @Query(() => [Note], { name: "getUserNotes" }) 20 | /** 21 | * Retrieve notes for a specific user 22 | */ 23 | async getUserNotes( 24 | @Args("userId", { type: () => Int }) userId: number, 25 | @Args("searchQuery", { type: () => String, nullable: true }) 26 | searchQuery?: string, 27 | @Args("tagFilter", { type: () => String, nullable: true }) 28 | tagFilter?: string, 29 | ) { 30 | return this.notesService.getUserNotes(userId, searchQuery, tagFilter); 31 | } 32 | 33 | @Mutation(() => Note) 34 | /** 35 | * Create a new note 36 | */ 37 | async createNote(@Args("createNoteInput") createNoteInput: CreateNoteInput) { 38 | return this.notesService.createNote({ 39 | userId: createNoteInput.userId, 40 | title: createNoteInput.title, 41 | content: createNoteInput.content, 42 | tags: createNoteInput.tags, 43 | dueDate: createNoteInput.dueDate, 44 | color: createNoteInput.color, 45 | }); 46 | } 47 | 48 | @Mutation(() => Note) 49 | /** 50 | * Update an existing note 51 | */ 52 | async updateNote( 53 | @Args("noteId", { type: () => Int }) noteId: number, 54 | @Args("userId", { type: () => Int }) userId: number, 55 | @Args("updates") updates: UpdateNoteInput, 56 | ) { 57 | return this.notesService.updateNote(noteId, userId, updates); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /backend/src/notes/notes.schema.ts: -------------------------------------------------------------------------------- 1 | import { ObjectType, Field, Int } from "@nestjs/graphql"; 2 | 3 | @ObjectType() 4 | /** 5 | * Object type for a note 6 | */ 7 | export class Note { 8 | @Field(() => Int) 9 | /** 10 | * The ID of the note 11 | */ 12 | id: number; 13 | 14 | @Field(() => Int) 15 | /** 16 | * The ID of the user who created the note 17 | */ 18 | userId: number; 19 | 20 | @Field() 21 | /** 22 | * The title of the note 23 | */ 24 | title: string; 25 | 26 | @Field() 27 | /** 28 | * The content of the note 29 | */ 30 | content: string; 31 | 32 | @Field(() => [String], { nullable: true }) 33 | /** 34 | * The tags of the note 35 | */ 36 | tags?: string[]; 37 | 38 | @Field({ nullable: true }) 39 | /** 40 | * The due date of the note 41 | */ 42 | dueDate?: string; 43 | 44 | @Field({ nullable: true }) 45 | /** 46 | * The color of the note 47 | */ 48 | color?: string; 49 | 50 | @Field({ nullable: true }) 51 | /** 52 | * The date the note was created 53 | */ 54 | pinned?: boolean; 55 | 56 | @Field(() => [Int], { nullable: true }) 57 | /** 58 | * The IDs of the users the note is shared with 59 | */ 60 | sharedWithUserIds?: number[]; 61 | 62 | @Field(() => Int, { nullable: true }) 63 | /** 64 | * The sort order of the note 65 | */ 66 | sortOrder?: number; 67 | 68 | @Field({ nullable: true }) 69 | /** 70 | * The username of the user who created the note 71 | */ 72 | username?: string; 73 | } 74 | -------------------------------------------------------------------------------- /backend/src/notes/notes.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Injectable, 3 | BadRequestException, 4 | NotFoundException, 5 | } from "@nestjs/common"; 6 | import { SupabaseService } from "../supabase/supabase.service"; 7 | 8 | @Injectable() 9 | /** 10 | * Service for handling note-related functionality 11 | */ 12 | export class NotesService { 13 | /** 14 | * Constructor for the NotesService 15 | * 16 | * @param supabaseService The SupabaseService instance 17 | */ 18 | constructor(private readonly supabaseService: SupabaseService) {} 19 | 20 | /** 21 | * Retrieve notes for a user 22 | * 23 | * @param userId The ID of the user 24 | * @param searchQuery The search query to filter notes 25 | * @param tagFilter The tag to filter notes 26 | */ 27 | async getUserNotes(userId: number, searchQuery?: string, tagFilter?: string) { 28 | let query = this.supabaseService 29 | .getClient() 30 | .from("notes") 31 | .select() 32 | // This OR means: user_id == current user OR current user is in shared_with_user_ids 33 | .or(`user_id.eq.${userId},shared_with_user_ids.cs.{${userId}}`) 34 | .order("sort_order", { ascending: true }) 35 | .order("id", { ascending: false }); 36 | 37 | if (searchQuery) { 38 | // search title using ilike 39 | query = query.ilike("title", `%${searchQuery}%`); 40 | } 41 | 42 | if (tagFilter) { 43 | // filter notes containing tagFilter in tags array 44 | query = query.contains("tags", [tagFilter]); 45 | } 46 | 47 | const { data, error } = await query; 48 | 49 | if (error) throw error; 50 | return data; 51 | } 52 | 53 | /** 54 | * Create a new note 55 | * 56 | * @param noteData The data for the new note 57 | */ 58 | async createNote(noteData: { 59 | userId: number; 60 | title: string; 61 | content: string; 62 | tags?: string[]; 63 | dueDate?: string; 64 | color?: string; 65 | }) { 66 | const { userId, title, content, tags, dueDate, color } = noteData; 67 | const { data, error } = await this.supabaseService 68 | .getClient() 69 | .from("notes") 70 | .insert([ 71 | { 72 | user_id: userId, 73 | title, 74 | content, 75 | tags: tags || [], 76 | due_date: dueDate || null, 77 | color: color || "#ffffff", 78 | pinned: false, 79 | shared_with_user_ids: [], 80 | sort_order: 999999, 81 | }, 82 | ]) 83 | .select(); // returns newly inserted row 84 | 85 | if (error) { 86 | throw new BadRequestException(error.message); 87 | } 88 | 89 | return data; 90 | } 91 | 92 | /** 93 | * Update an existing note 94 | * 95 | * @param noteId The ID of the note 96 | * @param userId The ID of the user 97 | * @param updates The updates to apply to the note 98 | */ 99 | async updateNote(noteId: number, userId: number, updates: any) { 100 | const { data: existing, error: findError } = await this.supabaseService 101 | .getClient() 102 | .from("notes") 103 | .select() 104 | .eq("id", noteId); 105 | 106 | if (findError) throw findError; 107 | 108 | if (!existing || existing.length === 0) { 109 | throw new NotFoundException("Note not found"); 110 | } 111 | 112 | const note = existing[0]; 113 | const isOwner = note.user_id === userId; 114 | const isSharedWithUser = note.shared_with_user_ids?.includes(userId); 115 | 116 | if (!isOwner && !isSharedWithUser) { 117 | throw new BadRequestException("You cannot update this note"); 118 | } 119 | 120 | const { data, error } = await this.supabaseService 121 | .getClient() 122 | .from("notes") 123 | .update(updates) 124 | .eq("id", noteId) 125 | .select(); 126 | 127 | if (error) { 128 | throw new BadRequestException(error.message); 129 | } 130 | 131 | return data; 132 | } 133 | 134 | /** 135 | * Get the user ID from a username 136 | * 137 | * @param username The username to search for 138 | */ 139 | async getUserIdFromUsername(username: string) { 140 | const { data, error } = await this.supabaseService 141 | .getClient() 142 | .from("users") 143 | .select("id") 144 | .eq("username", username) 145 | .maybeSingle(); // Change single() to maybeSingle() 146 | 147 | if (error) throw error; // Throw the error if the query fails 148 | 149 | if (!data) { 150 | throw new NotFoundException(`User with username "${username}" not found`); 151 | } 152 | 153 | return data.id; 154 | } 155 | 156 | /** 157 | * Share a note with another user 158 | * 159 | * @param noteId The ID of the note 160 | * @param ownerId The ID of the owner 161 | * @param targetUserName The username of the user to share the note with 162 | */ 163 | async shareNoteWithUser( 164 | noteId: number, 165 | ownerId: number, 166 | targetUserName: string, 167 | ) { 168 | // Get user ID from username 169 | const targetUserId = await this.getUserIdFromUsername(targetUserName); 170 | 171 | const { data: existing, error: findError } = await this.supabaseService 172 | .getClient() 173 | .from("notes") 174 | .select() 175 | .eq("id", noteId) 176 | .single(); 177 | 178 | if (findError) throw findError; 179 | 180 | if (!existing) { 181 | throw new NotFoundException("Note not found"); 182 | } 183 | 184 | if (existing.user_id !== ownerId) { 185 | throw new BadRequestException("You are not the owner of this note"); 186 | } 187 | 188 | const updatedSharedWith = existing.shared_with_user_ids || []; 189 | 190 | if (!updatedSharedWith.includes(targetUserId)) { 191 | updatedSharedWith.push(targetUserId); 192 | } 193 | 194 | const { data, error } = await this.supabaseService 195 | .getClient() 196 | .from("notes") 197 | .update({ shared_with_user_ids: updatedSharedWith }) 198 | .eq("id", noteId) 199 | .select(); 200 | 201 | if (error) { 202 | throw new BadRequestException(error.message); 203 | } 204 | 205 | return data; 206 | } 207 | 208 | /** 209 | * Remove a note for a user 210 | * 211 | * @param noteId The ID of the note 212 | * @param userId The ID of the user 213 | */ 214 | async removeNoteForUser(noteId: number, userId: number) { 215 | const { data: existing, error: findError } = await this.supabaseService 216 | .getClient() 217 | .from("notes") 218 | .select() 219 | .eq("id", noteId) 220 | .single(); 221 | 222 | if (findError) throw findError; 223 | 224 | if (!existing) { 225 | throw new NotFoundException("Note not found"); 226 | } 227 | 228 | // If user is owner, fully delete 229 | if (existing.user_id === userId) { 230 | const { error: delErr } = await this.supabaseService 231 | .getClient() 232 | .from("notes") 233 | .delete() 234 | .eq("id", noteId); 235 | 236 | if (delErr) throw new BadRequestException(delErr.message); 237 | 238 | return { message: "Note deleted from system" }; 239 | } else { 240 | // If not owner, remove user from shared_with_user_ids 241 | if (!existing.shared_with_user_ids?.includes(userId)) { 242 | throw new BadRequestException( 243 | "You do not have access to remove this note", 244 | ); 245 | } 246 | 247 | const updatedSharedWith = existing.shared_with_user_ids.filter( 248 | (id: any) => id !== userId, 249 | ); 250 | 251 | const { error: updateErr } = await this.supabaseService 252 | .getClient() 253 | .from("notes") 254 | .update({ shared_with_user_ids: updatedSharedWith }) 255 | .eq("id", noteId); 256 | 257 | if (updateErr) { 258 | throw new BadRequestException(updateErr.message); 259 | } 260 | 261 | return { message: "Note removed for this user only" }; 262 | } 263 | } 264 | 265 | /** 266 | * Reorder notes for a user 267 | * 268 | * @param userId The ID of the user 269 | * @param noteOrder The new order of note IDs 270 | */ 271 | async reorderNotes(userId: number, noteOrder: number[]) { 272 | // For each note ID in the new order, set sort_order = index 273 | for (let i = 0; i < noteOrder.length; i++) { 274 | const id = noteOrder[i]; 275 | 276 | await this.supabaseService 277 | .getClient() 278 | .from("notes") 279 | .update({ sort_order: i }) 280 | .eq("id", id) 281 | .or(`user_id.eq.${userId},shared_with_user_ids.cs.{${userId}}`); 282 | } 283 | 284 | return { message: "Notes reordered" }; 285 | } 286 | } 287 | -------------------------------------------------------------------------------- /backend/src/profile/profile.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Get, 4 | Param, 5 | Query, 6 | UseGuards, 7 | Request, 8 | } from "@nestjs/common"; 9 | import { ProfileService } from "./profile.service"; 10 | import { AuthGuard } from "@nestjs/passport"; 11 | import { AuthenticatedRequest } from "../types/authenticated-request"; 12 | import { 13 | ApiTags, 14 | ApiOperation, 15 | ApiResponse, 16 | ApiParam, 17 | ApiQuery, 18 | ApiBearerAuth, 19 | } from "@nestjs/swagger"; 20 | 21 | @ApiTags("Profile") 22 | @ApiBearerAuth() 23 | @Controller("profile") 24 | /** 25 | * Controller for handling profile-related endpoints 26 | */ 27 | export class ProfileController { 28 | /** 29 | * Constructor for the ProfileController 30 | * 31 | * @param profileService The ProfileService instance 32 | */ 33 | constructor(private readonly profileService: ProfileService) {} 34 | 35 | @UseGuards(AuthGuard("jwt")) 36 | @Get("me") 37 | @ApiOperation({ summary: "Retrieve the authenticated user's profile" }) 38 | @ApiResponse({ status: 200, description: "Profile retrieved successfully" }) 39 | @ApiResponse({ status: 401, description: "Unauthorized access" }) 40 | /** 41 | * Retrieve the profile of the authenticated user 42 | */ 43 | async getMyProfile(@Request() req: AuthenticatedRequest) { 44 | return this.profileService.getUserProfileById(req.user.id); 45 | } 46 | 47 | @UseGuards(AuthGuard("jwt")) 48 | @Get("userId/:id") 49 | @ApiOperation({ summary: "Retrieve a user profile by ID" }) 50 | @ApiResponse({ status: 200, description: "Profile retrieved successfully" }) 51 | @ApiResponse({ status: 404, description: "User not found" }) 52 | @ApiParam({ 53 | name: "id", 54 | description: "ID of the user whose profile is to be retrieved", 55 | }) 56 | /** 57 | * Retrieve a user profile by ID 58 | */ 59 | async getProfileById(@Param("id") id: string) { 60 | return this.profileService.getUserProfileById(+id); 61 | } 62 | 63 | @UseGuards(AuthGuard("jwt")) 64 | @Get("search") 65 | @ApiOperation({ summary: "Search for a user profile by username" }) 66 | @ApiResponse({ 67 | status: 200, 68 | description: "Search results returned successfully", 69 | }) 70 | @ApiQuery({ 71 | name: "username", 72 | required: false, 73 | description: "Username to search for", 74 | }) 75 | /** 76 | * Search for a user profile by username 77 | */ 78 | async searchProfile(@Query("username") username: string) { 79 | if (!username) return []; 80 | 81 | return this.profileService.getUserProfileByUsername(username); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /backend/src/profile/profile.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common"; 2 | import { ProfileService } from "./profile.service"; 3 | import { ProfileController } from "./profile.controller"; 4 | import { SupabaseModule } from "../supabase/supabase.module"; 5 | import { ProfileResolver } from "./profile.resolver"; 6 | 7 | @Module({ 8 | imports: [SupabaseModule], 9 | providers: [ProfileService, ProfileResolver], 10 | controllers: [ProfileController], 11 | }) 12 | /** 13 | * Module for handling profile-related functionality 14 | */ 15 | export class ProfileModule {} 16 | -------------------------------------------------------------------------------- /backend/src/profile/profile.resolver.ts: -------------------------------------------------------------------------------- 1 | import { Resolver, Query, Args, Int } from "@nestjs/graphql"; 2 | import { ProfileService } from "./profile.service"; 3 | import { UserProfile } from "./profile.schema"; 4 | 5 | @Resolver(() => UserProfile) 6 | /** 7 | * Resolver for profile-related functionality 8 | */ 9 | export class ProfileResolver { 10 | /** 11 | * Constructor for the ProfileResolver 12 | * 13 | * @param profileService The ProfileService instance 14 | */ 15 | constructor(private readonly profileService: ProfileService) {} 16 | 17 | @Query(() => UserProfile) 18 | /** 19 | * Retrieve a user's profile by ID 20 | */ 21 | async getUserProfileById( 22 | @Args("userId", { type: () => Int }) userId: number, 23 | ) { 24 | return this.profileService.getUserProfileById(userId); 25 | } 26 | 27 | @Query(() => [UserProfile]) 28 | /** 29 | * Retrieve a user's profile by username 30 | */ 31 | async searchProfiles( 32 | @Args("username", { type: () => String }) username: string, 33 | ) { 34 | return this.profileService.getUserProfileByUsername(username); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /backend/src/profile/profile.schema.ts: -------------------------------------------------------------------------------- 1 | import { ObjectType, Field, Int } from "@nestjs/graphql"; 2 | 3 | @ObjectType() 4 | /** 5 | * Object type for a user profile 6 | */ 7 | export class UserProfile { 8 | @Field(() => Int) 9 | /** 10 | * The ID of the user 11 | */ 12 | id: number; 13 | 14 | @Field() 15 | /** 16 | * The username of the user 17 | */ 18 | username: string; 19 | 20 | @Field() 21 | /** 22 | * The email of the user 23 | */ 24 | email: string; 25 | 26 | @Field() 27 | /** 28 | * The date the user was created 29 | */ 30 | createdAt: string; 31 | } 32 | -------------------------------------------------------------------------------- /backend/src/profile/profile.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NotFoundException } from "@nestjs/common"; 2 | import { SupabaseService } from "../supabase/supabase.service"; 3 | 4 | @Injectable() 5 | /** 6 | * Service for handling profile-related functionality 7 | */ 8 | export class ProfileService { 9 | /** 10 | * Constructor for the ProfileService 11 | * @param supabaseService 12 | */ 13 | constructor(private readonly supabaseService: SupabaseService) {} 14 | 15 | /** 16 | * Retrieve a user's profile by ID 17 | * 18 | * @param userId The ID of the user 19 | */ 20 | async getUserProfileById(userId: number) { 21 | const { data, error } = await this.supabaseService 22 | .getClient() 23 | .from("users") 24 | .select("id, username, email, created_at") 25 | .eq("id", userId); 26 | if (error) throw error; 27 | 28 | if (!data || data.length === 0) 29 | throw new NotFoundException("User not found"); 30 | 31 | return data[0]; 32 | } 33 | 34 | /** 35 | * Retrieve a user's profile by username 36 | * 37 | * @param username The username of the user 38 | */ 39 | async getUserProfileByUsername(username: string) { 40 | const { data, error } = await this.supabaseService 41 | .getClient() 42 | .from("users") 43 | .select("id, username, email") 44 | .ilike("username", `%${username}%`); 45 | 46 | if (error) throw error; 47 | 48 | return data; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /backend/src/schema.gql: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------ 2 | # THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY) 3 | # ------------------------------------------------------ 4 | 5 | type AuthResponse { 6 | message: String! 7 | accessToken: String 8 | } 9 | 10 | type Note { 11 | id: Int! 12 | userId: Int! 13 | title: String! 14 | content: String! 15 | tags: [String!] 16 | dueDate: String 17 | color: String 18 | pinned: Boolean 19 | sharedWithUserIds: [Int!] 20 | sortOrder: Int 21 | username: String 22 | } 23 | 24 | type UserProfile { 25 | id: Int! 26 | username: String! 27 | email: String! 28 | createdAt: String! 29 | } 30 | 31 | type Query { 32 | getUserNotes(userId: Int!, searchQuery: String, tagFilter: String): [Note!]! 33 | getUserProfileById(userId: Int!): UserProfile! 34 | searchProfiles(username: String!): [UserProfile!]! 35 | } 36 | 37 | type Mutation { 38 | register(username: String!, email: String!, password: String!): AuthResponse! 39 | login(email: String!, password: String!): AuthResponse! 40 | createNote(createNoteInput: CreateNoteInput!): Note! 41 | updateNote(noteId: Int!, userId: Int!, updates: UpdateNoteInput!): Note! 42 | } 43 | 44 | input CreateNoteInput { 45 | userId: Int! 46 | title: String! 47 | content: String! 48 | tags: [String!] 49 | dueDate: String 50 | color: String 51 | } 52 | 53 | input UpdateNoteInput { 54 | userId: Int 55 | title: String 56 | content: String 57 | tags: [String!] 58 | dueDate: String 59 | color: String 60 | id: Int! 61 | } -------------------------------------------------------------------------------- /backend/src/supabase/supabase.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common"; 2 | import { SupabaseService } from "./supabase.service"; 3 | 4 | @Module({ 5 | providers: [SupabaseService], 6 | exports: [SupabaseService], 7 | }) 8 | /** 9 | * Module for handling Supabase-related functionality 10 | */ 11 | export class SupabaseModule {} 12 | -------------------------------------------------------------------------------- /backend/src/supabase/supabase.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@nestjs/common"; 2 | import { ConfigService } from "@nestjs/config"; 3 | import { createClient, SupabaseClient } from "@supabase/supabase-js"; 4 | 5 | @Injectable() 6 | /** 7 | * Service for handling Supabase-related functionality 8 | */ 9 | export class SupabaseService { 10 | /** 11 | * Supabase client 12 | * 13 | * @private 14 | */ 15 | private readonly client: SupabaseClient; 16 | 17 | /** 18 | * Constructor for the SupabaseService 19 | * 20 | * @param configService The ConfigService instance 21 | */ 22 | constructor(private configService: ConfigService) { 23 | const url = this.configService.get("SUPABASE_URL") || ""; 24 | 25 | const serviceKey = 26 | this.configService.get("SUPABASE_SERVICE_KEY") || ""; 27 | 28 | this.client = createClient(url, serviceKey); 29 | } 30 | 31 | /** 32 | * Get the Supabase client 33 | */ 34 | getClient(): SupabaseClient { 35 | return this.client; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /backend/src/types/authenticated-request.ts: -------------------------------------------------------------------------------- 1 | import { Request } from "express"; 2 | 3 | /** 4 | * Interface for an authenticated request 5 | * 6 | * @property user The user object containing user information 7 | * @property user.id The user's ID 8 | * @property user.username The user's username 9 | * @property user.email The user's email 10 | * @property user.sub The user's subscription status 11 | */ 12 | export interface AuthenticatedRequest extends Request { 13 | user: { 14 | id: number; 15 | username: string; 16 | email: string; 17 | sub?: number; 18 | }; 19 | } 20 | -------------------------------------------------------------------------------- /backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "CommonJS", 4 | "target": "ES2021", 5 | "outDir": "dist", 6 | "rootDir": "src", 7 | "strict": true, 8 | "strictPropertyInitialization": false, 9 | // critical for NestJS decorators: 10 | "experimentalDecorators": true, 11 | "emitDecoratorMetadata": true, 12 | // optional but recommended: 13 | "esModuleInterop": true, 14 | "skipLibCheck": true, 15 | "typeRoots": ["node_modules/@types"] 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /backend/vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "builds": [ 4 | { 5 | "src": "dist/main.js", 6 | "use": "@vercel/node" 7 | } 8 | ], 9 | "routes": [ 10 | { 11 | "src": "/(.*)", 12 | "dest": "dist/main.js" 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /build-all.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | echo "Building backend..." 5 | cd backend 6 | npm install 7 | npm run build 8 | cd .. 9 | 10 | echo "Building frontend..." 11 | cd frontend 12 | npm install 13 | npm run build 14 | cd .. 15 | 16 | echo "Build completed for both backend and frontend." 17 | -------------------------------------------------------------------------------- /clean-all.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | echo "Cleaning backend..." 5 | cd backend 6 | rm -rf node_modules dist 7 | cd .. 8 | 9 | echo "Cleaning frontend..." 10 | cd frontend 11 | rm -rf node_modules dist 12 | cd .. 13 | 14 | echo "Cleanup completed for backend and frontend." 15 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | backend: 5 | build: 6 | context: . 7 | dockerfile: Dockerfile 8 | working_dir: /app/backend 9 | command: ["node", "dist/main.js"] 10 | ports: 11 | - "4000:4000" 12 | environment: 13 | NODE_ENV: production 14 | PORT: 4000 15 | JWT_SECRET: your_jwt_secret 16 | DATABASE_URL: your_database_url 17 | restart: unless-stopped 18 | 19 | frontend: 20 | build: 21 | context: . 22 | dockerfile: Dockerfile 23 | working_dir: /app/frontend 24 | ports: 25 | - "3000:80" 26 | restart: unless-stopped 27 | -------------------------------------------------------------------------------- /frontend/.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 | -------------------------------------------------------------------------------- /frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | # Stage 1: Build 2 | FROM node:18-alpine AS builder 3 | WORKDIR /app 4 | 5 | # Copy package.json and install dependencies 6 | COPY package*.json ./ 7 | RUN npm install 8 | 9 | # Copy source code and build the app 10 | COPY . . 11 | RUN npm run build 12 | 13 | # Stage 2: Serve 14 | FROM nginx:alpine 15 | WORKDIR /usr/share/nginx/html 16 | 17 | # Copy build output to nginx HTML directory 18 | COPY --from=builder /app/dist . 19 | 20 | # Expose the port and start the server 21 | EXPOSE 80 22 | CMD ["nginx", "-g", "daemon off;"] 23 | -------------------------------------------------------------------------------- /frontend/build-frontend.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # Step 1: Install dependencies 5 | echo "Installing frontend dependencies..." 6 | npm install 7 | 8 | # Step 2: Build the frontend 9 | echo "Building the frontend..." 10 | npm run build 11 | 12 | # Step 3: Start the frontend in preview mode 13 | echo "Starting the frontend preview server..." 14 | npm run preview 15 | -------------------------------------------------------------------------------- /frontend/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | frontend: 5 | build: 6 | context: . 7 | dockerfile: Dockerfile 8 | ports: 9 | - "3000:80" 10 | environment: 11 | REACT_APP_API_URL: http://localhost:4000 12 | restart: unless-stopped 13 | -------------------------------------------------------------------------------- /frontend/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 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | CollabNote - Take Notes Together 8 | 9 | 13 | 14 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 29 | 30 | 31 | 35 | 36 | 37 | 38 | 39 | 40 | 44 | 45 | 46 |
47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "private": false, 4 | "version": "1.0.0", 5 | "type": "module", 6 | "license": "MIT", 7 | "author": "Son Nguyen", 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/hoangsonww/CollabNote-Fullstack-App.git" 11 | }, 12 | "bugs": { 13 | "url": "https://github.com/hoangsonww/CollabNote-Fullstack-App/issues" 14 | }, 15 | "keywords": [ 16 | "react", 17 | "vite", 18 | "mui", 19 | "emotion", 20 | "react-slick", 21 | "collabnote", 22 | "frontend" 23 | ], 24 | "scripts": { 25 | "dev": "vite", 26 | "build": "tsc -b && vite build", 27 | "lint": "eslint .", 28 | "preview": "vite preview", 29 | "test": "npm run dev", 30 | "start": "vite" 31 | }, 32 | "dependencies": { 33 | "@emotion/react": "^11.14.0", 34 | "@emotion/styled": "^11.14.0", 35 | "@mui/icons-material": "^6.4.1", 36 | "@mui/material": "^6.4.1", 37 | "react": "^18.3.1", 38 | "react-dom": "^18.3.1", 39 | "react-router-dom": "^7.1.3", 40 | "react-slick": "^0.30.3", 41 | "slick-carousel": "^1.8.1" 42 | }, 43 | "devDependencies": { 44 | "@eslint/js": "^9.17.0", 45 | "@types/node": "^22.10.10", 46 | "@types/react": "^18.3.18", 47 | "@types/react-dom": "^18.3.5", 48 | "@vitejs/plugin-react": "^4.3.4", 49 | "eslint": "^9.17.0", 50 | "eslint-plugin-react-hooks": "^5.0.0", 51 | "eslint-plugin-react-refresh": "^0.4.16", 52 | "globals": "^15.14.0", 53 | "typescript": "~5.6.2", 54 | "typescript-eslint": "^8.18.2", 55 | "vite": "^6.0.5" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /frontend/public/OIP.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/frontend/public/OIP.jpg -------------------------------------------------------------------------------- /frontend/public/OIP10.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/frontend/public/OIP10.webp -------------------------------------------------------------------------------- /frontend/public/OIP11.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/frontend/public/OIP11.webp -------------------------------------------------------------------------------- /frontend/public/OIP12.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/frontend/public/OIP12.webp -------------------------------------------------------------------------------- /frontend/public/OIP13.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/frontend/public/OIP13.webp -------------------------------------------------------------------------------- /frontend/public/OIP14.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/frontend/public/OIP14.webp -------------------------------------------------------------------------------- /frontend/public/OIP15.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/frontend/public/OIP15.webp -------------------------------------------------------------------------------- /frontend/public/OIP16.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/frontend/public/OIP16.webp -------------------------------------------------------------------------------- /frontend/public/OIP17.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/frontend/public/OIP17.webp -------------------------------------------------------------------------------- /frontend/public/OIP18.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/frontend/public/OIP18.webp -------------------------------------------------------------------------------- /frontend/public/OIP19.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/frontend/public/OIP19.webp -------------------------------------------------------------------------------- /frontend/public/OIP2.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/frontend/public/OIP2.webp -------------------------------------------------------------------------------- /frontend/public/OIP20.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/frontend/public/OIP20.webp -------------------------------------------------------------------------------- /frontend/public/OIP3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/frontend/public/OIP3.png -------------------------------------------------------------------------------- /frontend/public/OIP4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/frontend/public/OIP4.png -------------------------------------------------------------------------------- /frontend/public/OIP5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/frontend/public/OIP5.png -------------------------------------------------------------------------------- /frontend/public/OIP6.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/frontend/public/OIP6.webp -------------------------------------------------------------------------------- /frontend/public/OIP7.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/frontend/public/OIP7.webp -------------------------------------------------------------------------------- /frontend/public/OIP8.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/frontend/public/OIP8.webp -------------------------------------------------------------------------------- /frontend/public/OIP9.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/frontend/public/OIP9.webp -------------------------------------------------------------------------------- /frontend/public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/frontend/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /frontend/public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/frontend/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /frontend/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/frontend/public/apple-touch-icon.png -------------------------------------------------------------------------------- /frontend/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/frontend/public/favicon-16x16.png -------------------------------------------------------------------------------- /frontend/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/frontend/public/favicon-32x32.png -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "CollabNote", 3 | "name": "CollabNote - Take Notes Together", 4 | "description": "A note-taking app that allows you to take notes together with your friends, family, and colleagues.", 5 | "icons": [ 6 | { 7 | "src": "android-chrome-192x192.png", 8 | "type": "image/png", 9 | "sizes": "192x192" 10 | }, 11 | { 12 | "src": "android-chrome-512x512.png", 13 | "type": "image/png", 14 | "sizes": "512x512" 15 | } 16 | ], 17 | "start_url": ".", 18 | "display": "standalone", 19 | "theme_color": "#00695c", 20 | "background_color": "#ffffff" 21 | } 22 | -------------------------------------------------------------------------------- /frontend/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/run-frontend-docker.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # Step 1: Build Docker image 5 | echo "Building Docker image for the frontend..." 6 | docker build -t collabnote-frontend . 7 | 8 | # Step 2: Run Docker container 9 | echo "Running Docker container for the frontend..." 10 | docker run -p 3000:3000 collabnote-frontend 11 | -------------------------------------------------------------------------------- /frontend/src/App.css: -------------------------------------------------------------------------------- 1 | #root { 2 | max-width: 1280px; 3 | margin: 0 auto; 4 | padding: 2rem; 5 | text-align: center; 6 | } 7 | 8 | .logo { 9 | height: 6em; 10 | padding: 1.5em; 11 | will-change: filter; 12 | transition: filter 300ms; 13 | } 14 | .logo:hover { 15 | filter: drop-shadow(0 0 2em #646cffaa); 16 | } 17 | .logo.react:hover { 18 | filter: drop-shadow(0 0 2em #61dafbaa); 19 | } 20 | 21 | @keyframes logo-spin { 22 | from { 23 | transform: rotate(0deg); 24 | } 25 | to { 26 | transform: rotate(360deg); 27 | } 28 | } 29 | 30 | @media (prefers-reduced-motion: no-preference) { 31 | a:nth-of-type(2) .logo { 32 | animation: logo-spin infinite 20s linear; 33 | } 34 | } 35 | 36 | .card { 37 | padding: 2em; 38 | } 39 | 40 | .read-the-docs { 41 | color: #888; 42 | } 43 | -------------------------------------------------------------------------------- /frontend/src/App.test.tsx: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | 3 | import React from "react"; 4 | import { render, screen, fireEvent, waitFor } from "@testing-library/react"; 5 | import { vi } from "vitest"; 6 | import NotesPage from "./NotesPage"; 7 | import "@testing-library/jest-dom"; 8 | import { BrowserRouter } from "react-router-dom"; 9 | 10 | // Mock fetch and localStorage 11 | const mockFetch = vi.fn(); 12 | const mockGetItem = vi.fn(); 13 | const mockSetItem = vi.fn(); 14 | 15 | global.fetch = mockFetch; 16 | global.localStorage = { 17 | getItem: mockGetItem, 18 | setItem: mockSetItem, 19 | removeItem: vi.fn(), 20 | } as any; 21 | 22 | // Wrap component with BrowserRouter for React Router usage 23 | const renderWithRouter = (component: React.ReactNode) => 24 | render({component}); 25 | 26 | describe("NotesPage Component", () => { 27 | beforeEach(() => { 28 | mockFetch.mockClear(); 29 | mockGetItem.mockClear(); 30 | mockSetItem.mockClear(); 31 | }); 32 | 33 | it("renders the NotesPage with Add Note button", () => { 34 | renderWithRouter(); 35 | expect(screen.getByText(/Add Note/i)).toBeInTheDocument(); 36 | }); 37 | 38 | it("opens and closes the Add Note dialog", async () => { 39 | renderWithRouter(); 40 | 41 | const addButton = screen.getByText(/Add Note/i); 42 | fireEvent.click(addButton); 43 | 44 | // Check if Add Note dialog appears 45 | expect(screen.getByText(/Add a New Note/i)).toBeInTheDocument(); 46 | 47 | // Close dialog 48 | const cancelButton = screen.getByText(/Cancel/i); 49 | fireEvent.click(cancelButton); 50 | 51 | await waitFor(() => { 52 | expect(screen.queryByText(/Add a New Note/i)).not.toBeInTheDocument(); 53 | }); 54 | }); 55 | 56 | it("opens the Note Details dialog when a note is clicked", async () => { 57 | mockFetch.mockResolvedValueOnce({ 58 | ok: true, 59 | json: () => 60 | Promise.resolve([ 61 | { 62 | id: 1, 63 | title: "Sample Note", 64 | content: "This is a sample note content.", 65 | tags: ["Work"], 66 | color: "#FFFFFF", 67 | due_date: "2025-12-31", 68 | pinned: false, 69 | shared_with_user_ids: [], 70 | sort_order: 0, 71 | user_id: 1, 72 | }, 73 | ]), 74 | }); 75 | 76 | renderWithRouter(); 77 | 78 | await waitFor(() => { 79 | expect(screen.getByText(/Sample Note/i)).toBeInTheDocument(); 80 | }); 81 | 82 | const note = screen.getByText(/Sample Note/i); 83 | fireEvent.click(note); 84 | 85 | // Check if Note Details dialog appears 86 | await waitFor(() => { 87 | expect(screen.getByText(/Note Details/i)).toBeInTheDocument(); 88 | expect(screen.getByText(/Title:/i)).toBeInTheDocument(); 89 | expect(screen.getByText(/Content:/i)).toBeInTheDocument(); 90 | }); 91 | 92 | const closeButton = screen.getByTitle(/Close/i); 93 | fireEvent.click(closeButton); 94 | 95 | await waitFor(() => { 96 | expect(screen.queryByText(/Note Details/i)).not.toBeInTheDocument(); 97 | }); 98 | }); 99 | 100 | it("deletes a selected note with confirmation dialog", async () => { 101 | mockFetch 102 | .mockResolvedValueOnce({ 103 | ok: true, 104 | json: () => 105 | Promise.resolve([ 106 | { 107 | id: 1, 108 | title: "Sample Note", 109 | content: "This is a sample note content.", 110 | tags: ["Work"], 111 | color: "#FFFFFF", 112 | due_date: "2025-12-31", 113 | pinned: false, 114 | shared_with_user_ids: [], 115 | sort_order: 0, 116 | user_id: 1, 117 | }, 118 | ]), 119 | }) 120 | .mockResolvedValueOnce({ 121 | ok: true, 122 | json: () => Promise.resolve(), 123 | }); 124 | 125 | renderWithRouter(); 126 | 127 | await waitFor(() => { 128 | expect(screen.getByText(/Sample Note/i)).toBeInTheDocument(); 129 | }); 130 | 131 | const deleteButton = screen.getByTitle(/Delete This Note/i); 132 | fireEvent.click(deleteButton); 133 | 134 | await waitFor(() => { 135 | expect(screen.getByText(/Confirm Deletion/i)).toBeInTheDocument(); 136 | }); 137 | 138 | const confirmDeleteButton = screen.getByText(/Delete/i); 139 | fireEvent.click(confirmDeleteButton); 140 | 141 | await waitFor(() => { 142 | expect(mockFetch).toHaveBeenCalledTimes(2); 143 | expect(screen.queryByText(/Sample Note/i)).not.toBeInTheDocument(); 144 | }); 145 | }); 146 | 147 | it("applies a tag filter and fetches notes", async () => { 148 | mockFetch.mockResolvedValueOnce({ 149 | ok: true, 150 | json: () => 151 | Promise.resolve([ 152 | { 153 | id: 1, 154 | title: "Filtered Note", 155 | content: "This note has the 'Work' tag.", 156 | tags: ["Work"], 157 | color: "#FFFFFF", 158 | due_date: null, 159 | pinned: false, 160 | shared_with_user_ids: [], 161 | sort_order: 0, 162 | user_id: 1, 163 | }, 164 | ]), 165 | }); 166 | 167 | renderWithRouter(); 168 | 169 | const filterDropdown = screen.getByLabelText(/Filter by Tag/i); 170 | fireEvent.mouseDown(filterDropdown); 171 | 172 | const workOption = screen.getByText(/Work/i); 173 | fireEvent.click(workOption); 174 | 175 | const applyFilterButton = screen.getByText(/Apply Filter/i); 176 | fireEvent.click(applyFilterButton); 177 | 178 | await waitFor(() => { 179 | expect(mockFetch).toHaveBeenCalled(); 180 | expect(screen.getByText(/Filtered Note/i)).toBeInTheDocument(); 181 | }); 182 | }); 183 | 184 | it("handles error when fetching notes", async () => { 185 | mockFetch.mockRejectedValueOnce(new Error("Failed to fetch notes")); 186 | 187 | renderWithRouter(); 188 | 189 | await waitFor(() => { 190 | expect(screen.getByText(/Good/i)).toBeInTheDocument(); 191 | }); 192 | 193 | expect(mockFetch).toHaveBeenCalled(); 194 | }); 195 | }); 196 | -------------------------------------------------------------------------------- /frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { BrowserRouter, Routes, Route } from "react-router-dom"; 2 | import Layout from "./layout/Layout"; 3 | import HomePage from "./routes/HomePage"; 4 | import LoginPage from "./routes/LoginPage"; 5 | import RegisterPage from "./routes/RegisterPage"; 6 | import ForgotPasswordPage from "./routes/ForgotPasswordPage"; 7 | import NotesPage from "./routes/NotesPage"; 8 | import ProfilePage from "./routes/ProfilePage"; 9 | import NotFoundPage from "./routes/NotFoundPage.tsx"; 10 | 11 | export default function App() { 12 | return ( 13 | 14 | 15 | 16 | } /> 17 | } /> 18 | } /> 19 | } /> 20 | } /> 21 | } /> 22 | } /> 23 | 24 | 25 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /frontend/src/assets/OIP.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/frontend/src/assets/OIP.jpg -------------------------------------------------------------------------------- /frontend/src/assets/OIP10.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/frontend/src/assets/OIP10.webp -------------------------------------------------------------------------------- /frontend/src/assets/OIP11.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/frontend/src/assets/OIP11.webp -------------------------------------------------------------------------------- /frontend/src/assets/OIP12.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/frontend/src/assets/OIP12.webp -------------------------------------------------------------------------------- /frontend/src/assets/OIP13.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/frontend/src/assets/OIP13.webp -------------------------------------------------------------------------------- /frontend/src/assets/OIP14.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/frontend/src/assets/OIP14.webp -------------------------------------------------------------------------------- /frontend/src/assets/OIP15.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/frontend/src/assets/OIP15.webp -------------------------------------------------------------------------------- /frontend/src/assets/OIP16.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/frontend/src/assets/OIP16.webp -------------------------------------------------------------------------------- /frontend/src/assets/OIP17.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/frontend/src/assets/OIP17.webp -------------------------------------------------------------------------------- /frontend/src/assets/OIP18.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/frontend/src/assets/OIP18.webp -------------------------------------------------------------------------------- /frontend/src/assets/OIP19.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/frontend/src/assets/OIP19.webp -------------------------------------------------------------------------------- /frontend/src/assets/OIP2.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/frontend/src/assets/OIP2.webp -------------------------------------------------------------------------------- /frontend/src/assets/OIP20.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/frontend/src/assets/OIP20.webp -------------------------------------------------------------------------------- /frontend/src/assets/OIP3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/frontend/src/assets/OIP3.png -------------------------------------------------------------------------------- /frontend/src/assets/OIP4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/frontend/src/assets/OIP4.png -------------------------------------------------------------------------------- /frontend/src/assets/OIP5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/frontend/src/assets/OIP5.png -------------------------------------------------------------------------------- /frontend/src/assets/OIP6.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/frontend/src/assets/OIP6.webp -------------------------------------------------------------------------------- /frontend/src/assets/OIP7.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/frontend/src/assets/OIP7.webp -------------------------------------------------------------------------------- /frontend/src/assets/OIP8.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/frontend/src/assets/OIP8.webp -------------------------------------------------------------------------------- /frontend/src/assets/OIP9.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/frontend/src/assets/OIP9.webp -------------------------------------------------------------------------------- /frontend/src/assets/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/frontend/src/assets/android-chrome-192x192.png -------------------------------------------------------------------------------- /frontend/src/assets/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/frontend/src/assets/android-chrome-512x512.png -------------------------------------------------------------------------------- /frontend/src/assets/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/frontend/src/assets/apple-touch-icon.png -------------------------------------------------------------------------------- /frontend/src/assets/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/frontend/src/assets/favicon-16x16.png -------------------------------------------------------------------------------- /frontend/src/assets/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/frontend/src/assets/favicon-32x32.png -------------------------------------------------------------------------------- /frontend/src/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/frontend/src/assets/favicon.ico -------------------------------------------------------------------------------- /frontend/src/assets/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "CollabNote", 3 | "name": "CollabNote - Take Notes Together", 4 | "description": "A note-taking app that allows you to take notes together with your friends, family, and colleagues.", 5 | "icons": [ 6 | { 7 | "src": "android-chrome-192x192.png", 8 | "type": "image/png", 9 | "sizes": "192x192" 10 | }, 11 | { 12 | "src": "android-chrome-512x512.png", 13 | "type": "image/png", 14 | "sizes": "512x512" 15 | } 16 | ], 17 | "start_url": ".", 18 | "display": "standalone", 19 | "theme_color": "#00695c", 20 | "background_color": "#ffffff" 21 | } 22 | -------------------------------------------------------------------------------- /frontend/src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/assets/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/components/LoadingOverlay.tsx: -------------------------------------------------------------------------------- 1 | import { Box, CircularProgress } from "@mui/material"; 2 | 3 | type LoadingOverlayProps = { 4 | loading: boolean; 5 | }; 6 | 7 | export default function LoadingOverlay({ loading }: LoadingOverlayProps) { 8 | if (!loading) return null; 9 | return ( 10 | 24 | 25 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /frontend/src/components/PasswordField.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { TextField, IconButton, InputAdornment } from "@mui/material"; 3 | import { Visibility, VisibilityOff } from "@mui/icons-material"; 4 | 5 | type PasswordFieldProps = { 6 | label: string; 7 | value: string; 8 | onChange: (val: string) => void; 9 | }; 10 | 11 | export default function PasswordField({ 12 | label, 13 | value, 14 | onChange, 15 | }: PasswordFieldProps) { 16 | const [showPassword, setShowPassword] = useState(false); 17 | 18 | return ( 19 | onChange(e.target.value)} 25 | InputProps={{ 26 | endAdornment: ( 27 | 28 | setShowPassword(!showPassword)} 30 | edge="end" 31 | > 32 | {showPassword ? : } 33 | 34 | 35 | ), 36 | }} 37 | /> 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /frontend/src/global.css: -------------------------------------------------------------------------------- 1 | @import url("https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600&display=swap"); 2 | 3 | html, 4 | body, 5 | #root { 6 | margin: 0; 7 | padding: 0; 8 | height: 100%; 9 | font-family: "Poppins", sans-serif; 10 | background-color: #f5f5f5; 11 | } 12 | a { 13 | text-decoration: none; 14 | color: inherit; 15 | } 16 | button, 17 | input, 18 | textarea { 19 | font-family: inherit; 20 | } 21 | body { 22 | transition: 23 | background-color 0.3s ease, 24 | color 0.3s ease; 25 | } 26 | @keyframes fadeIn { 27 | from { 28 | opacity: 0; 29 | } 30 | to { 31 | opacity: 1; 32 | } 33 | } 34 | 35 | @keyframes slideDown { 36 | from { 37 | transform: translateY(-30px); 38 | opacity: 0; 39 | } 40 | to { 41 | transform: translateY(0); 42 | opacity: 1; 43 | } 44 | } 45 | 46 | @keyframes slideUp { 47 | from { 48 | transform: translateY(30px); 49 | opacity: 0; 50 | } 51 | to { 52 | transform: translateY(0); 53 | opacity: 1; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /frontend/src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; 3 | line-height: 1.5; 4 | font-weight: 400; 5 | 6 | color-scheme: light dark; 7 | color: rgba(255, 255, 255, 0.87); 8 | background-color: #242424; 9 | 10 | font-synthesis: none; 11 | text-rendering: optimizeLegibility; 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | } 15 | 16 | a { 17 | font-weight: 500; 18 | color: #646cff; 19 | text-decoration: inherit; 20 | } 21 | a:hover { 22 | color: #535bf2; 23 | } 24 | 25 | body { 26 | margin: 0; 27 | display: flex; 28 | place-items: center; 29 | min-width: 320px; 30 | min-height: 100vh; 31 | } 32 | 33 | h1 { 34 | font-size: 3.2em; 35 | line-height: 1.1; 36 | } 37 | 38 | button { 39 | border-radius: 8px; 40 | border: 1px solid transparent; 41 | padding: 0.6em 1.2em; 42 | font-size: 1em; 43 | font-weight: 500; 44 | font-family: inherit; 45 | background-color: #1a1a1a; 46 | cursor: pointer; 47 | transition: border-color 0.25s; 48 | } 49 | button:hover { 50 | border-color: #646cff; 51 | } 52 | button:focus, 53 | button:focus-visible { 54 | outline: 4px auto -webkit-focus-ring-color; 55 | } 56 | 57 | @media (prefers-color-scheme: light) { 58 | :root { 59 | color: #213547; 60 | background-color: #ffffff; 61 | } 62 | a:hover { 63 | color: #747bff; 64 | } 65 | button { 66 | background-color: #f9f9f9; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /frontend/src/layout/Footer.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Typography, IconButton, Link } from "@mui/material"; 2 | import { GitHub, Language, LinkedIn, Mail } from "@mui/icons-material"; 3 | import { useLocation, useNavigate } from "react-router-dom"; 4 | import { useEffect, useState } from "react"; 5 | 6 | export default function Footer() { 7 | const location = useLocation(); 8 | const [token, setToken] = useState( 9 | localStorage.getItem("access_token"), 10 | ); 11 | // @ts-ignore 12 | const [invalidToken, setInvalidToken] = useState(false); 13 | const isActive = (path: string) => location.pathname === path; 14 | const navigate = useNavigate(); 15 | const isLoggedIn = !!token && !invalidToken; 16 | 17 | const onLogout = () => { 18 | localStorage.removeItem("access_token"); 19 | setToken(null); 20 | navigate("/login"); 21 | }; 22 | 23 | useEffect(() => { 24 | const interval = setInterval(() => { 25 | const storedToken = localStorage.getItem("access_token"); 26 | if (storedToken !== token) { 27 | setToken(storedToken); 28 | } 29 | }, 1000); 30 | 31 | return () => clearInterval(interval); // Cleanup interval on unmount 32 | }, [token]); 33 | 34 | return ( 35 | 47 | {/* Social Media Icons */} 48 | 57 | 68 | 69 | 70 | 81 | 82 | 83 | 94 | 95 | 96 | 107 | 108 | 109 | 110 | 111 | {/* Navigation Links */} 112 | 123 | 137 | Home 138 | 139 | 153 | Notes 154 | 155 | 169 | Profile 170 | 171 | {isLoggedIn && ( 172 | 186 | Logout 187 | 188 | )} 189 | {!isLoggedIn && ( 190 | <> 191 | 205 | Login 206 | 207 | 208 | )} 209 | 223 | Register 224 | 225 | 226 | 227 | {/* Footer Divider */} 228 |
236 | 237 | {/* Footer Text */} 238 | 246 | © {new Date().getFullYear()} CollabNote. All rights reserved. 247 | 248 |
249 | ); 250 | } 251 | -------------------------------------------------------------------------------- /frontend/src/layout/Layout.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Box } from "@mui/material"; 3 | import Navbar from "./Navbar"; 4 | import Footer from "./Footer"; 5 | 6 | export default function Layout({ children }: { children: React.ReactNode }) { 7 | return ( 8 | 17 | 18 | 19 | {children} 20 | 21 |