├── .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 |
5 |
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 |
22 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/frontend/src/layout/Navbar.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from "react";
2 | import {
3 | AppBar,
4 | Toolbar,
5 | Typography,
6 | IconButton,
7 | Box,
8 | Button,
9 | } from "@mui/material";
10 | import { Menu as MenuIcon, LightMode, DarkMode } from "@mui/icons-material";
11 | import { useNavigate, useLocation } from "react-router-dom";
12 | import { useThemeContext } from "../theme/ThemeContext";
13 | import ResponsiveDrawer from "./ResponsiveDrawer";
14 |
15 | export default function Navbar() {
16 | const { isDarkMode, toggleTheme } = useThemeContext();
17 | const [drawerOpen, setDrawerOpen] = useState(false);
18 | const [token, setToken] = useState(
19 | localStorage.getItem("access_token"),
20 | );
21 | const [invalidToken, setInvalidToken] = useState(false);
22 | const navigate = useNavigate();
23 | const location = useLocation();
24 |
25 | const isLoggedIn = !!token && !invalidToken;
26 |
27 | const onLogout = () => {
28 | localStorage.removeItem("access_token");
29 | setToken(null);
30 | navigate("/login");
31 | };
32 |
33 | const isActive = (path: string) => location.pathname === path;
34 |
35 | // Poll localStorage every 500ms to update token state.
36 | // When no token is present, mimic the "token isn't present" logic.
37 | useEffect(() => {
38 | const interval = setInterval(() => {
39 | const storedToken = localStorage.getItem("access_token");
40 | if (storedToken !== token) {
41 | setToken(storedToken);
42 | // When no token is present, ensure invalidToken is false
43 | if (!storedToken) {
44 | setInvalidToken(false);
45 | }
46 | }
47 | }, 500);
48 | return () => clearInterval(interval);
49 | }, [token]);
50 |
51 | // Check token validity every 500ms.
52 | // When the token is missing (as in the "token isn't present" logic),
53 | // simply set token to null without stopping the polling.
54 | useEffect(() => {
55 | const interval = setInterval(async () => {
56 | const t = localStorage.getItem("access_token");
57 | if (!t) {
58 | setToken(null);
59 | // Do not clear the interval; keep polling for a potential token.
60 | return;
61 | }
62 | try {
63 | const resp = await fetch(
64 | "https://collabnote-fullstack-app.onrender.com/profile/me",
65 | {
66 | headers: {
67 | Authorization: `Bearer ${t}`,
68 | },
69 | },
70 | );
71 | if (resp.status === 401) {
72 | localStorage.removeItem("access_token");
73 | setToken(null);
74 | setInvalidToken(true);
75 | // Continue polling so that if a valid token is set later, it is picked up.
76 | }
77 | } catch {
78 | localStorage.removeItem("access_token");
79 | setToken(null);
80 | setInvalidToken(true);
81 | // Continue polling
82 | }
83 | }, 500);
84 | return () => clearInterval(interval);
85 | }, []);
86 |
87 | return (
88 | <>
89 |
90 |
91 | setDrawerOpen(true)}
95 | sx={{ mr: 2, display: { sm: "none" } }}
96 | >
97 |
98 |
99 | navigate("/")}
103 | >
104 | CollabNote
105 |
106 |
113 |
124 |
135 |
146 | {!isLoggedIn && (
147 | <>
148 |
161 |
174 | >
175 | )}
176 | {isLoggedIn && (
177 |
180 | )}
181 |
182 | {isDarkMode ? : }
183 |
184 |
185 |
186 |
187 | setDrawerOpen(false)}
190 | isLoggedIn={isLoggedIn}
191 | onLogout={onLogout}
192 | toggleTheme={toggleTheme}
193 | isDarkMode={isDarkMode}
194 | />
195 | >
196 | );
197 | }
198 |
--------------------------------------------------------------------------------
/frontend/src/layout/ResponsiveDrawer.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Drawer,
3 | List,
4 | ListItemButton,
5 | ListItemText,
6 | IconButton,
7 | Box,
8 | Toolbar,
9 | Typography,
10 | Switch,
11 | FormControlLabel,
12 | } from "@mui/material";
13 | import { Close } from "@mui/icons-material";
14 | import { useNavigate, useLocation } from "react-router-dom";
15 |
16 | export default function ResponsiveDrawer({
17 | open,
18 | onClose,
19 | isLoggedIn,
20 | onLogout,
21 | toggleTheme,
22 | isDarkMode,
23 | }: {
24 | open: boolean;
25 | onClose: () => void;
26 | isLoggedIn: boolean;
27 | onLogout: () => void;
28 | toggleTheme: () => void;
29 | isDarkMode: boolean;
30 | }) {
31 | const navigate = useNavigate();
32 | const location = useLocation();
33 |
34 | const isActive = (path: string) => location.pathname === path;
35 |
36 | return (
37 |
38 |
39 |
47 |
48 | CollabNote
49 |
50 |
51 |
52 |
53 |
54 |
55 | {
57 | navigate("/");
58 | onClose();
59 | }}
60 | sx={{
61 | fontWeight: isActive("/") ? "bold" : "normal",
62 | color: isActive("/") ? "white" : "text.primary",
63 | backgroundColor: isActive("/") ? "#00695c" : "transparent",
64 | "&:hover": {
65 | backgroundColor: "primary.light",
66 | },
67 | }}
68 | >
69 |
70 |
71 | {
73 | navigate("/notes");
74 | onClose();
75 | }}
76 | sx={{
77 | fontWeight: isActive("/notes") ? "bold" : "normal",
78 | color: isActive("/notes") ? "white" : "text.primary",
79 | backgroundColor: isActive("/notes") ? "#00695c" : "transparent",
80 | "&:hover": {
81 | backgroundColor: "primary.light",
82 | },
83 | }}
84 | >
85 |
86 |
87 | {
89 | navigate("/profile");
90 | onClose();
91 | }}
92 | sx={{
93 | fontWeight: isActive("/profile") ? "bold" : "normal",
94 | color: isActive("/profile") ? "white" : "text.primary",
95 | backgroundColor: isActive("/profile") ? "#00695c" : "transparent",
96 | "&:hover": {
97 | backgroundColor: "primary.light",
98 | },
99 | }}
100 | >
101 |
102 |
103 | {isLoggedIn ? (
104 | <>
105 | {
107 | onLogout();
108 | onClose();
109 | }}
110 | >
111 |
112 |
113 | >
114 | ) : (
115 | <>
116 | {
118 | navigate("/login");
119 | onClose();
120 | }}
121 | sx={{
122 | fontWeight: isActive("/login") ? "bold" : "normal",
123 | color: isActive("/login") ? "white" : "text.primary",
124 | backgroundColor: isActive("/login")
125 | ? "#00695c"
126 | : "transparent",
127 | "&:hover": {
128 | backgroundColor: "primary.light",
129 | },
130 | }}
131 | >
132 |
133 |
134 | {
136 | navigate("/register");
137 | onClose();
138 | }}
139 | sx={{
140 | fontWeight: isActive("/register") ? "bold" : "normal",
141 | color: isActive("/register") ? "white" : "text.primary",
142 | backgroundColor: isActive("/register")
143 | ? "#00695c"
144 | : "transparent",
145 | "&:hover": {
146 | backgroundColor: "primary.light",
147 | },
148 | }}
149 | >
150 |
151 |
152 | >
153 | )}
154 |
157 |
158 | {
163 | toggleTheme();
164 | }}
165 | />
166 | }
167 | label={isDarkMode ? "Dark Mode" : "Light Mode"}
168 | sx={{ ml: 1 }}
169 | />
170 |
171 |
172 |
173 |
174 | );
175 | }
176 |
--------------------------------------------------------------------------------
/frontend/src/main.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom/client";
3 | import App from "./App";
4 | import ThemeProviderWrapper from "./theme/ThemeProviderWrapper";
5 | import "./global.css";
6 |
7 | ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
8 |
9 |
10 |
11 |
12 | ,
13 | );
14 |
--------------------------------------------------------------------------------
/frontend/src/routes/ForgotPasswordPage.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import {
3 | Container,
4 | Paper,
5 | Box,
6 | Typography,
7 | Button,
8 | TextField,
9 | IconButton,
10 | InputAdornment,
11 | } from "@mui/material";
12 | import { Link } from "react-router-dom";
13 | import { Visibility, VisibilityOff } from "@mui/icons-material";
14 | import LoadingOverlay from "../components/LoadingOverlay";
15 |
16 | export default function ForgotPasswordPage() {
17 | const [email, setEmail] = useState("");
18 | const [emailExists, setEmailExists] = useState(null);
19 | const [newPass, setNewPass] = useState("");
20 | const [confirmPass, setConfirmPass] = useState("");
21 | const [loading, setLoading] = useState(false);
22 | const [showNewPass, setShowNewPass] = useState(false);
23 | const [showConfirmPass, setShowConfirmPass] = useState(false);
24 |
25 | const checkEmail = async () => {
26 | setLoading(true);
27 | try {
28 | const res = await fetch(
29 | "https://collabnote-fullstack-app.onrender.com/auth/check-email-exists",
30 | {
31 | method: "POST",
32 | headers: { "Content-Type": "application/json" },
33 | body: JSON.stringify({ email }),
34 | },
35 | );
36 | if (!res.ok) throw new Error("Error checking email");
37 | const data = await res.json();
38 | setEmailExists(data.exists);
39 | if (!data.exists) {
40 | alert("Email not found");
41 | }
42 | } catch (err) {
43 | alert(err);
44 | } finally {
45 | setLoading(false);
46 | }
47 | };
48 |
49 | const resetPassword = async () => {
50 | if (!emailExists) {
51 | alert("No valid email to reset");
52 | return;
53 | }
54 | if (newPass !== confirmPass) {
55 | alert("Passwords do not match");
56 | return;
57 | }
58 | setLoading(true);
59 | try {
60 | const res = await fetch(
61 | "https://collabnote-fullstack-app.onrender.com/auth/reset-password",
62 | {
63 | method: "POST",
64 | headers: { "Content-Type": "application/json" },
65 | body: JSON.stringify({
66 | email,
67 | newPassword: newPass,
68 | confirmPassword: confirmPass,
69 | }),
70 | },
71 | );
72 | if (!res.ok) throw new Error("Error resetting password");
73 | alert(
74 | "Password reset successful! You can now login with your new password.",
75 | );
76 | } catch (err) {
77 | alert(
78 | err +
79 | " - Please check your email and password. It may also be that this Supabase project is paused. If you encounter this issue again, please contact the project owner for assistance.",
80 | );
81 | } finally {
82 | setLoading(false);
83 | }
84 | };
85 |
86 | return (
87 | <>
88 |
89 |
90 |
91 |
92 | Forgot Password
93 |
94 |
95 | Enter your email to reset your password.
96 |
97 |
98 | setEmail(e.target.value)}
103 | disabled={emailExists === true}
104 | onKeyPress={(e) => e.key === "Enter" && checkEmail()}
105 | />
106 | {emailExists === null && (
107 |
110 | )}
111 | {emailExists === true && (
112 | <>
113 |
117 | Email found! Enter your new password. Remember to keep it
118 | safe.
119 |
120 | setNewPass(e.target.value)}
126 | onKeyPress={(e) => e.key === "Enter" && resetPassword()}
127 | InputProps={{
128 | endAdornment: (
129 |
130 | setShowNewPass(!showNewPass)}
132 | edge="end"
133 | >
134 | {showNewPass ? : }
135 |
136 |
137 | ),
138 | }}
139 | />
140 | setConfirmPass(e.target.value)}
146 | onKeyPress={(e) => e.key === "Enter" && resetPassword()}
147 | InputProps={{
148 | endAdornment: (
149 |
150 | setShowConfirmPass(!showConfirmPass)}
152 | edge="end"
153 | >
154 | {showConfirmPass ? : }
155 |
156 |
157 | ),
158 | }}
159 | />
160 |
163 | >
164 | )}
165 |
166 | Remembered your password?{" "}
167 |
175 | Login Here
176 |
177 |
178 |
179 |
180 |
181 | >
182 | );
183 | }
184 |
--------------------------------------------------------------------------------
/frontend/src/routes/HomePage.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Box,
3 | Typography,
4 | Button,
5 | Container,
6 | Grid,
7 | Card,
8 | CardContent,
9 | CardActions,
10 | } from "@mui/material";
11 | import { Link } from "react-router-dom";
12 | import { useTheme } from "@mui/material/styles";
13 | import { keyframes } from "@emotion/react";
14 |
15 | const slideUp = keyframes`
16 | from {
17 | transform: translateY(30px);
18 | opacity: 0;
19 | }
20 | to {
21 | transform: translateY(0);
22 | opacity: 1;
23 | }
24 | `;
25 |
26 | const LandingPage = () => {
27 | const theme = useTheme();
28 | // @ts-ignore
29 | const isDarkMode = theme.palette.mode === "dark";
30 |
31 | const features = [
32 | {
33 | title: "Collaborative Editing",
34 | description:
35 | "Work together in real-time with team members and colleagues.",
36 | buttonText: "Start Editing",
37 | link: "/notes",
38 | },
39 | {
40 | title: "Cloud Sync",
41 | description: "Access your notes anytime, anywhere, on any device.",
42 | buttonText: "Learn More",
43 | link: "/notes",
44 | },
45 | {
46 | title: "Organized Workspace",
47 | description:
48 | "Keep your notes structured with folders, tags, and categories.",
49 | buttonText: "Explore Features",
50 | link: "/notes",
51 | },
52 | {
53 | title: "Secure Sharing",
54 | description:
55 | "Share notes securely with access controls and permission settings.",
56 | buttonText: "Share Notes",
57 | link: "/notes",
58 | },
59 | {
60 | title: "Powerful Search",
61 | description:
62 | "Find notes quickly with advanced search and filter options.",
63 | buttonText: "Search Notes",
64 | link: "/notes",
65 | },
66 | {
67 | title: "Dark Mode Support",
68 | description: "Work comfortably with an interface tailored to your needs.",
69 | buttonText: "Try Dark Mode",
70 | link: "/notes",
71 | },
72 | ];
73 |
74 | return (
75 |
83 | {/* Hero Section */}
84 |
97 |
101 | Welcome to CollabNote
102 |
103 |
104 | The ultimate tool for organizing, sharing, and collaborating on your
105 | notes with ease. Revolutionize your workflow today.
106 |
107 |
123 |
124 |
125 | {/* Features Section */}
126 |
127 |
128 | {features.map((feature, index) => (
129 |
140 |
154 |
155 |
163 | {feature.title}
164 |
165 |
166 | {feature.description}
167 |
168 |
169 |
172 |
187 |
188 |
189 |
190 | ))}
191 |
192 |
193 |
194 | {/* Call-to-Action Section */}
195 |
206 |
214 | Ready to Elevate Your Note-Taking Experience?
215 |
216 |
220 | Join thousands of users and start collaborating effectively today.
221 |
222 |
237 |
238 |
239 | );
240 | };
241 |
242 | export default LandingPage;
243 |
--------------------------------------------------------------------------------
/frontend/src/routes/LoginPage.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import {
3 | Container,
4 | Paper,
5 | Box,
6 | Typography,
7 | Button,
8 | TextField,
9 | IconButton,
10 | InputAdornment,
11 | } from "@mui/material";
12 | import { Visibility, VisibilityOff } from "@mui/icons-material";
13 | import { useNavigate, Link } from "react-router-dom";
14 | import LoadingOverlay from "../components/LoadingOverlay";
15 |
16 | export default function LoginPage() {
17 | const [email, setEmail] = useState("");
18 | const [password, setPassword] = useState("");
19 | const [showPassword, setShowPassword] = useState(false);
20 | const [loading, setLoading] = useState(false);
21 | const navigate = useNavigate();
22 |
23 | const handleLogin = async () => {
24 | setLoading(true);
25 | try {
26 | const response = await fetch(
27 | "https://collabnote-fullstack-app.onrender.com/auth/login",
28 | {
29 | method: "POST",
30 | headers: { "Content-Type": "application/json" },
31 | body: JSON.stringify({ email, password }),
32 | },
33 | );
34 |
35 | console.log(response);
36 | if (!response.ok) throw new Error("Login failed");
37 | const data = await response.json();
38 | localStorage.setItem("access_token", data.access_token);
39 | navigate("/notes");
40 | } catch (err) {
41 | alert(
42 | err +
43 | " - Please check your email and password. It may also be that this Supabase project is paused. If you encounter this issue again, please contact the project owner for assistance.",
44 | );
45 | } finally {
46 | setLoading(false);
47 | }
48 | };
49 |
50 | return (
51 | <>
52 |
53 |
54 |
62 |
63 | Login
64 |
65 |
66 | Login to your account to create & access your notes.
67 |
68 |
69 | setEmail(e.target.value)}
75 | onKeyPress={(e) => e.key === "Enter" && handleLogin()}
76 | />
77 | setPassword(e.target.value)}
83 | onKeyPress={(e) => e.key === "Enter" && handleLogin()}
84 | InputProps={{
85 | endAdornment: (
86 |
87 | setShowPassword(!showPassword)}
89 | edge="end"
90 | >
91 | {showPassword ? : }
92 |
93 |
94 | ),
95 | }}
96 | />
97 |
104 |
105 |
106 | Forgot your password?{" "}
107 |
115 | Reset Password
116 |
117 |
118 |
119 |
120 | >
121 | );
122 | }
123 |
--------------------------------------------------------------------------------
/frontend/src/routes/NotFoundPage.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Typography, Button } from "@mui/material";
2 | import { useNavigate } from "react-router-dom";
3 |
4 | export default function NotFoundPage() {
5 | const navigate = useNavigate();
6 |
7 | return (
8 |
22 |
32 | 404
33 |
34 |
42 | Oops! The page you're looking for doesn't exist.
43 |
44 |
61 |
62 | );
63 | }
64 |
--------------------------------------------------------------------------------
/frontend/src/routes/ProfilePage.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, useRef } from "react";
2 | import {
3 | Box,
4 | Paper,
5 | Typography,
6 | TextField,
7 | Button,
8 | IconButton,
9 | CircularProgress,
10 | } from "@mui/material";
11 | import { Edit as EditIcon, Search as SearchIcon } from "@mui/icons-material";
12 | import LoadingOverlay from "../components/LoadingOverlay";
13 |
14 | const avatarImages = [
15 | "/OIP.jpg",
16 | "/OIP2.webp",
17 | "/OIP3.png",
18 | "/OIP4.png",
19 | "/OIP5.png",
20 | "/OIP6.webp",
21 | "/OIP7.webp",
22 | "/OIP8.webp",
23 | "/OIP9.webp",
24 | "/OIP10.webp",
25 | "/OIP11.webp",
26 | "/OIP12.webp",
27 | "/OIP13.webp",
28 | "/OIP14.webp",
29 | "/OIP15.webp",
30 | "/OIP16.webp",
31 | "/OIP17.webp",
32 | "/OIP18.webp",
33 | "/OIP19.webp",
34 | "/OIP20.webp",
35 | ];
36 |
37 | export default function ProfilePage() {
38 | const [email, setEmail] = useState("");
39 | const [username, setUsername] = useState("");
40 | const [isEditingEmail, setIsEditingEmail] = useState(false);
41 | const [newEmail, setNewEmail] = useState("");
42 | const [daysSinceJoined, setDaysSinceJoined] = useState<
43 | number | string | null
44 | >(null);
45 | const [loading, setLoading] = useState(true);
46 | const [updatingEmail, setUpdatingEmail] = useState(false);
47 | const [joinedDate, setJoinedDate] = useState("");
48 | const [error, setError] = useState("");
49 | const [randomAvatar, setRandomAvatar] = useState("");
50 | // @ts-ignore
51 | const [userData, setUserData] = useState(null);
52 | const [searchQuery, setSearchQuery] = useState("");
53 | const [searchResults, setSearchResults] = useState([]);
54 | const [searchLoading, setSearchLoading] = useState(false);
55 | const [noResults, setNoResults] = useState(false);
56 |
57 | const userToken = localStorage.getItem("access_token");
58 | // @ts-ignore
59 | const debounceTimeoutRef = useRef(null);
60 |
61 | const [notesCount, setNotesCount] = useState(null);
62 |
63 | const fetchNotesCount = async () => {
64 | try {
65 | const res = await fetch(
66 | "https://collabnote-fullstack-app.onrender.com/notes",
67 | {
68 | headers: { Authorization: `Bearer ${userToken}` },
69 | },
70 | );
71 | if (!res.ok) throw new Error("Failed to fetch notes count");
72 | const data = await res.json();
73 | setNotesCount(data.length); // Assuming the response is an array of notes
74 | } catch (err) {
75 | console.error(err);
76 | setNotesCount(0); // Default to 0 if there's an error
77 | }
78 | };
79 |
80 | const fetchMyProfile = async () => {
81 | try {
82 | const res = await fetch(
83 | "https://collabnote-fullstack-app.onrender.com/profile/me",
84 | {
85 | headers: { Authorization: `Bearer ${userToken}` },
86 | },
87 | );
88 | if (!res.ok) throw new Error("Failed to fetch profile");
89 | const data = await res.json();
90 | setUserData(data);
91 | if (data.created_at) {
92 | const joined = new Date(data.created_at);
93 | const now = new Date();
94 | const diffTime = Math.abs(now.getTime() - joined.getTime());
95 | const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
96 | setDaysSinceJoined(diffDays);
97 | setJoinedDate(joined.toLocaleDateString());
98 | } else {
99 | setDaysSinceJoined("N/A");
100 | setJoinedDate("N/A");
101 | }
102 | setEmail(data.email || "N/A");
103 | setUsername(data.username || "N/A");
104 | } catch (err) {
105 | console.error(err);
106 | }
107 | };
108 |
109 | const handleUpdateEmail = async () => {
110 | setUpdatingEmail(true);
111 | setError("");
112 | try {
113 | const res = await fetch(
114 | "https://collabnote-fullstack-app.onrender.com/profile/email",
115 | {
116 | method: "PUT",
117 | headers: {
118 | "Content-Type": "application/json",
119 | Authorization: `Bearer ${userToken}`,
120 | },
121 | body: JSON.stringify({ newEmail }),
122 | },
123 | );
124 | if (!res.ok) throw new Error("Failed to update email");
125 | setEmail(newEmail);
126 | setIsEditingEmail(false);
127 | } catch (err) {
128 | setError("Failed to update email. Please try again.");
129 | } finally {
130 | setUpdatingEmail(false);
131 | }
132 | };
133 |
134 | const fetchSearchResults = async (query: string) => {
135 | if (!query) {
136 | setSearchResults([]);
137 | setNoResults(false);
138 | return;
139 | }
140 |
141 | setSearchLoading(true);
142 | setNoResults(false);
143 |
144 | try {
145 | const res = await fetch(
146 | `https://collabnote-fullstack-app.onrender.com/profile/search?username=${query}`,
147 | {
148 | headers: { Authorization: `Bearer ${userToken}` },
149 | },
150 | );
151 | if (!res.ok) throw new Error("Failed to fetch search results");
152 | const data = await res.json();
153 | setSearchResults(data);
154 | setNoResults(data.length === 0);
155 | } catch (err) {
156 | console.error(err);
157 | setNoResults(true);
158 | } finally {
159 | setSearchLoading(false);
160 | }
161 | };
162 |
163 | const handleSearchChange = (value: string) => {
164 | setSearchQuery(value);
165 |
166 | if (debounceTimeoutRef.current) {
167 | clearTimeout(debounceTimeoutRef.current);
168 | }
169 |
170 | debounceTimeoutRef.current = setTimeout(() => {
171 | fetchSearchResults(value);
172 | }, 300);
173 | };
174 |
175 | useEffect(() => {
176 | setRandomAvatar(
177 | avatarImages[Math.floor(Math.random() * avatarImages.length)],
178 | );
179 | if (!userToken) {
180 | setLoading(false);
181 | return;
182 | }
183 | fetchMyProfile().then(() => setLoading(false));
184 | fetchNotesCount().then(() => setLoading(false));
185 | }, []);
186 |
187 | if (loading) {
188 | return ;
189 | }
190 |
191 | if (!userToken) {
192 | return (
193 |
204 |
205 | You are not signed in. Please{" "}
206 |
214 | log in
215 | {" "}
216 | to view your profile.
217 |
218 |
219 | );
220 | }
221 |
222 | const today = new Date().toLocaleDateString();
223 |
224 | return (
225 |
237 | {/* Search Bar */}
238 |
247 |
255 | handleSearchChange(e.target.value)}
261 | sx={{
262 | backgroundColor: "background.paper",
263 | borderRadius: 1,
264 | "& .MuiInputBase-root": {
265 | paddingRight: "40px",
266 | },
267 | }}
268 | />
269 |
278 |
279 | {searchLoading && (
280 |
281 |
282 |
283 | )}
284 | {!searchLoading && noResults && (
285 |
286 | No user found with "{searchQuery}"
287 |
288 | )}
289 | {!searchLoading && searchResults.length > 0 && (
290 |
291 | {searchResults.map((user) => (
292 |
301 |
302 | Username: {user.username}
303 |
304 |
305 | Email: {user.email}
306 |
307 |
308 | ))}
309 |
310 | )}
311 |
312 |
313 | {/* Profile Information */}
314 |
326 |
337 |
342 |
343 |
344 | Your Profile
345 |
346 |
347 | Email: {email}
348 | {!isEditingEmail && (
349 | setIsEditingEmail(true)}>
350 |
351 |
352 | )}
353 |
354 | {isEditingEmail && (
355 |
356 | setNewEmail(e.target.value)}
362 | sx={{ mb: 2 }}
363 | />
364 |
365 |
374 |
386 |
387 | {error && (
388 |
389 | {error}
390 |
391 | )}
392 |
393 | )}
394 |
395 | Username: {username}
396 |
397 |
398 | Days Since Joined: {daysSinceJoined}
399 |
400 |
401 | Date Joined: {joinedDate}
402 |
403 |
404 | Notes Created:{" "}
405 | {notesCount !== null ? notesCount : "Loading..."}
406 |
407 |
408 | Today's Date: {today}
409 |
410 |
417 |
418 | Thank you for using CollabNote today! 🚀
419 |
420 |
431 |
432 |
433 | );
434 | }
435 |
--------------------------------------------------------------------------------
/frontend/src/routes/RegisterPage.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import {
3 | Container,
4 | Paper,
5 | Box,
6 | Typography,
7 | Button,
8 | TextField,
9 | IconButton,
10 | InputAdornment,
11 | } from "@mui/material";
12 | import { Visibility, VisibilityOff } from "@mui/icons-material";
13 | import { useNavigate, Link } from "react-router-dom";
14 | import LoadingOverlay from "../components/LoadingOverlay";
15 |
16 | export default function RegisterPage() {
17 | const [username, setUsername] = useState("");
18 | const [email, setEmail] = useState("");
19 | const [password, setPassword] = useState("");
20 | const [confirmPW, setConfirmPW] = useState("");
21 | const [loading, setLoading] = useState(false);
22 | const [showPassword, setShowPassword] = useState(false);
23 | const [showConfirmPW, setShowConfirmPW] = useState(false);
24 |
25 | const navigate = useNavigate();
26 |
27 | const handleRegister = async () => {
28 | if (password !== confirmPW) {
29 | alert("Passwords do not match!");
30 | return;
31 | }
32 | setLoading(true);
33 | try {
34 | const response = await fetch(
35 | "https://collabnote-fullstack-app.onrender.com/auth/register",
36 | {
37 | method: "POST",
38 | headers: { "Content-Type": "application/json" },
39 | body: JSON.stringify({ username, email, password }),
40 | },
41 | );
42 | if (!response.ok) throw new Error("Registration failed");
43 | alert("Registration successful! You can now login.");
44 | navigate("/login");
45 | } catch (err) {
46 | alert(
47 | err +
48 | "- Please check your username, email, and password. It may also be that this Supabase project is paused. If you encounter this issue again, please contact the project owner for assistance.",
49 | );
50 | } finally {
51 | setLoading(false);
52 | }
53 | };
54 |
55 | return (
56 | <>
57 |
58 |
59 |
68 |
69 | Register
70 |
71 |
72 | Register for an account to create & access your notes.
73 |
74 |
75 | setUsername(e.target.value)}
81 | onKeyPress={(e) => e.key === "Enter" && handleRegister()}
82 | />
83 | setEmail(e.target.value)}
89 | onKeyPress={(e) => e.key === "Enter" && handleRegister()}
90 | />
91 | setPassword(e.target.value)}
97 | onKeyPress={(e) => e.key === "Enter" && handleRegister()}
98 | InputProps={{
99 | endAdornment: (
100 |
101 | setShowPassword(!showPassword)}
103 | edge="end"
104 | >
105 | {showPassword ? : }
106 |
107 |
108 | ),
109 | }}
110 | />
111 | setConfirmPW(e.target.value)}
117 | onKeyPress={(e) => e.key === "Enter" && handleRegister()}
118 | InputProps={{
119 | endAdornment: (
120 |
121 | setShowConfirmPW(!showConfirmPW)}
123 | edge="end"
124 | >
125 | {showConfirmPW ? : }
126 |
127 |
128 | ),
129 | }}
130 | />
131 |
138 |
139 |
140 | Already have an account?{" "}
141 |
149 | Login
150 |
151 |
152 |
153 |
154 | >
155 | );
156 | }
157 |
--------------------------------------------------------------------------------
/frontend/src/theme/ThemeContext.tsx:
--------------------------------------------------------------------------------
1 | import { createContext, useContext } from "react";
2 |
3 | export type ThemeContextType = {
4 | isDarkMode: boolean;
5 | toggleTheme: () => void;
6 | };
7 |
8 | export const ThemeContext = createContext({
9 | isDarkMode: false,
10 | toggleTheme: () => {},
11 | });
12 |
13 | export const useThemeContext = () => useContext(ThemeContext);
14 |
--------------------------------------------------------------------------------
/frontend/src/theme/ThemeProviderWrapper.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from "react";
2 | import { ThemeProvider, CssBaseline } from "@mui/material";
3 | import { ThemeContext } from "./ThemeContext";
4 | import { darkTheme, lightTheme } from "./index";
5 |
6 | export default function ThemeProviderWrapper({
7 | children,
8 | }: {
9 | children: React.ReactNode;
10 | }) {
11 | const [isDarkMode, setIsDarkMode] = useState(false);
12 |
13 | useEffect(() => {
14 | const storedPref = localStorage.getItem("collabnote_darkmode");
15 | if (storedPref === "true") {
16 | setIsDarkMode(true);
17 | document.body.style.backgroundColor =
18 | darkTheme.palette.background.default;
19 | } else {
20 | document.body.style.backgroundColor =
21 | lightTheme.palette.background.default;
22 | }
23 | }, []);
24 |
25 | const toggleTheme = () => {
26 | setIsDarkMode((prev) => {
27 | const newVal = !prev;
28 | localStorage.setItem("collabnote_darkmode", newVal ? "true" : "false");
29 | if (newVal) {
30 | document.body.style.backgroundColor =
31 | darkTheme.palette.background.default;
32 | } else {
33 | document.body.style.backgroundColor =
34 | lightTheme.palette.background.default;
35 | }
36 | return newVal;
37 | });
38 | };
39 |
40 | const theme = isDarkMode ? darkTheme : lightTheme;
41 |
42 | return (
43 |
44 |
45 |
46 | {children}
47 |
48 |
49 | );
50 | }
51 |
--------------------------------------------------------------------------------
/frontend/src/theme/index.ts:
--------------------------------------------------------------------------------
1 | import { createTheme } from "@mui/material/styles";
2 |
3 | export const lightTheme = createTheme({
4 | palette: {
5 | mode: "light",
6 | primary: { main: "#00695c" },
7 | secondary: { main: "#9c27b0" },
8 | background: { default: "#f5f5f5", paper: "#ffffff" },
9 | text: { primary: "#000000" },
10 | },
11 | typography: {
12 | fontFamily: "Poppins, sans-serif",
13 | h4: { fontWeight: 600 },
14 | },
15 | components: {
16 | MuiPaper: {
17 | styleOverrides: {
18 | root: {
19 | backgroundColor: "#ffffff",
20 | transition: "background-color 0.3s ease",
21 | },
22 | },
23 | },
24 | MuiCard: {
25 | styleOverrides: {
26 | root: {
27 | transition: "background-color 0.3s ease",
28 | },
29 | },
30 | },
31 | },
32 | });
33 |
34 | export const darkTheme = createTheme({
35 | palette: {
36 | mode: "dark",
37 | primary: { main: "#80cbc4" },
38 | secondary: { main: "#ce93d8" },
39 | background: { default: "#1c1c1c", paper: "#242424" },
40 | text: { primary: "#ffffff" },
41 | },
42 | typography: {
43 | fontFamily: "Poppins, sans-serif",
44 | h4: { fontWeight: 600 },
45 | },
46 | components: {
47 | MuiPaper: {
48 | styleOverrides: {
49 | root: {
50 | backgroundColor: "#242424",
51 | transition: "background-color 0.3s ease",
52 | },
53 | },
54 | },
55 | MuiCard: {
56 | styleOverrides: {
57 | root: {
58 | transition: "background-color 0.3s ease",
59 | },
60 | },
61 | },
62 | },
63 | });
64 |
--------------------------------------------------------------------------------
/frontend/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/frontend/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4 | "target": "ES2020",
5 | "useDefineForClassFields": true,
6 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
7 | "module": "ESNext",
8 | "skipLibCheck": true,
9 |
10 | /* Bundler mode */
11 | "moduleResolution": "bundler",
12 | "allowImportingTsExtensions": true,
13 | "isolatedModules": true,
14 | "moduleDetection": "force",
15 | "noEmit": true,
16 | "jsx": "react-jsx",
17 |
18 | /* Linting */
19 | "strict": true,
20 | "noUnusedLocals": true,
21 | "noUnusedParameters": true,
22 | "noFallthroughCasesInSwitch": true,
23 | "noUncheckedSideEffectImports": true,
24 | "allowSyntheticDefaultImports": true
25 | },
26 | "include": ["src"]
27 | }
28 |
--------------------------------------------------------------------------------
/frontend/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "files": [],
3 | "references": [
4 | { "path": "./tsconfig.app.json" },
5 | { "path": "./tsconfig.node.json" }
6 | ]
7 | }
8 |
--------------------------------------------------------------------------------
/frontend/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4 | "target": "ES2022",
5 | "lib": ["ES2023"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "isolatedModules": true,
13 | "moduleDetection": "force",
14 | "noEmit": true,
15 |
16 | /* Linting */
17 | "strict": true,
18 | "noUnusedLocals": true,
19 | "noUnusedParameters": true,
20 | "noFallthroughCasesInSwitch": true,
21 | "noUncheckedSideEffectImports": true
22 | },
23 | "include": ["vite.config.ts"]
24 | }
25 |
--------------------------------------------------------------------------------
/frontend/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "rewrites": [
3 | {
4 | "source": "/(.*)",
5 | "destination": "/index.html"
6 | }
7 | ]
8 | }
9 |
--------------------------------------------------------------------------------
/frontend/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vite";
2 | import react from "@vitejs/plugin-react";
3 |
4 | export default defineConfig({
5 | plugins: [react()],
6 | base: "/",
7 | server: {
8 | port: 5173,
9 | },
10 | build: {
11 | outDir: "dist",
12 | },
13 | });
14 |
--------------------------------------------------------------------------------
/img/add-note.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/img/add-note.png
--------------------------------------------------------------------------------
/img/api-docs.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/img/api-docs.png
--------------------------------------------------------------------------------
/img/edit-note.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/img/edit-note.png
--------------------------------------------------------------------------------
/img/footer.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/img/footer.png
--------------------------------------------------------------------------------
/img/graphql.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/img/graphql.png
--------------------------------------------------------------------------------
/img/home-dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/img/home-dark.png
--------------------------------------------------------------------------------
/img/home.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/img/home.png
--------------------------------------------------------------------------------
/img/login-dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/img/login-dark.png
--------------------------------------------------------------------------------
/img/login.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/img/login.png
--------------------------------------------------------------------------------
/img/note-details.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/img/note-details.png
--------------------------------------------------------------------------------
/img/notes-dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/img/notes-dark.png
--------------------------------------------------------------------------------
/img/notes.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/img/notes.png
--------------------------------------------------------------------------------
/img/profile-dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/img/profile-dark.png
--------------------------------------------------------------------------------
/img/profile.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/img/profile.png
--------------------------------------------------------------------------------
/img/register-dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/img/register-dark.png
--------------------------------------------------------------------------------
/img/register.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/img/register.png
--------------------------------------------------------------------------------
/img/reset-password-dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/img/reset-password-dark.png
--------------------------------------------------------------------------------
/img/reset-password.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/img/reset-password.png
--------------------------------------------------------------------------------
/img/responsive.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/img/responsive.png
--------------------------------------------------------------------------------
/img/schema.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hoangsonww/CollabNote-Fullstack-App/245d49c74d121f8ed40b071ab9defb3acd78df53/img/schema.png
--------------------------------------------------------------------------------
/jenkins_cicd.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Define colors for output
4 | GREEN='\033[0;32m'
5 | NC='\033[0m' # No Color
6 |
7 | # Function to print stages
8 | function print_stage() {
9 | echo -e "${GREEN}--- $1 ---${NC}"
10 | }
11 |
12 | # Stage 1: Install Dependencies
13 | print_stage "Stage 1: Install Dependencies"
14 | if npm install; then
15 | echo "Dependencies installed successfully."
16 | else
17 | echo "Failed to install dependencies."
18 | exit 1
19 | fi
20 |
21 | # Stage 2: Build
22 | print_stage "Stage 2: Build"
23 | if npm run build; then
24 | echo "Build completed successfully."
25 | else
26 | echo "Build failed."
27 | exit 1
28 | fi
29 |
30 | echo -e "${GREEN}Pipeline completed successfully.${NC}"
31 |
--------------------------------------------------------------------------------
/kubernetes/backend-deployment.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: apps/v1
2 | kind: Deployment
3 | metadata:
4 | name: backend-deployment
5 | labels:
6 | app: backend
7 | spec:
8 | replicas: 1
9 | selector:
10 | matchLabels:
11 | app: backend
12 | template:
13 | metadata:
14 | labels:
15 | app: backend
16 | spec:
17 | containers:
18 | - name: backend
19 | image: collabnote-backend:latest # Build and push this to a container registry
20 | ports:
21 | - containerPort: 3000
22 | env:
23 | - name: NODE_ENV
24 | value: "production"
25 | volumeMounts:
26 | - name: backend-code
27 | mountPath: /app
28 | volumes:
29 | - name: backend-code
30 | hostPath:
31 | path: /home/user/project/backend
32 |
--------------------------------------------------------------------------------
/kubernetes/backend-service.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: v1
2 | kind: Service
3 | metadata:
4 | name: backend-service
5 | spec:
6 | selector:
7 | app: backend
8 | ports:
9 | - protocol: TCP
10 | port: 3000
11 | targetPort: 3000
12 | type: ClusterIP # Internal service
13 |
--------------------------------------------------------------------------------
/kubernetes/configmap.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: v1
2 | kind: ConfigMap
3 | metadata:
4 | name: collabnote-config
5 | data:
6 | NODE_ENV: "production"
7 | REACT_APP_BACKEND_URL: "http://backend-service:3000"
8 |
--------------------------------------------------------------------------------
/kubernetes/frontend-deployment.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: apps/v1
2 | kind: Deployment
3 | metadata:
4 | name: frontend-deployment
5 | labels:
6 | app: frontend
7 | spec:
8 | replicas: 1
9 | selector:
10 | matchLabels:
11 | app: frontend
12 | template:
13 | metadata:
14 | labels:
15 | app: frontend
16 | spec:
17 | containers:
18 | - name: frontend
19 | image: collabnote-frontend:latest # Build and push this to a container registry
20 | ports:
21 | - containerPort: 3001
22 | env:
23 | - name: REACT_APP_BACKEND_URL
24 | value: "http://backend-service:3000" # This service will route to the backend service
25 | volumeMounts:
26 | - name: frontend-code
27 | mountPath: /app
28 | volumes:
29 | - name: frontend-code
30 | hostPath:
31 | path: /home/user/project/frontend
32 |
--------------------------------------------------------------------------------
/kubernetes/frontend-service.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: v1
2 | kind: Service
3 | metadata:
4 | name: frontend-service
5 | spec:
6 | selector:
7 | app: frontend
8 | ports:
9 | - protocol: TCP
10 | port: 3001
11 | targetPort: 3001
12 | type: NodePort # Expose frontend for external access
13 |
--------------------------------------------------------------------------------
/nginx/Dockerfile:
--------------------------------------------------------------------------------
1 | # Base image for nginx
2 | FROM nginx:latest
3 |
4 | # Remove the default nginx config file
5 | RUN rm /etc/nginx/conf.d/default.conf
6 |
7 | # Copy the custom nginx configuration file
8 | COPY nginx.conf /etc/nginx/conf.d/
9 |
10 | # Expose port 80
11 | EXPOSE 80
12 |
13 | # Start Nginx
14 | CMD ["nginx", "-g", "daemon off;"]
15 |
--------------------------------------------------------------------------------
/nginx/README.md:
--------------------------------------------------------------------------------
1 | # NGINX Docker Setup
2 |
3 | This directory contains all necessary files to set up an NGINX server using Docker for the **DocuThinker App**. It includes a `Dockerfile` to build an NGINX image, a `docker-compose.yml` file to orchestrate the Docker container setup, and a configuration file (`nginx.conf`) to define NGINX’s server behavior.
4 |
5 | ## Directory Structure
6 |
7 | - **docker-compose.yml**: Defines services, networks, and volumes for the Docker setup. This file configures the NGINX service and can be used to start the container with `docker-compose`.
8 | - **Dockerfile**: Contains the instructions to build a Docker image for NGINX, applying any specific configurations or customizations required.
9 | - **nginx.conf**: The NGINX configuration file. This file is used to specify the server configuration, such as listening ports, server names, proxy settings, and location rules.
10 | - **start_nginx.sh**: A shell script to start the NGINX server. This script can be used as an entry point or utility to start the service within the container or on the host system.
11 |
12 | ## Prerequisites
13 |
14 | - **Docker**: Make sure Docker is installed on your machine. [Get Docker here](https://www.docker.com/get-started).
15 | - **Docker Compose**: This setup requires Docker Compose. Install it if you haven’t done so already. [Get Docker Compose here](https://docs.docker.com/compose/install/).
16 |
17 | ## Setup and Usage
18 |
19 | 1. **Build the Docker Image** (optional, if `docker-compose` is configured to build automatically):
20 |
21 | ```bash
22 | docker build -t custom-nginx .
23 | ```
24 |
25 | 2. **Run the NGINX Server** using Docker Compose:
26 |
27 | ```bash
28 | docker-compose up -d
29 | ```
30 |
31 | This command will start the NGINX server in detached mode. The `docker-compose.yml` file will handle the setup, including any volume mappings, port configurations, and network settings.
32 |
33 | 3. **Access the NGINX Server**:
34 |
35 | - By default, NGINX should be accessible at `http://localhost:80` (or the port specified in `nginx.conf`).
36 | - Adjust `nginx.conf` if you need custom settings for server name, proxying, or different ports.
37 |
38 | 4. **Stop the NGINX Server**:
39 | ```bash
40 | docker-compose down
41 | ```
42 | This command will stop and remove the NGINX container(s).
43 |
44 | ## Configuration
45 |
46 | - **nginx.conf**: Modify this file to customize NGINX behavior. Some common changes include:
47 |
48 | - Updating the listening port.
49 | - Adding server names or aliases.
50 | - Configuring reverse proxy settings.
51 |
52 | - **start_nginx.sh**: This script can be used to start the NGINX server if it’s not started by Docker Compose automatically or if you’re running NGINX on a host machine.
53 |
54 | ## Troubleshooting
55 |
56 | - **View Logs**: To see logs for the NGINX container, use:
57 |
58 | ```bash
59 | docker-compose logs -f
60 | ```
61 |
62 | - **Rebuild the Image**: If you change the `Dockerfile`, rebuild the image with:
63 | ```bash
64 | docker-compose up -d --build
65 | ```
66 |
67 | ## Additional Information
68 |
69 | - **Ports**: Make sure the ports defined in `docker-compose.yml` or `nginx.conf` do not conflict with other services on your host machine.
70 | - **Volumes**: Ensure any volumes mounted for `nginx.conf` or other assets are correctly specified in `docker-compose.yml`.
71 |
72 | ---
73 |
74 | For more information about the project, refer to the main [README.md](../README.md) file. Thanks for checking out this NGINX Docker setup!
75 |
--------------------------------------------------------------------------------
/nginx/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3.8'
2 |
3 | services:
4 | nginx:
5 | build:
6 | context: .
7 | dockerfile: Dockerfile
8 | ports:
9 | - "80:80" # Map host port 80 to container port 80
10 | volumes:
11 | - ./nginx.conf:/etc/nginx/conf.d/nginx.conf # Mount custom nginx config file
12 | restart: always # Automatically restart container if it crashes
13 |
--------------------------------------------------------------------------------
/nginx/nginx.conf:
--------------------------------------------------------------------------------
1 | # Load Balancer Configuration
2 | http {
3 | upstream django_backend {
4 | server moodify-emotion-music-app.onrender.com; # Default port: 10000
5 | server moodify-emotion-music-app.onrender.com:8000;
6 | server moodify-emotion-music-app.onrender.com:8001;
7 | server moodify-emotion-music-app.onrender.com:8002;
8 | }
9 |
10 | # Server block for load balancing
11 | server {
12 | listen 80;
13 |
14 | # Define the root for the requests, forward to upstream
15 | location / {
16 | proxy_pass http://django_backend;
17 |
18 | # Set proxy headers
19 | proxy_set_header Host $host;
20 | proxy_set_header X-Real-IP $remote_addr;
21 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
22 | proxy_set_header X-Forwarded-Proto $scheme;
23 | }
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/nginx/start_nginx.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Define the docker-compose file location
4 | COMPOSE_FILE="docker-compose.yml"
5 |
6 | # Display help message
7 | function show_help() {
8 | echo "Usage: $0 [option]"
9 | echo "Options:"
10 | echo " build Build the Nginx Docker image"
11 | echo " start Start the Nginx container"
12 | echo " stop Stop the Nginx container"
13 | echo " restart Restart the Nginx container"
14 | echo " logs Show logs of the Nginx container"
15 | echo " clean Stop and remove the container"
16 | echo " help Display this help message"
17 | }
18 |
19 | # Check if docker-compose file exists
20 | if [ ! -f "$COMPOSE_FILE" ]; then
21 | echo "Error: $COMPOSE_FILE not found!"
22 | exit 1
23 | fi
24 |
25 | # Handle script arguments
26 | case "$1" in
27 | build)
28 | echo "Building the Nginx Docker image..."
29 | docker-compose -f $COMPOSE_FILE build
30 | ;;
31 | start)
32 | echo "Starting the Nginx container..."
33 | docker-compose -f $COMPOSE_FILE up -d
34 | ;;
35 | stop)
36 | echo "Stopping the Nginx container..."
37 | docker-compose -f $COMPOSE_FILE down
38 | ;;
39 | restart)
40 | echo "Restarting the Nginx container..."
41 | docker-compose -f $COMPOSE_FILE down
42 | docker-compose -f $COMPOSE_FILE up -d
43 | ;;
44 | logs)
45 | echo "Displaying logs of the Nginx container..."
46 | docker-compose -f $COMPOSE_FILE logs -f
47 | ;;
48 | clean)
49 | echo "Cleaning up: stopping and removing the container..."
50 | docker-compose -f $COMPOSE_FILE down -v
51 | ;;
52 | help)
53 | show_help
54 | ;;
55 | *)
56 | echo "Invalid option!"
57 | show_help
58 | ;;
59 | esac
60 |
--------------------------------------------------------------------------------
/openapi.yaml:
--------------------------------------------------------------------------------
1 | openapi: 3.0.3
2 | info:
3 | title: CollabNote API
4 | description: >
5 | Comprehensive API documentation for the CollabNote application, an intuitive collaborative notes platform.
6 | version: 1.0.0
7 | contact:
8 | name: Son Nguyen
9 | url: https://github.com/hoangsonww
10 | email: hoangson091104@gmail.com
11 | license:
12 | name: MIT
13 | url: https://opensource.org/licenses/MIT
14 | servers:
15 | - url: http://localhost:4000
16 | description: Development server
17 | - url: https://collabnote-fullstack-app.onrender.com
18 | description: Production server
19 | tags:
20 | - name: Auth
21 | description: Authentication-related endpoints
22 | - name: Notes
23 | description: Endpoints related to creating, managing, and sharing notes
24 | - name: Profile
25 | description: Endpoints for user profiles
26 | paths:
27 | /auth/register:
28 | post:
29 | tags:
30 | - Auth
31 | summary: Register a new user
32 | requestBody:
33 | description: User registration data
34 | required: true
35 | content:
36 | application/json:
37 | schema:
38 | type: object
39 | properties:
40 | username:
41 | type: string
42 | example: "string"
43 | email:
44 | type: string
45 | example: "string"
46 | password:
47 | type: string
48 | example: "string"
49 | responses:
50 | '201':
51 | description: User successfully registered
52 | '400':
53 | description: Bad request
54 | /auth/login:
55 | post:
56 | tags:
57 | - Auth
58 | summary: Login an existing user
59 | requestBody:
60 | description: User login data
61 | required: true
62 | content:
63 | application/json:
64 | schema:
65 | type: object
66 | properties:
67 | email:
68 | type: string
69 | example: "string"
70 | password:
71 | type: string
72 | example: "string"
73 | responses:
74 | '200':
75 | description: User successfully logged in
76 | '401':
77 | description: Invalid credentials
78 | /auth/check-email-exists:
79 | post:
80 | tags:
81 | - Auth
82 | summary: Check if an email exists
83 | requestBody:
84 | description: Email to check
85 | required: true
86 | content:
87 | application/json:
88 | schema:
89 | type: object
90 | properties:
91 | email:
92 | type: string
93 | example: "string"
94 | responses:
95 | '200':
96 | description: Email check completed
97 | '400':
98 | description: Bad request
99 | /auth/reset-password:
100 | post:
101 | tags:
102 | - Auth
103 | summary: Reset a user's password
104 | requestBody:
105 | description: Password reset data
106 | required: true
107 | content:
108 | application/json:
109 | schema:
110 | type: object
111 | properties:
112 | email:
113 | type: string
114 | example: "string"
115 | newPassword:
116 | type: string
117 | example: "string"
118 | confirmPassword:
119 | type: string
120 | example: "string"
121 | responses:
122 | '200':
123 | description: Password successfully reset
124 | '400':
125 | description: Bad request
126 | /notes:
127 | get:
128 | tags:
129 | - Notes
130 | summary: Retrieve user notes
131 | parameters:
132 | - name: search
133 | in: query
134 | description: Search term to filter notes
135 | schema:
136 | type: string
137 | - name: tag
138 | in: query
139 | description: Tag to filter notes
140 | schema:
141 | type: string
142 | responses:
143 | '200':
144 | description: List of user notes retrieved successfully
145 | '401':
146 | description: Unauthorized access
147 | post:
148 | tags:
149 | - Notes
150 | summary: Create a new note
151 | requestBody:
152 | description: Details of the new note
153 | required: true
154 | content:
155 | application/json:
156 | schema:
157 | type: object
158 | properties:
159 | title:
160 | type: string
161 | example: "string"
162 | content:
163 | type: string
164 | example: "string"
165 | tags:
166 | type: array
167 | items:
168 | type: string
169 | example: ["string"]
170 | dueDate:
171 | type: string
172 | format: date-time
173 | example: "2025-01-23T14:43:40.020Z"
174 | color:
175 | type: string
176 | example: "string"
177 | responses:
178 | '201':
179 | description: Note created successfully
180 | '400':
181 | description: Invalid input
182 | /notes/{id}:
183 | patch:
184 | tags:
185 | - Notes
186 | summary: Update a note
187 | parameters:
188 | - name: id
189 | in: path
190 | required: true
191 | description: ID of the note to update
192 | schema:
193 | type: string
194 | requestBody:
195 | description: Updates to the note
196 | required: true
197 | content:
198 | application/json:
199 | schema:
200 | type: object
201 | responses:
202 | '200':
203 | description: Note updated successfully
204 | '404':
205 | description: Note not found
206 | delete:
207 | tags:
208 | - Notes
209 | summary: Delete a note
210 | parameters:
211 | - name: id
212 | in: path
213 | required: true
214 | description: ID of the note to delete
215 | schema:
216 | type: string
217 | responses:
218 | '200':
219 | description: Note deleted successfully
220 | '404':
221 | description: Note not found
222 | /notes/{id}/share:
223 | post:
224 | tags:
225 | - Notes
226 | summary: Share a note with another user
227 | parameters:
228 | - name: id
229 | in: path
230 | required: true
231 | description: ID of the note to share
232 | schema:
233 | type: string
234 | requestBody:
235 | description: Target user to share the note with
236 | required: true
237 | content:
238 | application/json:
239 | schema:
240 | type: object
241 | properties:
242 | targetUserId:
243 | type: integer
244 | example: 0
245 | responses:
246 | '200':
247 | description: Note shared successfully
248 | '404':
249 | description: Note or user not found
250 | /notes/reorder:
251 | post:
252 | tags:
253 | - Notes
254 | summary: Reorder user notes
255 | requestBody:
256 | description: New order of note IDs
257 | required: true
258 | content:
259 | application/json:
260 | schema:
261 | type: object
262 | properties:
263 | noteOrder:
264 | type: array
265 | items:
266 | type: integer
267 | example: [0]
268 | responses:
269 | '200':
270 | description: Notes reordered successfully
271 | '400':
272 | description: Invalid input
273 | /profile/me:
274 | get:
275 | tags:
276 | - Profile
277 | summary: Retrieve the authenticated user's profile
278 | responses:
279 | '200':
280 | description: Profile retrieved successfully
281 | '401':
282 | description: Unauthorized access
283 | /profile/userId/{id}:
284 | get:
285 | tags:
286 | - Profile
287 | summary: Retrieve a user profile by ID
288 | parameters:
289 | - name: id
290 | in: path
291 | required: true
292 | description: ID of the user to retrieve
293 | schema:
294 | type: string
295 | responses:
296 | '200':
297 | description: Profile retrieved successfully
298 | '404':
299 | description: User not found
300 | /profile/search:
301 | get:
302 | tags:
303 | - Profile
304 | summary: Search for a user profile by username
305 | parameters:
306 | - name: username
307 | in: query
308 | description: Username to search for
309 | schema:
310 | type: string
311 | responses:
312 | '200':
313 | description: Search results returned successfully
314 |
--------------------------------------------------------------------------------
/package-lock.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "collabnote",
3 | "version": "1.0.0",
4 | "lockfileVersion": 3,
5 | "requires": true,
6 | "packages": {
7 | "": {
8 | "name": "collabnote",
9 | "version": "1.0.0",
10 | "dependencies": {
11 | "prettier": "^3.4.2"
12 | }
13 | },
14 | "node_modules/prettier": {
15 | "version": "3.4.2",
16 | "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.2.tgz",
17 | "integrity": "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==",
18 | "bin": {
19 | "prettier": "bin/prettier.cjs"
20 | },
21 | "engines": {
22 | "node": ">=14"
23 | },
24 | "funding": {
25 | "url": "https://github.com/prettier/prettier?sponsor=1"
26 | }
27 | }
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "collabnote",
3 | "displayName": "CollabNote",
4 | "version": "1.0.0",
5 | "description": "A note-taking app that allows you to take notes together with your friends, family, and colleagues.",
6 | "scripts": {
7 | "dev": "cd frontend && vite && cd ../backend && npm run start:dev",
8 | "start": "cd frontend && vite && cd ../backend && npm run start:dev",
9 | "frontend": "cd frontend && vite",
10 | "backend": "cd backend && npm run start:dev",
11 | "format": "prettier --write \"**/*.{js,ts,tsx,json,css,html}\""
12 | },
13 | "private": false,
14 | "dependencies": {
15 | "prettier": "^3.4.2"
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/start-all.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | set -e
3 |
4 | # Start backend
5 | echo "Starting backend server..."
6 | cd backend
7 | npm run start & # Run backend in the background
8 | cd ..
9 |
10 | # Start frontend
11 | echo "Starting frontend server..."
12 | cd frontend
13 | npm run preview & # Run frontend in the background
14 | cd ..
15 |
16 | echo "Both backend and frontend are running."
17 |
--------------------------------------------------------------------------------
/stop-all.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | echo "Stopping all running processes..."
3 | kill $(jobs -p)
4 | echo "All processes stopped."
5 |
--------------------------------------------------------------------------------
/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "rewrites": [
3 | {
4 | "source": "/(.*)",
5 | "destination": "/index.html"
6 | }
7 | ]
8 | }
9 |
--------------------------------------------------------------------------------