first commit
This commit is contained in:
44
.github/copilot-instructions.md
vendored
Normal file
44
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# Copilot Instructions for Exam Manager
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
- **Backend**: TypeScript (Node.js) located in [/backend](/backend). Uses `rjweb-server` for the web server and `mongodb` for the database.
|
||||||
|
- **Frontend**: React (Vite) located in [/frontend](/frontend). Uses Tailwind CSS for styling and React Router for navigation.
|
||||||
|
- **Database**: MongoDB. Database connection string is managed via `.env` file.
|
||||||
|
- **Deployment**: Backend serves the frontend's built files from `/frontend/dist`. API routes are prefixed with `/api`.
|
||||||
|
|
||||||
|
## Core Patterns & Conventions
|
||||||
|
|
||||||
|
### Backend (Node.js/TypeScript)
|
||||||
|
- **Server Framework**: `rjweb-server`. Routes are defined using `fileRouter.Path`.
|
||||||
|
- **Database Access**: Use the exported `db` object from [backend/src/index.ts](backend/src/index.ts).
|
||||||
|
- **Models & Types**: Defined in [backend/src/types.ts](backend/src/types.ts).
|
||||||
|
- **Authentication**: Session-based using a cookie named `exams_session` or an `api-authentication` header. Use `authCheck` from [backend/src/lib/Auth.ts](backend/src/lib/Auth.ts) to protect routes.
|
||||||
|
- **Static Files**: The backend serves the frontend's built files from `/frontend/dist`. API routes are prefixed with `/api`.
|
||||||
|
|
||||||
|
### Frontend (React)
|
||||||
|
- **API Calls**: Axios-based. Authentication is handled automatically via cookies. API paths start with `/api/`.
|
||||||
|
- **Types**: Always keep [frontend/src/types.ts](frontend/src/types.ts) in sync with backend types.
|
||||||
|
- **Routing**: Managed in [frontend/src/App.tsx](frontend/src/App.tsx). Protected routes should verify authentication with the backend.
|
||||||
|
- **Icons**: Use `lucide-react` for iconography.
|
||||||
|
|
||||||
|
## Developer Workflows
|
||||||
|
- **Running with Docker**: Use `docker-compose up --build` to build both stacks and the DB.
|
||||||
|
- **Local Backend Dev**:
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
- **Local Frontend Dev**:
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Important Files
|
||||||
|
- [backend/src/index.ts](backend/src/index.ts): Entry point and server configuration.
|
||||||
|
- [backend/src/routes/](backend/src/routes/): Directory containing all API endpoints.
|
||||||
|
- [backend/src/types.ts](backend/src/types.ts): Shared TypeScript interfaces for models.
|
||||||
|
- [frontend/src/App.tsx](frontend/src/App.tsx): Frontend routing and auth state.
|
||||||
|
- [frontend/src/types.ts](frontend/src/types.ts): Type definitions for the frontend.
|
||||||
15
.gitignore
vendored
Normal file
15
.gitignore
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
./db
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
|
||||||
|
# Ignore Secrets
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
package-lock.json
|
||||||
|
pnpm-lock.yaml
|
||||||
|
yarn.lock
|
||||||
|
.pnpm-store/
|
||||||
19
backend/package.json
Normal file
19
backend/package.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"@rjweb/runtime-node": "^1.1.1",
|
||||||
|
"@rjweb/utils": "^1.12.29",
|
||||||
|
"@types/node": "^25.0.3",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
|
"mongodb": "^7.0.0",
|
||||||
|
"rimraf": "^6.1.2",
|
||||||
|
"rjweb-server": "^9.8.6",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "rimraf dist && tsc",
|
||||||
|
"dev": "npm run build && cd dist && node index.js",
|
||||||
|
"start": "npm run build && cd dist && node index.js"
|
||||||
|
}
|
||||||
|
}
|
||||||
46
backend/src/index.ts
Normal file
46
backend/src/index.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { Server } from "rjweb-server";
|
||||||
|
import { Runtime } from "@rjweb/runtime-node";
|
||||||
|
import * as dotenv from "dotenv";
|
||||||
|
import * as mongoDB from "mongodb";
|
||||||
|
import { env } from "node:process";
|
||||||
|
|
||||||
|
dotenv.config({ path: "../.env" });
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
CookieDomain: env.COOKIE_DOMAIN || "localhost"
|
||||||
|
}
|
||||||
|
|
||||||
|
const client: mongoDB.MongoClient = new mongoDB.MongoClient(
|
||||||
|
env.DB_CONN_STRING!
|
||||||
|
);
|
||||||
|
|
||||||
|
const db: mongoDB.Db = client.db("exams_db");
|
||||||
|
|
||||||
|
const COOKIENAME = "exams_session";
|
||||||
|
export { db, client, COOKIENAME };
|
||||||
|
|
||||||
|
const server = new Server(Runtime, {
|
||||||
|
port: 8080,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const fileRouter = new server.FileLoader("/")
|
||||||
|
.load("./routes", { fileBasedRouting: false })
|
||||||
|
.export();
|
||||||
|
|
||||||
|
server.path("/", (path) =>
|
||||||
|
path.static("../../frontend/dist", {
|
||||||
|
stripHtmlEnding: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
server.notFound(async (ctr) => {
|
||||||
|
return ctr
|
||||||
|
.status(200, "maybe frontend?")
|
||||||
|
.printFile("../../frontend/dist/index.html", { addTypes: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
server.start().then(async (port) => {
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
console.log(`Server started on port ${port}!`);
|
||||||
|
});
|
||||||
71
backend/src/lib/Auth.ts
Normal file
71
backend/src/lib/Auth.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { db } from "..";
|
||||||
|
import { Session, User } from "../types";
|
||||||
|
import crypto from "crypto";
|
||||||
|
|
||||||
|
interface AuthCheckResultBase {
|
||||||
|
state: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthCheckSuccess extends AuthCheckResultBase {
|
||||||
|
state: true;
|
||||||
|
userId: string;
|
||||||
|
user: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthCheckFailure extends AuthCheckResultBase {
|
||||||
|
state: false;
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthCheckResult = AuthCheckSuccess | AuthCheckFailure;
|
||||||
|
|
||||||
|
export async function authCheck(
|
||||||
|
cookie: string | null,
|
||||||
|
apiHeader: string | null = null
|
||||||
|
): Promise<AuthCheckResult> {
|
||||||
|
// If API header provided, try API key auth first
|
||||||
|
if (apiHeader) {
|
||||||
|
try {
|
||||||
|
const hash = crypto.createHash("sha256").update(apiHeader).digest("hex");
|
||||||
|
const userByKey = await db
|
||||||
|
.collection<User>("users")
|
||||||
|
.findOne({ "apiKeys.keyHash": hash });
|
||||||
|
|
||||||
|
if (userByKey) {
|
||||||
|
// find the apiKey id
|
||||||
|
const found = (userByKey.apiKeys || []).find((k) => k.keyHash === hash);
|
||||||
|
return {
|
||||||
|
state: true,
|
||||||
|
message: "Authenticated via API key",
|
||||||
|
userId: userByKey.id,
|
||||||
|
user: userByKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// fall through to session-based auth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cookie) {
|
||||||
|
return { state: false, message: "No Cookie Provided" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await db.collection<Session>("sessions").findOne({ token: cookie });
|
||||||
|
if (!session) {
|
||||||
|
return { state: false, message: "Invalid Session" };
|
||||||
|
}
|
||||||
|
if (session.expiresAt < new Date()) {
|
||||||
|
return { state: false, message: "Session Expired" };
|
||||||
|
}
|
||||||
|
const user = await db.collection<User>("users").findOne({ id: session.userId });
|
||||||
|
if (!user) {
|
||||||
|
return { state: false, message: "User Not Found" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: true,
|
||||||
|
message: "Authenticated",
|
||||||
|
userId: session.userId,
|
||||||
|
user: user,
|
||||||
|
};
|
||||||
|
}
|
||||||
108
backend/src/routes/account/api-keys.ts
Normal file
108
backend/src/routes/account/api-keys.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { COOKIENAME } from "../..";
|
||||||
|
import { db, fileRouter } from "../../";
|
||||||
|
import { authCheck } from "../../lib/Auth";
|
||||||
|
import crypto from "crypto";
|
||||||
|
|
||||||
|
export = new fileRouter.Path("/")
|
||||||
|
.http("GET", "/api/account/api-keys", (http) =>
|
||||||
|
http.onRequest(async (ctr) => {
|
||||||
|
const cookie = ctr.cookies.get(COOKIENAME) || null;
|
||||||
|
const apiHeader = (ctr.headers && ctr.headers.get)
|
||||||
|
? ctr.headers.get("api-authentication") || null
|
||||||
|
: null;
|
||||||
|
const auth = await authCheck(cookie, apiHeader);
|
||||||
|
if (!auth.state) {
|
||||||
|
return ctr.status(ctr.$status.UNAUTHORIZED).print({ error: auth.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await db.collection("users").findOne({ id: auth.userId });
|
||||||
|
if (!user) {
|
||||||
|
return ctr.status(ctr.$status.NOT_FOUND).print({ error: "User not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = (user.apiKeys || []).map((k: any) => ({
|
||||||
|
id: k.id,
|
||||||
|
description: k.description,
|
||||||
|
createdAt: k.createdAt,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return ctr.print({ success: true, apiKeys: keys });
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.http("POST", "/api/account/api-keys/create", (http) =>
|
||||||
|
http.onRequest(async (ctr) => {
|
||||||
|
const cookie = ctr.cookies.get(COOKIENAME) || null;
|
||||||
|
const apiHeader = (ctr.headers && ctr.headers.get)
|
||||||
|
? ctr.headers.get("api-authentication") || null
|
||||||
|
: null;
|
||||||
|
const auth = await authCheck(cookie, apiHeader);
|
||||||
|
if (!auth.state) {
|
||||||
|
return ctr.status(ctr.$status.UNAUTHORIZED).print({ error: auth.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [data, error] = await ctr.bindBody((z) =>
|
||||||
|
z.object({ description: z.string().min(1).max(200) })
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!data) return ctr.status(ctr.$status.BAD_REQUEST).print({ error: error.toString() });
|
||||||
|
|
||||||
|
const user = await db.collection("users").findOne({ id: auth.userId });
|
||||||
|
if (!user) return ctr.status(ctr.$status.NOT_FOUND).print({ error: "User not found" });
|
||||||
|
|
||||||
|
const existing = user.apiKeys || [];
|
||||||
|
if (existing.length >= 4) {
|
||||||
|
return ctr.status(ctr.$status.BAD_REQUEST).print({ error: "API key limit reached (max 4)" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = crypto.randomBytes(32).toString("hex");
|
||||||
|
const keyHash = crypto.createHash("sha256").update(raw).digest("hex");
|
||||||
|
const keyId = crypto.randomUUID();
|
||||||
|
|
||||||
|
const newKey = {
|
||||||
|
id: keyId,
|
||||||
|
description: data.description,
|
||||||
|
keyHash,
|
||||||
|
createdAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await db.collection("users").updateOne(
|
||||||
|
{ id: auth.userId },
|
||||||
|
{ $push: { apiKeys: newKey } } as any,
|
||||||
|
{ upsert: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.acknowledged) {
|
||||||
|
return ctr.status(ctr.$status.INTERNAL_SERVER_ERROR).print({ error: "Failed to create API key" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctr.print({ success: true, apiKey: { id: keyId, description: data.description, createdAt: newKey.createdAt, token: raw } });
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.http("DELETE", "/api/account/api-keys/{id}", (http) =>
|
||||||
|
http.onRequest(async (ctr) => {
|
||||||
|
const cookie = ctr.cookies.get(COOKIENAME) || null;
|
||||||
|
const apiHeader = (ctr.headers && ctr.headers.get)
|
||||||
|
? ctr.headers.get("api-authentication") || null
|
||||||
|
: null;
|
||||||
|
const auth = await authCheck(cookie, apiHeader);
|
||||||
|
if (!auth.state) {
|
||||||
|
return ctr.status(ctr.$status.UNAUTHORIZED).print({ error: auth.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyId = ctr.params.get("id");
|
||||||
|
if (!keyId) return ctr.status(ctr.$status.BAD_REQUEST).print({ error: "Key id required" });
|
||||||
|
|
||||||
|
const res = await db.collection("users").updateOne(
|
||||||
|
{ id: auth.userId },
|
||||||
|
{ $pull: { apiKeys: { id: keyId } } } as any
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.acknowledged) {
|
||||||
|
return ctr.status(ctr.$status.INTERNAL_SERVER_ERROR).print({ error: "Failed to delete API key" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctr.print({ success: true });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
29
backend/src/routes/account/delete.ts
Normal file
29
backend/src/routes/account/delete.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { COOKIENAME, db, fileRouter } from "../../";
|
||||||
|
import { authCheck } from "../../lib/Auth";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
|
||||||
|
export = new fileRouter.Path("/").http(
|
||||||
|
"DELETE",
|
||||||
|
"/api/account/delete",
|
||||||
|
(http) =>
|
||||||
|
http.onRequest(async (ctr) => {
|
||||||
|
const cookie = ctr.cookies.get(COOKIENAME) || null;
|
||||||
|
const apiHeader = ctr.headers?.get?.("api-authentication") || null;
|
||||||
|
const auth = await authCheck(cookie, apiHeader);
|
||||||
|
|
||||||
|
if (!auth.state) {
|
||||||
|
return ctr.status(ctr.$status.UNAUTHORIZED).print({ error: auth.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete all exams for the user
|
||||||
|
await db.collection("exams").deleteMany({ userId: auth.userId });
|
||||||
|
|
||||||
|
// Delete the user account
|
||||||
|
await db.collection("users").deleteOne({ id: auth.userId });
|
||||||
|
|
||||||
|
// Clear session cookie
|
||||||
|
ctr.cookies.delete(COOKIENAME);
|
||||||
|
|
||||||
|
return ctr.print({ success: true, message: "Account deleted" });
|
||||||
|
})
|
||||||
|
);
|
||||||
56
backend/src/routes/account/download.ts
Normal file
56
backend/src/routes/account/download.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { COOKIENAME } from "../..";
|
||||||
|
import { db, fileRouter } from "../../";
|
||||||
|
import { authCheck } from "../../lib/Auth";
|
||||||
|
import { Exam } from "../../types";
|
||||||
|
|
||||||
|
export = new fileRouter.Path("/").http(
|
||||||
|
"GET",
|
||||||
|
"/api/account/download",
|
||||||
|
(http) =>
|
||||||
|
http.onRequest(async (ctr) => {
|
||||||
|
const cookie = ctr.cookies.get(COOKIENAME) || null;
|
||||||
|
const apiHeader = (ctr.headers && ctr.headers.get)
|
||||||
|
? ctr.headers.get("api-authentication") || null
|
||||||
|
: null;
|
||||||
|
const auth = await authCheck(cookie, apiHeader);
|
||||||
|
|
||||||
|
if (!auth.state) {
|
||||||
|
return ctr
|
||||||
|
.status(ctr.$status.UNAUTHORIZED)
|
||||||
|
.print({ error: auth.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch all user data
|
||||||
|
const exams = await db
|
||||||
|
.collection<Exam>("exams")
|
||||||
|
.find({ userId: auth.userId })
|
||||||
|
.toArray();
|
||||||
|
|
||||||
|
const userData = {
|
||||||
|
user: {
|
||||||
|
id: auth.user.id,
|
||||||
|
username: auth.user.username,
|
||||||
|
},
|
||||||
|
exams: exams.map((exam:Exam) => ({
|
||||||
|
id: exam.id,
|
||||||
|
name: exam.name,
|
||||||
|
description: exam.description,
|
||||||
|
date: exam.date,
|
||||||
|
result: exam.result,
|
||||||
|
imageUrls: exam.imageUrls,
|
||||||
|
createdAt: exam.createdAt,
|
||||||
|
updatedAt: exam.updatedAt,
|
||||||
|
})),
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set headers for file download
|
||||||
|
ctr.headers.set("Content-Type", "application/json");
|
||||||
|
ctr.headers.set(
|
||||||
|
"Content-Disposition",
|
||||||
|
`attachment; filename="exams-data-${auth.user.username}-${Date.now()}.json"`
|
||||||
|
);
|
||||||
|
|
||||||
|
return ctr.print(JSON.stringify(userData, null, 2));
|
||||||
|
})
|
||||||
|
);
|
||||||
28
backend/src/routes/account/fetch.ts
Normal file
28
backend/src/routes/account/fetch.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { COOKIENAME } from "../..";
|
||||||
|
import { db, fileRouter } from "../../";
|
||||||
|
import { authCheck } from "../../lib/Auth";
|
||||||
|
|
||||||
|
export = new fileRouter.Path("/").http("GET", "/api/account/fetch", (http) =>
|
||||||
|
http.onRequest(async (ctr) => {
|
||||||
|
const cookie = ctr.cookies.get(COOKIENAME) || null;
|
||||||
|
const apiHeader = (ctr.headers && ctr.headers.get)
|
||||||
|
? ctr.headers.get("api-authentication") || null
|
||||||
|
: null;
|
||||||
|
const auth = await authCheck(cookie, apiHeader);
|
||||||
|
|
||||||
|
if (!auth.state) {
|
||||||
|
return ctr
|
||||||
|
.status(ctr.$status.UNAUTHORIZED)
|
||||||
|
.print({ error: auth.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return user data without sensitive information
|
||||||
|
return ctr.print({
|
||||||
|
success: true,
|
||||||
|
user: {
|
||||||
|
...auth.user,
|
||||||
|
passwordHash: undefined, // nukes sensitive info
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
58
backend/src/routes/account/login.ts
Normal file
58
backend/src/routes/account/login.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { Cookie } from "rjweb-server";
|
||||||
|
import { db, fileRouter, config } from "../../";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { hash } from "crypto";
|
||||||
|
import { COOKIENAME } from "../..";
|
||||||
|
|
||||||
|
export = new fileRouter.Path("/").http(
|
||||||
|
"POST",
|
||||||
|
"/api/account/login",
|
||||||
|
(http) =>
|
||||||
|
http.onRequest(async (ctr) => {
|
||||||
|
const [data, error] = await ctr.bindBody((z) =>
|
||||||
|
z.object({
|
||||||
|
username: z.string().min(3).max(20),
|
||||||
|
password: z.string().min(6).max(200),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!data)
|
||||||
|
return ctr.status(ctr.$status.BAD_REQUEST).print(error.toString());
|
||||||
|
|
||||||
|
// Find user
|
||||||
|
const user = await db.collection("users").findOne({
|
||||||
|
username: data.username,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return ctr.status(ctr.$status.UNAUTHORIZED).print({ error: "Invalid username or password" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify password
|
||||||
|
const isPasswordValid = await bcrypt.compare(data.password, user.passwordHash);
|
||||||
|
|
||||||
|
if (!isPasswordValid) {
|
||||||
|
return ctr.status(ctr.$status.UNAUTHORIZED).print({ error: "Invalid username or password" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create Session
|
||||||
|
const token = hash("sha256", crypto.randomUUID() + data.username, "hex");
|
||||||
|
await db.collection("sessions").insertOne({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
userId: user.id,
|
||||||
|
token,
|
||||||
|
createdAt: new Date(),
|
||||||
|
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 90), // 90 days
|
||||||
|
});
|
||||||
|
|
||||||
|
ctr.cookies.set(
|
||||||
|
COOKIENAME,
|
||||||
|
new Cookie(token, {
|
||||||
|
domain: config.CookieDomain,
|
||||||
|
expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 90), // 90 days
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return ctr.print({ success: true });
|
||||||
|
})
|
||||||
|
);
|
||||||
30
backend/src/routes/account/logout.ts
Normal file
30
backend/src/routes/account/logout.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { COOKIENAME } from "../..";
|
||||||
|
import { db, fileRouter } from "../../";
|
||||||
|
import { authCheck } from "../../lib/Auth";
|
||||||
|
|
||||||
|
export = new fileRouter.Path("/").http(
|
||||||
|
"POST",
|
||||||
|
"/api/account/logout",
|
||||||
|
(http) =>
|
||||||
|
http.onRequest(async (ctr) => {
|
||||||
|
const cookie = ctr.cookies.get(COOKIENAME) || null;
|
||||||
|
const apiHeader = (ctr.headers && ctr.headers.get)
|
||||||
|
? ctr.headers.get("api-authentication") || null
|
||||||
|
: null;
|
||||||
|
const auth = await authCheck(cookie, apiHeader);
|
||||||
|
|
||||||
|
if (!auth.state) {
|
||||||
|
return ctr
|
||||||
|
.status(ctr.$status.UNAUTHORIZED)
|
||||||
|
.print({ error: auth.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the session from the database
|
||||||
|
await db.collection("sessions").deleteOne({ token: cookie });
|
||||||
|
|
||||||
|
// Clear the cookie
|
||||||
|
ctr.cookies.delete(COOKIENAME);
|
||||||
|
|
||||||
|
return ctr.print({ success: true, message: "Logged out successfully" });
|
||||||
|
})
|
||||||
|
);
|
||||||
60
backend/src/routes/account/manage.ts
Normal file
60
backend/src/routes/account/manage.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { COOKIENAME } from "../..";
|
||||||
|
import { db, fileRouter } from "../../";
|
||||||
|
import { authCheck } from "../../lib/Auth";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
|
||||||
|
export = new fileRouter.Path("/").http(
|
||||||
|
"POST",
|
||||||
|
"/api/account/manage",
|
||||||
|
(http) =>
|
||||||
|
http.onRequest(async (ctr) => {
|
||||||
|
const cookie = ctr.cookies.get(COOKIENAME) || null;
|
||||||
|
const apiHeader = (ctr.headers && ctr.headers.get)
|
||||||
|
? ctr.headers.get("api-authentication") || null
|
||||||
|
: null;
|
||||||
|
const auth = await authCheck(cookie, apiHeader);
|
||||||
|
|
||||||
|
if (!auth.state) {
|
||||||
|
return ctr
|
||||||
|
.status(ctr.$status.UNAUTHORIZED)
|
||||||
|
.print({ error: auth.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [data, error] = await ctr.bindBody((z) =>
|
||||||
|
z.object({
|
||||||
|
currentPassword: z.string().min(1),
|
||||||
|
newPassword: z.string().min(6).max(200),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!data)
|
||||||
|
return ctr.status(ctr.$status.BAD_REQUEST).print(error.toString());
|
||||||
|
|
||||||
|
// Verify current password
|
||||||
|
const passwordMatch = await bcrypt.compare(
|
||||||
|
data.currentPassword,
|
||||||
|
auth.user.passwordHash
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!passwordMatch) {
|
||||||
|
return ctr
|
||||||
|
.status(ctr.$status.UNAUTHORIZED)
|
||||||
|
.print({ error: "Current password is incorrect" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash new password and update
|
||||||
|
const newPasswordHash = await bcrypt.hash(data.newPassword, 10);
|
||||||
|
const result = await db.collection("users").updateOne(
|
||||||
|
{ id: auth.userId },
|
||||||
|
{ $set: { passwordHash: newPasswordHash } }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.modifiedCount === 0) {
|
||||||
|
return ctr
|
||||||
|
.status(ctr.$status.INTERNAL_SERVER_ERROR)
|
||||||
|
.print({ error: "Failed to update password" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctr.print({ success: true, message: "Password updated successfully" });
|
||||||
|
})
|
||||||
|
);
|
||||||
48
backend/src/routes/account/profile.ts
Normal file
48
backend/src/routes/account/profile.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { COOKIENAME, db, fileRouter } from "../../";
|
||||||
|
import { authCheck } from "../../lib/Auth";
|
||||||
|
|
||||||
|
export = new fileRouter.Path("/").http(
|
||||||
|
"POST",
|
||||||
|
"/api/account/profile",
|
||||||
|
(http) =>
|
||||||
|
http.onRequest(async (ctr) => {
|
||||||
|
const cookie = ctr.cookies.get(COOKIENAME) || null;
|
||||||
|
const apiHeader = (ctr.headers && ctr.headers.get)
|
||||||
|
? ctr.headers.get("api-authentication") || null
|
||||||
|
: null;
|
||||||
|
const auth = await authCheck(cookie, apiHeader);
|
||||||
|
|
||||||
|
if (!auth.state) {
|
||||||
|
return ctr
|
||||||
|
.status(ctr.$status.UNAUTHORIZED)
|
||||||
|
.print({ error: auth.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [data, error] = await ctr.bindBody((z) =>
|
||||||
|
z.object({
|
||||||
|
displayName: z.string().max(100).optional(),
|
||||||
|
iconUrl: z.string().url().max(500).or(z.literal("")).optional(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!data)
|
||||||
|
return ctr.status(ctr.$status.BAD_REQUEST).print(error.toString());
|
||||||
|
|
||||||
|
const updateData: any = {};
|
||||||
|
if (data.displayName !== undefined) updateData.displayName = data.displayName;
|
||||||
|
if (data.iconUrl !== undefined) updateData.iconUrl = data.iconUrl === "" ? null : data.iconUrl;
|
||||||
|
|
||||||
|
const result = await db.collection("users").updateOne(
|
||||||
|
{ id: auth.userId },
|
||||||
|
{ $set: updateData }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.matchedCount === 0) {
|
||||||
|
return ctr
|
||||||
|
.status(ctr.$status.INTERNAL_SERVER_ERROR)
|
||||||
|
.print({ error: "Failed to update profile" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctr.print({ success: true, message: "Profile updated successfully" });
|
||||||
|
})
|
||||||
|
);
|
||||||
65
backend/src/routes/account/register.ts
Normal file
65
backend/src/routes/account/register.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { Cookie } from "rjweb-server";
|
||||||
|
import { db, fileRouter, config } from "../../";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { hash } from "crypto";
|
||||||
|
import { COOKIENAME } from "../..";
|
||||||
|
|
||||||
|
export = new fileRouter.Path("/").http(
|
||||||
|
"POST",
|
||||||
|
"/api/account/register",
|
||||||
|
(http) =>
|
||||||
|
http.onRequest(async (ctr) => {
|
||||||
|
const [data, error] = await ctr.bindBody((z) =>
|
||||||
|
z.object({
|
||||||
|
username: z.string().min(3).max(20),
|
||||||
|
password: z.string().min(6).max(200),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!data)
|
||||||
|
return ctr.status(ctr.$status.BAD_REQUEST).print(error.toString());
|
||||||
|
|
||||||
|
// Check if username is taken
|
||||||
|
const existingUser = await db.collection("users").findOne({
|
||||||
|
username: data.username,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
return ctr.status(ctr.$status.BAD_REQUEST).print({ error: "Username already taken" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create user
|
||||||
|
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const res = await db.collection("users").insertOne({
|
||||||
|
id,
|
||||||
|
username: data.username,
|
||||||
|
passwordHash: await bcrypt.hash(data.password, 10),
|
||||||
|
createdAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.acknowledged === false) {
|
||||||
|
return ctr.status(500).print({ error: "Failed to set the value" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create Session
|
||||||
|
const token = hash("sha256", crypto.randomUUID() + data.username, "hex");
|
||||||
|
await db.collection("sessions").insertOne({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
userId: id,
|
||||||
|
token,
|
||||||
|
createdAt: new Date(),
|
||||||
|
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 90), // 90 days
|
||||||
|
});
|
||||||
|
|
||||||
|
ctr.cookies.set(
|
||||||
|
COOKIENAME,
|
||||||
|
new Cookie(token, {
|
||||||
|
domain: config.CookieDomain,
|
||||||
|
expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 90), // 90 days
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return ctr.print({ success: true });
|
||||||
|
})
|
||||||
|
);
|
||||||
43
backend/src/routes/account/subject-colors.ts
Normal file
43
backend/src/routes/account/subject-colors.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { COOKIENAME, db, fileRouter } from "../../";
|
||||||
|
import { authCheck } from "../../lib/Auth";
|
||||||
|
|
||||||
|
export = new fileRouter.Path("/").http(
|
||||||
|
"POST",
|
||||||
|
"/api/account/subject-colors",
|
||||||
|
(http) =>
|
||||||
|
http.onRequest(async (ctr) => {
|
||||||
|
const cookie = ctr.cookies.get(COOKIENAME) || null;
|
||||||
|
const apiHeader = (ctr.headers && ctr.headers.get)
|
||||||
|
? ctr.headers.get("api-authentication") || null
|
||||||
|
: null;
|
||||||
|
const auth = await authCheck(cookie, apiHeader);
|
||||||
|
|
||||||
|
if (!auth.state) {
|
||||||
|
return ctr
|
||||||
|
.status(ctr.$status.UNAUTHORIZED)
|
||||||
|
.print({ error: auth.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [data, error] = await ctr.bindBody((z) =>
|
||||||
|
z.object({
|
||||||
|
colors: z.record(z.string(), z.string().regex(/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/)),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!data)
|
||||||
|
return ctr.status(ctr.$status.BAD_REQUEST).print(error.toString());
|
||||||
|
|
||||||
|
const result = await db.collection("users").updateOne(
|
||||||
|
{ id: auth.userId },
|
||||||
|
{ $set: { subjectColors: data.colors } }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.matchedCount === 0) {
|
||||||
|
return ctr
|
||||||
|
.status(ctr.$status.INTERNAL_SERVER_ERROR)
|
||||||
|
.print({ error: "Failed to update subject colors" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctr.print({ success: true, message: "Subject colors updated successfully" });
|
||||||
|
})
|
||||||
|
);
|
||||||
60
backend/src/routes/exams/bulk-create.ts
Normal file
60
backend/src/routes/exams/bulk-create.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { COOKIENAME, db, fileRouter } from "../../";
|
||||||
|
import { authCheck } from "../../lib/Auth";
|
||||||
|
|
||||||
|
export = new fileRouter.Path("/").http(
|
||||||
|
"POST",
|
||||||
|
"/api/exams/bulk-create",
|
||||||
|
(http) =>
|
||||||
|
http.onRequest(async (ctr) => {
|
||||||
|
// Check authentication
|
||||||
|
const cookie = ctr.cookies.get(COOKIENAME) || null;
|
||||||
|
const apiHeader = (ctr.headers && ctr.headers.get)
|
||||||
|
? ctr.headers.get("api-authentication") || null
|
||||||
|
: null;
|
||||||
|
const auth = await authCheck(cookie, apiHeader);
|
||||||
|
if (!auth.state) {
|
||||||
|
return ctr.status(ctr.$status.UNAUTHORIZED).print({ error: auth.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate request body
|
||||||
|
const [data, error] = await ctr.bindBody((z) =>
|
||||||
|
z.object({
|
||||||
|
exams: z.array(z.object({
|
||||||
|
name: z.string().min(1).max(200),
|
||||||
|
subject: z.string().min(1).max(200),
|
||||||
|
duration: z.number().min(0).optional(),
|
||||||
|
description: z.string().max(5000).optional(),
|
||||||
|
date: z.string().refine((val) => !isNaN(Date.parse(val)), {
|
||||||
|
message: "Invalid date format",
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return ctr.status(ctr.$status.BAD_REQUEST).print({ error: error.toString() });
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const createdExams = data.exams.map((exam) => ({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
name: exam.name,
|
||||||
|
description: exam.description || "",
|
||||||
|
date: new Date(exam.date),
|
||||||
|
result: "",
|
||||||
|
imageUrls: [],
|
||||||
|
userId: auth.userId,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
subject: exam.subject,
|
||||||
|
duration: exam.duration || 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await db.collection("exams").insertMany(createdExams);
|
||||||
|
|
||||||
|
return ctr.status(ctr.$status.CREATED).print({
|
||||||
|
success: true,
|
||||||
|
count: createdExams.length
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
69
backend/src/routes/exams/create.ts
Normal file
69
backend/src/routes/exams/create.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { COOKIENAME, db, fileRouter } from "../../";
|
||||||
|
import { authCheck } from "../../lib/Auth";
|
||||||
|
|
||||||
|
export = new fileRouter.Path("/").http(
|
||||||
|
"POST",
|
||||||
|
"/api/exams/create",
|
||||||
|
(http) =>
|
||||||
|
http.onRequest(async (ctr) => {
|
||||||
|
// Check authentication
|
||||||
|
const cookie = ctr.cookies.get(COOKIENAME) || null;
|
||||||
|
const apiHeader = (ctr.headers && ctr.headers.get)
|
||||||
|
? ctr.headers.get("api-authentication") || null
|
||||||
|
: null;
|
||||||
|
const auth = await authCheck(cookie, apiHeader);
|
||||||
|
if (!auth.state) {
|
||||||
|
return ctr.status(ctr.$status.UNAUTHORIZED).print({ error: auth.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate request body
|
||||||
|
const [data, error] = await ctr.bindBody((z) =>
|
||||||
|
z.object({
|
||||||
|
name: z.string().min(1).max(200),
|
||||||
|
subject: z.string().min(1).max(200),
|
||||||
|
duration: z.number().min(0),
|
||||||
|
description: z.string().max(5000).optional(),
|
||||||
|
date: z.string().refine((val) => !isNaN(Date.parse(val)), {
|
||||||
|
message: "Invalid date format",
|
||||||
|
}),
|
||||||
|
result: z.string().min(1).max(100).optional(),
|
||||||
|
imageUrls: z.array(z.string().url()).optional(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return ctr.status(ctr.$status.BAD_REQUEST).print({ error: error.toString() });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the idea
|
||||||
|
const now = new Date();
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
|
||||||
|
const exam = {
|
||||||
|
id,
|
||||||
|
name: data.name,
|
||||||
|
description: data.description,
|
||||||
|
date: new Date(data.date),
|
||||||
|
result: data.result || "",
|
||||||
|
imageUrls: data.imageUrls || [],
|
||||||
|
userId: auth.userId,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
subject: data.subject,
|
||||||
|
duration: data.duration,
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await db.collection("exams").insertOne(exam);
|
||||||
|
|
||||||
|
if (!res.acknowledged) {
|
||||||
|
return ctr.status(ctr.$status.INTERNAL_SERVER_ERROR).print({
|
||||||
|
error: "Failed to create exam"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctr.print({
|
||||||
|
success: true,
|
||||||
|
exam
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
32
backend/src/routes/exams/delete.ts
Normal file
32
backend/src/routes/exams/delete.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { COOKIENAME, db, fileRouter } from "../../";
|
||||||
|
import { authCheck } from "../../lib/Auth";
|
||||||
|
|
||||||
|
export = new fileRouter.Path("/").http(
|
||||||
|
"DELETE",
|
||||||
|
"/api/exams/delete/{id}",
|
||||||
|
(http) =>
|
||||||
|
http.onRequest(async (ctr) => {
|
||||||
|
// Check authentication
|
||||||
|
const cookie = ctr.cookies.get(COOKIENAME) || null;
|
||||||
|
const apiHeader = ctr.headers?.get?.("api-authentication") || null;
|
||||||
|
const auth = await authCheck(cookie, apiHeader);
|
||||||
|
if (!auth.state) {
|
||||||
|
return ctr.status(ctr.$status.UNAUTHORIZED).print({ error: auth.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get exam ID from request parameters
|
||||||
|
const id = ctr.params.get("id");
|
||||||
|
|
||||||
|
// Delete the exam
|
||||||
|
const res = await db.collection("exams").deleteOne({ id, userId: auth.userId });
|
||||||
|
|
||||||
|
if (res.deletedCount === 0) {
|
||||||
|
return ctr.status(ctr.$status.NOT_FOUND).print({ error: "Exam not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctr.print({
|
||||||
|
success: true,
|
||||||
|
message: "Exam deleted successfully"
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
25
backend/src/routes/exams/get.ts
Normal file
25
backend/src/routes/exams/get.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { COOKIENAME, db, fileRouter } from "../../";
|
||||||
|
import { authCheck } from "../../lib/Auth";
|
||||||
|
|
||||||
|
export = new fileRouter.Path("/").http(
|
||||||
|
"GET",
|
||||||
|
"/api/exams/get/{id}",
|
||||||
|
(http) =>
|
||||||
|
http.onRequest(async (ctr) => {
|
||||||
|
const cookie = ctr.cookies.get(COOKIENAME) || null;
|
||||||
|
const apiHeader = ctr.headers?.get?.("api-authentication") || null;
|
||||||
|
const auth = await authCheck(cookie, apiHeader);
|
||||||
|
if (!auth.state) {
|
||||||
|
return ctr.status(ctr.$status.UNAUTHORIZED).print({ error: auth.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = ctr.params.get("id");
|
||||||
|
const exam = await db.collection("exams").findOne({ id, userId: auth.userId });
|
||||||
|
|
||||||
|
if (!exam) {
|
||||||
|
return ctr.status(ctr.$status.NOT_FOUND).print({ error: "Exam not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctr.print({ success: true, exam });
|
||||||
|
})
|
||||||
|
);
|
||||||
26
backend/src/routes/exams/list.ts
Normal file
26
backend/src/routes/exams/list.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { COOKIENAME, db, fileRouter } from "../../";
|
||||||
|
import { authCheck } from "../../lib/Auth";
|
||||||
|
|
||||||
|
export = new fileRouter.Path("/").http(
|
||||||
|
"GET",
|
||||||
|
"/api/exams/list",
|
||||||
|
(http) =>
|
||||||
|
http.onRequest(async (ctr) => {
|
||||||
|
// Check authentication
|
||||||
|
const cookie = ctr.cookies.get(COOKIENAME) || null;
|
||||||
|
const apiHeader = (ctr.headers && ctr.headers.get)
|
||||||
|
? ctr.headers.get("api-authentication") || null
|
||||||
|
: null;
|
||||||
|
const auth = await authCheck(cookie, apiHeader);
|
||||||
|
if (!auth.state) {
|
||||||
|
return ctr.status(ctr.$status.UNAUTHORIZED).print({ error: auth.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all exams for the authenticated user
|
||||||
|
const exams = await db.collection("exams").find({ userId: auth.userId }).toArray();
|
||||||
|
return ctr.print({
|
||||||
|
success: true,
|
||||||
|
exams
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
51
backend/src/routes/exams/update.ts
Normal file
51
backend/src/routes/exams/update.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { COOKIENAME, db, fileRouter } from "../../";
|
||||||
|
import { authCheck } from "../../lib/Auth";
|
||||||
|
|
||||||
|
export = new fileRouter.Path("/").http(
|
||||||
|
"POST",
|
||||||
|
"/api/exams/update/",
|
||||||
|
(http) =>
|
||||||
|
http.onRequest(async (ctr) => {
|
||||||
|
// Check authentication
|
||||||
|
const cookie = ctr.cookies.get(COOKIENAME) || null;
|
||||||
|
const apiHeader = ctr.headers?.get?.("api-authentication") || null;
|
||||||
|
const auth = await authCheck(cookie, apiHeader);
|
||||||
|
if (!auth.state) {
|
||||||
|
return ctr.status(ctr.$status.UNAUTHORIZED).print({ error: auth.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate request body
|
||||||
|
const [data, error] = await ctr.bindBody((z) =>
|
||||||
|
z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
subject: z.string().min(1).max(200),
|
||||||
|
duration: z.number().min(0).optional(),
|
||||||
|
name: z.string().min(1).max(200).optional(),
|
||||||
|
description: z.string().min(1).max(5000).optional(),
|
||||||
|
date: z.string().refine((val) => !isNaN(Date.parse(val))).optional(),
|
||||||
|
result: z.string().min(1).max(100).optional(),
|
||||||
|
imageUrls: z.array(z.string().url()).optional(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return ctr.status(ctr.$status.BAD_REQUEST).print({ error: error.toString() });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build update object
|
||||||
|
const updateData: any = { ...data, updatedAt: new Date() };
|
||||||
|
if (data.date) updateData.date = new Date(data.date);
|
||||||
|
|
||||||
|
// Update the exam
|
||||||
|
const res = await db.collection("exams").updateOne(
|
||||||
|
{ id: data.id, userId: auth.userId },
|
||||||
|
{ $set: updateData }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res.matchedCount === 0) {
|
||||||
|
return ctr.status(ctr.$status.NOT_FOUND).print({ error: "Exam not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctr.print({ success: true });
|
||||||
|
})
|
||||||
|
);
|
||||||
41
backend/src/types.ts
Normal file
41
backend/src/types.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
interface Session {
|
||||||
|
token: string;
|
||||||
|
userId: string;
|
||||||
|
expiresAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
displayName?: string;
|
||||||
|
iconUrl?: string;
|
||||||
|
passwordHash: string;
|
||||||
|
apiKeys?: ApiKey[];
|
||||||
|
subjectColors?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiKey {
|
||||||
|
id: string;
|
||||||
|
description: string;
|
||||||
|
keyHash: string; // sha256 of the raw key
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Exam {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
date: Date;
|
||||||
|
subject: string; // New field for the subject of the exam
|
||||||
|
duration: number; // Duration of the exam in minutes
|
||||||
|
result: string; // Custom defineable result format, e.g. "A", "B", "C", "Pass", "Fail"
|
||||||
|
userId: string; // Reference to the owning user
|
||||||
|
|
||||||
|
description?: string; // Optional field for additional details about the exam
|
||||||
|
imageUrls?: string[]; // Optional array of image URLs related to the exam (e.g. scanned results, certificates)
|
||||||
|
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Session, User, ApiKey, Exam };
|
||||||
22
backend/tsconfig.json
Normal file
22
backend/tsconfig.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "node16",
|
||||||
|
"strict": true,
|
||||||
|
"moduleResolution": "node16",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"pretty": true,
|
||||||
|
"alwaysStrict": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"declaration": false,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"target": "ES2022",
|
||||||
|
"outDir": "dist",
|
||||||
|
"sourceMap": true,
|
||||||
|
"removeComments": true,
|
||||||
|
"allowUnusedLabels": false,
|
||||||
|
"allowUnreachableCode": false,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"]
|
||||||
|
}
|
||||||
16
docker-compose.yml
Normal file
16
docker-compose.yml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
services:
|
||||||
|
application:
|
||||||
|
image: node:24-slim
|
||||||
|
working_dir: /app
|
||||||
|
volumes:
|
||||||
|
- ./:/app
|
||||||
|
ports:
|
||||||
|
- "$PORT:8080"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
command: sh -c "npm i -g pnpm && cd frontend && pnpm install && pnpm run build && cd ../backend && pnpm install && pnpm run start"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:$PORT/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" class="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Exams — Manage your schedule</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
33
frontend/package.json
Normal file
33
frontend/package.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "exam-manager-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-router-dom": "^6.22.0",
|
||||||
|
"react-toastify": "^10.0.4",
|
||||||
|
"axios": "^1.6.7",
|
||||||
|
"lucide-react": "^0.323.0",
|
||||||
|
"date-fns": "^3.3.1",
|
||||||
|
"clsx": "^2.1.0",
|
||||||
|
"tailwind-merge": "^2.2.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.11.16",
|
||||||
|
"@types/react": "^18.2.55",
|
||||||
|
"@types/react-dom": "^18.2.19",
|
||||||
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
"autoprefixer": "^10.4.17",
|
||||||
|
"postcss": "^8.4.35",
|
||||||
|
"tailwindcss": "^3.4.1",
|
||||||
|
"typescript": "^5.3.3",
|
||||||
|
"vite": "^5.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
90
frontend/src/App.tsx
Normal file
90
frontend/src/App.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Routes, Route, useNavigate, Navigate } from 'react-router-dom';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { ToastContainer, toast } from 'react-toastify';
|
||||||
|
import 'react-toastify/dist/ReactToastify.css';
|
||||||
|
import Login from './pages/Login';
|
||||||
|
import Home from './pages/Home';
|
||||||
|
import Navbar from './components/Navbar';
|
||||||
|
import { User, ApiResponse } from './types';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import AccountPage from './pages/AccountPage';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const checkAuth = async () => {
|
||||||
|
try {
|
||||||
|
const res = await axios.get<ApiResponse<{ user: User }>>('/api/account/fetch');
|
||||||
|
if (res.data.success) {
|
||||||
|
setUser(res.data.user);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setUser(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkAuth();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleLogin = (user: User) => {
|
||||||
|
setUser(user);
|
||||||
|
navigate('/');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
try {
|
||||||
|
await axios.post('/api/account/logout');
|
||||||
|
toast.success('Logged out successfully');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Logout failed', err);
|
||||||
|
toast.error('Logout failed');
|
||||||
|
}
|
||||||
|
setUser(null);
|
||||||
|
navigate('/login');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-apple-dark flex items-center justify-center">
|
||||||
|
<Loader2 className="animate-spin text-apple-accent w-10 h-10" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen w-full bg-apple-dark text-apple-text flex flex-col font-sans">
|
||||||
|
{user && <Navbar onLogout={handleLogout} user={user} />}
|
||||||
|
<main className="flex-grow container mx-auto px-4 py-8 max-w-6xl">
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/login"
|
||||||
|
element={user ? <Navigate to="/" /> : <Login onLogin={handleLogin} />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={user ? <Home user={user} /> : <Navigate to="/login" />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/account"
|
||||||
|
element={
|
||||||
|
user ? (
|
||||||
|
<AccountPage onLogout={handleLogout} onUpdateUser={checkAuth} />
|
||||||
|
) : (
|
||||||
|
<Navigate to="/login" replace />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</main>
|
||||||
|
<ToastContainer position="bottom-right" theme="dark" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
51
frontend/src/components/Navbar.tsx
Normal file
51
frontend/src/components/Navbar.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { LogOut, GraduationCap } from 'lucide-react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { User } from '../types';
|
||||||
|
|
||||||
|
interface NavbarProps {
|
||||||
|
onLogout: () => void;
|
||||||
|
user: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Navbar({ onLogout, user }: NavbarProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
return (
|
||||||
|
<nav className="glass sticky top-0 z-50 px-6 py-3 flex justify-between items-center mx-4 mt-4 rounded-2xl">
|
||||||
|
<div
|
||||||
|
className="flex items-center space-x-2 cursor-pointer transition-opacity hover:opacity-80"
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
>
|
||||||
|
<div className="bg-apple-accent p-1.5 rounded-lg">
|
||||||
|
<GraduationCap className="text-white w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<span className="text-lg font-semibold tracking-tight text-white">
|
||||||
|
Exams
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/account')}
|
||||||
|
className="apple-button flex items-center space-x-2 bg-white/5 hover:bg-white/10 text-white p-1 pr-3"
|
||||||
|
>
|
||||||
|
{user.iconUrl ? (
|
||||||
|
<img src={user.iconUrl} alt="Avatar" className="w-8 h-8 rounded-lg object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-apple-accent flex items-center justify-center text-xs font-bold">
|
||||||
|
{(user.displayName || user.username).charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="hidden sm:inline font-medium">{user.displayName || user.username}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onLogout}
|
||||||
|
className="apple-button flex items-center space-x-2 bg-white/5 hover:bg-white/10 text-white"
|
||||||
|
>
|
||||||
|
<LogOut size={18} />
|
||||||
|
<span className="hidden sm:inline">Logout</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Navbar;
|
||||||
44
frontend/src/index.css
Normal file
44
frontend/src/index.css
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-weight: 400;
|
||||||
|
|
||||||
|
color-scheme: dark;
|
||||||
|
color: #f5f5f7;
|
||||||
|
background-color: #000000;
|
||||||
|
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass {
|
||||||
|
background: rgba(28, 28, 30, 0.7);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.apple-button {
|
||||||
|
@apply px-4 py-2 rounded-full font-medium transition-all duration-200 active:scale-95;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apple-card {
|
||||||
|
@apply bg-apple-card rounded-apple-lg border border-white/5 overflow-hidden;
|
||||||
|
}
|
||||||
13
frontend/src/main.tsx
Normal file
13
frontend/src/main.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App'
|
||||||
|
import './index.css'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
131
frontend/src/modals/ExamModal.tsx
Normal file
131
frontend/src/modals/ExamModal.tsx
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
import { Exam } from '../types';
|
||||||
|
|
||||||
|
interface ExamModalProps {
|
||||||
|
show: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (e: React.FormEvent) => void;
|
||||||
|
formData: {
|
||||||
|
name: string;
|
||||||
|
subject: string;
|
||||||
|
date: string;
|
||||||
|
description: string;
|
||||||
|
result: string;
|
||||||
|
duration: string;
|
||||||
|
};
|
||||||
|
setFormData: React.Dispatch<React.SetStateAction<{
|
||||||
|
name: string;
|
||||||
|
subject: string;
|
||||||
|
date: string;
|
||||||
|
description: string;
|
||||||
|
result: string;
|
||||||
|
duration: string;
|
||||||
|
}>>;
|
||||||
|
editingExam: Exam | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ExamModal: React.FC<ExamModalProps> = ({
|
||||||
|
show,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
formData,
|
||||||
|
setFormData,
|
||||||
|
editingExam
|
||||||
|
}) => {
|
||||||
|
if (!show) return null;
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/80 backdrop-blur-md flex items-center justify-center z-[100] p-4 animate-in fade-in duration-300">
|
||||||
|
<div className="glass w-full max-w-lg rounded-apple-lg shadow-2xl overflow-hidden transform animate-in zoom-in duration-300 border border-white/10">
|
||||||
|
<div className="px-8 py-6 flex justify-between items-center border-b border-white/5">
|
||||||
|
<h2 className="text-2xl font-bold text-white tracking-tight">{editingExam ? 'Exam Details' : 'New Schedule'}</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="bg-white/5 hover:bg-white/10 p-2 rounded-full transition-colors text-apple-muted hover:text-white"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={onSubmit} className="p-8 space-y-6">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-bold text-apple-muted uppercase tracking-widest ml-1">Event Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({...formData, name: e.target.value})}
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 focus:outline-none focus:ring-2 focus:ring-apple-accent/50 transition-all text-white placeholder:text-apple-muted/50"
|
||||||
|
placeholder="e.g. Final Math Exam"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-bold text-apple-muted uppercase tracking-widest ml-1">Subject</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.subject}
|
||||||
|
onChange={(e) => setFormData({...formData, subject: e.target.value})}
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 focus:outline-none focus:ring-2 focus:ring-apple-accent/50 transition-all text-white placeholder:text-apple-muted/50"
|
||||||
|
placeholder="Mathematics"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-bold text-apple-muted uppercase tracking-widest ml-1">Date</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={formData.date}
|
||||||
|
onChange={(e) => setFormData({...formData, date: e.target.value})}
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 focus:outline-none focus:ring-2 focus:ring-apple-accent/50 transition-all text-white [color-scheme:dark]"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-bold text-apple-muted uppercase tracking-widest ml-1">Result / Status</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.result}
|
||||||
|
onChange={(e) => setFormData({ ...formData, result: e.target.value })}
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 focus:outline-none focus:ring-2 focus:ring-apple-accent/50 transition-all text-white placeholder:text-apple-muted/50"
|
||||||
|
placeholder="e.g. Pending, 95/100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-bold text-apple-muted uppercase tracking-widest ml-1">Duration (min)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={formData.duration}
|
||||||
|
onChange={(e) => setFormData({...formData, duration: e.target.value})}
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 focus:outline-none focus:ring-2 focus:ring-apple-accent/50 transition-all text-white placeholder:text-apple-muted/50"
|
||||||
|
placeholder="60"
|
||||||
|
min="0"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-bold text-apple-muted uppercase tracking-widest ml-1">Notes</label>
|
||||||
|
<textarea
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => setFormData({...formData, description: e.target.value})}
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 focus:outline-none focus:ring-2 focus:ring-apple-accent/50 transition-all text-white placeholder:text-apple-muted/50 h-28 resize-none"
|
||||||
|
placeholder="Topic coverage, materials needed..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="pt-4">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="apple-button w-full bg-apple-accent hover:opacity-90 text-white font-semibold py-4 shadow-lg shadow-apple-accent/20 h-14"
|
||||||
|
>
|
||||||
|
{editingExam ? 'Update Schedule' : 'Schedule Exam'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ExamModal;
|
||||||
150
frontend/src/modals/ImportAIModal.tsx
Normal file
150
frontend/src/modals/ImportAIModal.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { X, Copy, Check, Sparkles, Terminal } from 'lucide-react';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
interface ImportAIModalProps {
|
||||||
|
show: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ImportAIModal: React.FC<ImportAIModalProps> = ({ show, onClose, onSuccess }) => {
|
||||||
|
const [step, setStep] = useState(1);
|
||||||
|
const [rawText, setRawText] = useState('');
|
||||||
|
const [jsonText, setJsonText] = useState('');
|
||||||
|
const [isCopying, setIsCopying] = useState(false);
|
||||||
|
const [isImporting, setIsImporting] = useState(false);
|
||||||
|
|
||||||
|
if (!show) return null;
|
||||||
|
|
||||||
|
const promptTemplate = `Hello. Put the content at the end into an array format like this, respond with, yes and the codeblock with the array, i will do the rest, nothing else needed. An object in the array can have "name","description","date","subject", "duration"(number)\n\n`;
|
||||||
|
|
||||||
|
const fullPrompt = promptTemplate + rawText;
|
||||||
|
|
||||||
|
const handleCopyPrompt = () => {
|
||||||
|
navigator.clipboard.writeText(fullPrompt);
|
||||||
|
setIsCopying(true);
|
||||||
|
toast.success('Prompt copied to clipboard!');
|
||||||
|
setTimeout(() => setIsCopying(false), 2000);
|
||||||
|
setStep(2);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImport = async () => {
|
||||||
|
try {
|
||||||
|
setIsImporting(true);
|
||||||
|
// Try to extract JSON from the text if it's wrapped in a codeblock
|
||||||
|
const jsonMatch = jsonText.match(/```(?:json)?\s*([\s\S]*?)\s*```/) || [null, jsonText];
|
||||||
|
const cleanJson = jsonMatch[1].trim();
|
||||||
|
|
||||||
|
const parsed = JSON.parse(cleanJson);
|
||||||
|
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
throw new Error('Response must be an array of exams');
|
||||||
|
}
|
||||||
|
|
||||||
|
await axios.post('/api/exams/bulk-create', { exams: parsed });
|
||||||
|
toast.success(`Successfully imported ${parsed.length} exams!`);
|
||||||
|
onSuccess();
|
||||||
|
onClose();
|
||||||
|
// Reset state for next time
|
||||||
|
setStep(1);
|
||||||
|
setRawText('');
|
||||||
|
setJsonText('');
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(err);
|
||||||
|
toast.error(err.message || 'Failed to parse or import JSON. Make sure it is a valid JSON array.');
|
||||||
|
} finally {
|
||||||
|
setIsImporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/80 backdrop-blur-md flex items-center justify-center z-[100] p-4 animate-in fade-in duration-300">
|
||||||
|
<div className="glass w-full max-w-2xl rounded-apple-lg shadow-2xl overflow-hidden transform animate-in zoom-in duration-300 border border-white/10">
|
||||||
|
<div className="px-8 py-6 flex justify-between items-center border-b border-white/5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Sparkles className="text-apple-accent" size={24} />
|
||||||
|
<h2 className="text-2xl font-bold text-white tracking-tight">Import using AI</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="bg-white/5 hover:bg-white/10 p-2 rounded-full transition-colors text-apple-muted hover:text-white"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-8 space-y-6">
|
||||||
|
{step === 1 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-bold text-apple-muted uppercase tracking-widest ml-1">Paste Raw Exam Data</label>
|
||||||
|
<p className="text-sm text-apple-muted ml-1 mb-2">Copy and paste your exams from a school portal, PDF, or email.</p>
|
||||||
|
<textarea
|
||||||
|
value={rawText}
|
||||||
|
onChange={(e) => setRawText(e.target.value)}
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 focus:outline-none focus:ring-2 focus:ring-apple-accent/50 transition-all text-white placeholder:text-apple-muted/50 h-48 resize-none"
|
||||||
|
placeholder="e.g. Mathematics - Jan 15th 2026 10:00... Physics - Feb 2nd..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
disabled={!rawText.trim()}
|
||||||
|
onClick={handleCopyPrompt}
|
||||||
|
className="w-full flex items-center justify-center gap-2 bg-apple-accent hover:bg-apple-accent-dark disabled:opacity-50 disabled:cursor-not-allowed text-white font-bold py-4 rounded-xl transition-all shadow-lg shadow-apple-accent/20"
|
||||||
|
>
|
||||||
|
{isCopying ? <Check size={20} /> : <Copy size={20} />}
|
||||||
|
{isCopying ? 'Copied!' : 'Copy AI Prompt & Continue'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 2 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-white/5 border border-white/10 rounded-xl p-4 mb-6">
|
||||||
|
<div className="flex items-center gap-2 mb-2 text-apple-accent">
|
||||||
|
<Terminal size={16} />
|
||||||
|
<span className="text-xs font-bold uppercase tracking-widest">Instructions</span>
|
||||||
|
</div>
|
||||||
|
<ol className="text-sm text-apple-muted space-y-2 list-decimal ml-4">
|
||||||
|
<li>Paste the copied prompt into your favorite AI (ChatGPT, Claude, etc.)</li>
|
||||||
|
<li>Copy the resulting JSON codeblock from the AI</li>
|
||||||
|
<li>Paste it into the box below</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-bold text-apple-muted uppercase tracking-widest ml-1">Paste AI Response (JSON)</label>
|
||||||
|
<textarea
|
||||||
|
value={jsonText}
|
||||||
|
onChange={(e) => setJsonText(e.target.value)}
|
||||||
|
className="w-full font-mono text-sm bg-white/5 border border-white/10 rounded-xl px-4 py-3 focus:outline-none focus:ring-2 focus:ring-apple-accent/50 transition-all text-white placeholder:text-apple-muted/50 h-48 resize-none"
|
||||||
|
placeholder='[{"name": "Math Exam", "date": "2026-01-15", ...}]'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4 pt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setStep(1)}
|
||||||
|
className="flex-1 bg-white/5 hover:bg-white/10 text-white font-bold py-4 rounded-xl transition-all"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
disabled={!jsonText.trim() || isImporting}
|
||||||
|
onClick={handleImport}
|
||||||
|
className="flex-[2] flex items-center justify-center gap-2 bg-green-500 hover:bg-green-600 disabled:opacity-50 disabled:cursor-not-allowed text-white font-bold py-4 rounded-xl transition-all shadow-lg shadow-green-500/20"
|
||||||
|
>
|
||||||
|
{isImporting ? <span className="animate-spin">⏳</span> : <Sparkles size={20} />}
|
||||||
|
{isImporting ? 'Importing...' : 'Finalize Import'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ImportAIModal;
|
||||||
265
frontend/src/pages/AccountPage.tsx
Normal file
265
frontend/src/pages/AccountPage.tsx
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
import { Trash2, Download, Loader2, Palette, Check, Save } from 'lucide-react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { User, Exam } from '../types';
|
||||||
|
|
||||||
|
interface AccountPageProps {
|
||||||
|
onLogout: () => void;
|
||||||
|
onUpdateUser: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRESET_COLORS = [
|
||||||
|
'#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6', '#ec4899', '#6366f1', '#14b8a6'
|
||||||
|
];
|
||||||
|
|
||||||
|
const AccountPage: React.FC<AccountPageProps> = ({ onLogout, onUpdateUser }) => {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [subjects, setSubjects] = useState<string[]>([]);
|
||||||
|
const [subjectColors, setSubjectColors] = useState<Record<string, string>>({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [savingColors, setSavingColors] = useState(false);
|
||||||
|
const [savingProfile, setSavingProfile] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const [displayName, setDisplayName] = useState('');
|
||||||
|
const [iconUrl, setIconUrl] = useState('');
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
const [userRes, examsRes] = await Promise.all([
|
||||||
|
axios.get('/api/account/fetch'),
|
||||||
|
axios.get('/api/exams/list')
|
||||||
|
]);
|
||||||
|
|
||||||
|
setUser(userRes.data.user);
|
||||||
|
setSubjectColors(userRes.data.user.subjectColors || {});
|
||||||
|
setDisplayName(userRes.data.user.displayName || '');
|
||||||
|
setIconUrl(userRes.data.user.iconUrl || '');
|
||||||
|
|
||||||
|
const allSubjects = (examsRes.data.exams as Exam[]).map(e => e.subject);
|
||||||
|
setSubjects(Array.from(new Set(allSubjects)));
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleUpdateColor = (subject: string, color: string) => {
|
||||||
|
setSubjectColors(prev => ({ ...prev, [subject]: color }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveColors = async () => {
|
||||||
|
setSavingColors(true);
|
||||||
|
try {
|
||||||
|
await axios.post('/api/account/subject-colors', { colors: subjectColors });
|
||||||
|
await onUpdateUser();
|
||||||
|
toast.success('Subject colors saved successfully!');
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Failed to save subject colors.');
|
||||||
|
} finally {
|
||||||
|
setSavingColors(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveProfile = async () => {
|
||||||
|
setSavingProfile(true);
|
||||||
|
try {
|
||||||
|
await axios.post('/api/account/profile', { displayName, iconUrl });
|
||||||
|
await onUpdateUser();
|
||||||
|
toast.success('Profile updated successfully!');
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Failed to update profile.');
|
||||||
|
} finally {
|
||||||
|
setSavingProfile(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = async () => {
|
||||||
|
try {
|
||||||
|
const res = await axios.get('/api/account/download', { responseType: 'blob' });
|
||||||
|
const url = window.URL.createObjectURL(new Blob([res.data]));
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.setAttribute('download', 'account-data.json');
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.parentNode?.removeChild(link);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Failed to download account data.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!window.confirm('Are you sure you want to delete your account? This cannot be undone.')) return;
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
await axios.delete('/api/account/delete');
|
||||||
|
onLogout();
|
||||||
|
navigate('/login');
|
||||||
|
} catch {
|
||||||
|
setDeleting(false);
|
||||||
|
toast.error('Failed to delete account.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center h-64">
|
||||||
|
<Loader2 className="animate-spin w-10 h-10 text-apple-accent" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto mt-10 space-y-8 animate-in fade-in duration-500">
|
||||||
|
<div className="flex flex-col items-center mb-10 text-center">
|
||||||
|
<div className="bg-apple-accent w-20 h-20 rounded-3xl flex items-center justify-center mb-6 shadow-xl shadow-apple-accent/30 overflow-hidden">
|
||||||
|
{user?.iconUrl ? (
|
||||||
|
<img src={user.iconUrl} alt="Profile" className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<span className="text-3xl font-bold text-white uppercase">{(user?.displayName || user?.username || '?').charAt(0)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h1 className="text-4xl font-bold tracking-tight text-white mb-2">{user?.displayName || user?.username}</h1>
|
||||||
|
{user?.displayName && <p className="text-apple-muted font-medium mb-1">@{user.username}</p>}
|
||||||
|
<p className="text-apple-muted/60 text-xs tracking-widest uppercase">User ID: {user?.id}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="glass rounded-apple-lg overflow-hidden">
|
||||||
|
<div className="p-8 space-y-8">
|
||||||
|
<section>
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-bold text-apple-muted uppercase tracking-widest mb-1">Profile Settings</h3>
|
||||||
|
<p className="text-apple-muted text-sm">Personalize how you appear in the app.</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleSaveProfile}
|
||||||
|
disabled={savingProfile}
|
||||||
|
className="apple-button flex items-center space-x-2 bg-apple-accent hover:opacity-90 text-white py-2 px-4 text-sm"
|
||||||
|
>
|
||||||
|
{savingProfile ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
|
||||||
|
<span className="font-semibold">Save Profile</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-bold text-apple-muted uppercase tracking-widest ml-1">Display Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 focus:outline-none focus:ring-2 focus:ring-apple-accent/50 transition-all text-white placeholder:text-apple-muted/50"
|
||||||
|
placeholder="e.g. John Doe"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-bold text-apple-muted uppercase tracking-widest ml-1">Icon URL</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={iconUrl}
|
||||||
|
onChange={(e) => setIconUrl(e.target.value)}
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 focus:outline-none focus:ring-2 focus:ring-apple-accent/50 transition-all text-white placeholder:text-apple-muted/50"
|
||||||
|
placeholder="https://example.com/avatar.png"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="h-px bg-white/10 w-full" />
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-bold text-apple-muted uppercase tracking-widest mb-1">Subject Themes</h3>
|
||||||
|
<p className="text-apple-muted text-sm">Personalize your calendar by giving each subject a unique color.</p>
|
||||||
|
</div>
|
||||||
|
{subjects.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={handleSaveColors}
|
||||||
|
disabled={savingColors}
|
||||||
|
className="apple-button flex items-center space-x-2 bg-apple-accent hover:opacity-90 text-white py-2 px-4 text-sm"
|
||||||
|
>
|
||||||
|
{savingColors ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
|
||||||
|
<span className="font-semibold">Save Colors</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{subjects.length > 0 ? (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{subjects.map(subject => (
|
||||||
|
<div key={subject} className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 p-4 rounded-2xl bg-white/5 border border-white/5">
|
||||||
|
<span className="font-semibold text-white">{subject}</span>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{PRESET_COLORS.map(color => (
|
||||||
|
<button
|
||||||
|
key={color}
|
||||||
|
onClick={() => handleUpdateColor(subject, color)}
|
||||||
|
className={`w-8 h-8 rounded-full border-2 transition-transform hover:scale-110 ${subjectColors[subject] === color ? 'border-white' : 'border-transparent'}`}
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
>
|
||||||
|
{subjectColors[subject] === color && <Check size={14} className="mx-auto text-white drop-shadow-md" />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={subjectColors[subject] || '#3b82f6'}
|
||||||
|
onChange={(e) => handleUpdateColor(subject, e.target.value)}
|
||||||
|
className="w-8 h-8 bg-transparent border-none cursor-pointer rounded-full overflow-hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 bg-white/5 rounded-2xl border border-dashed border-white/10">
|
||||||
|
<Palette className="mx-auto text-apple-muted mb-2" size={32} />
|
||||||
|
<p className="text-apple-muted italic text-sm">No subjects found. Create an exam first to see them here.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="h-px bg-white/10 w-full" />
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3 className="text-sm font-bold text-apple-muted uppercase tracking-widest mb-4">Export & Visibility</h3>
|
||||||
|
<p className="text-apple-muted mb-6 text-sm">Download all your stored examination data in JSON format for backup or migration purposes.</p>
|
||||||
|
<button
|
||||||
|
onClick={handleDownload}
|
||||||
|
className="apple-button w-full sm:w-auto flex items-center justify-center space-x-2 bg-white/5 hover:bg-white/10 text-white py-3 px-6"
|
||||||
|
>
|
||||||
|
<Download size={18} className="text-apple-accent" />
|
||||||
|
<span className="font-semibold">Download Account Data</span>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="h-px bg-white/10 w-full" />
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3 className="text-sm font-bold text-red-400 uppercase tracking-widest mb-4">Danger Zone</h3>
|
||||||
|
<p className="text-apple-muted mb-6 text-sm">Permanently delete your account and all associated data. This action is irreversible.</p>
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={deleting}
|
||||||
|
className="apple-button w-full sm:w-auto flex items-center justify-center space-x-2 bg-red-500/10 hover:bg-red-500/20 text-red-400 py-3 px-6 border border-red-500/20"
|
||||||
|
>
|
||||||
|
<Trash2 size={18} />
|
||||||
|
<span className="font-semibold">{deleting ? 'Removing Account...' : 'Delete Account'}</span>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AccountPage;
|
||||||
291
frontend/src/pages/Home.tsx
Normal file
291
frontend/src/pages/Home.tsx
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
import { Plus, Edit2, Trash2, Calendar, BookOpen, Loader2, Clock, ChevronDown, ChevronRight, Sparkles } from 'lucide-react';
|
||||||
|
import { format, isFuture, differenceInCalendarDays } from 'date-fns';
|
||||||
|
import { Exam, ApiResponse, User } from '../types';
|
||||||
|
import ExamModal from '../modals/ExamModal';
|
||||||
|
import ImportAIModal from '../modals/ImportAIModal';
|
||||||
|
|
||||||
|
type HomeProps = {
|
||||||
|
user: User;
|
||||||
|
};
|
||||||
|
|
||||||
|
function Home({ user }: HomeProps) {
|
||||||
|
const [exams, setExams] = useState<Exam[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [showImportModal, setShowImportModal] = useState(false);
|
||||||
|
const [showCompleted, setShowCompleted] = useState(false);
|
||||||
|
const [editingExam, setEditingExam] = useState<Exam | null>(null);
|
||||||
|
const [formData, setFormData] = useState({ name: '', subject: '', date: '', description: '', result: '', duration: '' });
|
||||||
|
|
||||||
|
const fetchExams = async () => {
|
||||||
|
try {
|
||||||
|
const res = await axios.get<ApiResponse<{ exams: Exam[] }>>('/api/exams/list');
|
||||||
|
if (res.data.success) {
|
||||||
|
setExams(res.data.exams);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
toast.error('Failed to load exams');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchExams();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleOpenModal = (exam: Exam | null = null) => {
|
||||||
|
if (exam) {
|
||||||
|
setEditingExam(exam);
|
||||||
|
setFormData({
|
||||||
|
name: exam.name,
|
||||||
|
subject: exam.subject,
|
||||||
|
date: new Date(exam.date).toISOString().split('T')[0],
|
||||||
|
description: exam.description || '',
|
||||||
|
result: exam.result || '',
|
||||||
|
duration: exam.duration?.toString() || ''
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setEditingExam(null);
|
||||||
|
setFormData({ name: '', subject: '', date: '', description: '', result: '', duration: '' });
|
||||||
|
}
|
||||||
|
setShowModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
// Convert "" values to undefined
|
||||||
|
const cleanedFormData = Object.fromEntries(
|
||||||
|
Object.entries(formData).map(([k, v]) => {
|
||||||
|
if (k === 'duration') return [k, v === '' ? 0 : parseInt(v) || 0];
|
||||||
|
return [k, v === '' ? undefined : v];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
if (editingExam) {
|
||||||
|
await axios.post('/api/exams/update/', { id: editingExam.id, ...cleanedFormData });
|
||||||
|
} else {
|
||||||
|
await axios.post('/api/exams/create', cleanedFormData);
|
||||||
|
}
|
||||||
|
setShowModal(false);
|
||||||
|
fetchExams();
|
||||||
|
toast.success(editingExam ? 'Exam updated successfully' : 'Exam created successfully');
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Error saving exam');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (window.confirm('Are you sure you want to delete this exam?')) {
|
||||||
|
try {
|
||||||
|
await axios.delete(`/api/exams/delete/${id}`);
|
||||||
|
fetchExams();
|
||||||
|
toast.success('Exam deleted successfully');
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Error deleting exam');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderExamCard = (exam: Exam) => {
|
||||||
|
const examDate = new Date(exam.date);
|
||||||
|
const upcoming = isFuture(examDate);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={exam.id} className="apple-card group transition-all duration-300 hover:translate-y-[-4px] hover:shadow-2xl hover:shadow-white/5">
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-start justify-between mb-6">
|
||||||
|
<div
|
||||||
|
className="p-3 rounded-2xl transition-colors"
|
||||||
|
style={{
|
||||||
|
backgroundColor: user.subjectColors?.[exam.subject] ? `${user.subjectColors[exam.subject]}15` : 'rgba(255,255,255,0.05)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BookOpen
|
||||||
|
style={{ color: user.subjectColors?.[exam.subject] || '#0071e3' }}
|
||||||
|
size={24}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={`px-3 py-1 text-[10px] font-bold tracking-widest rounded-full uppercase ${upcoming ? 'bg-apple-accent/20 text-apple-accent' : 'bg-white/10 text-apple-muted'}`}>
|
||||||
|
{upcoming ? 'Upcoming' : 'Passed'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<h3 className="text-xl font-semibold text-white truncate">{exam.name}</h3>
|
||||||
|
<span
|
||||||
|
className="text-[10px] px-2 py-0.5 rounded-full font-bold tracking-wider uppercase border border-white/5"
|
||||||
|
style={{
|
||||||
|
backgroundColor: user.subjectColors?.[exam.subject] ? `${user.subjectColors[exam.subject]}20` : 'rgba(255,255,255,0.05)',
|
||||||
|
color: user.subjectColors?.[exam.subject] || '#94a3b8',
|
||||||
|
borderColor: user.subjectColors?.[exam.subject] ? `${user.subjectColors[exam.subject]}40` : 'rgba(255,255,255,0.1)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{exam.subject}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-4 text-apple-muted text-sm font-medium">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Calendar size={14} className="mr-1.5" />
|
||||||
|
{format(examDate, 'MMMM d, yyyy')}
|
||||||
|
</div>
|
||||||
|
{exam.duration !== undefined && exam.duration > 0 && (
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Clock size={14} className="mr-1.5" />
|
||||||
|
{exam.duration} min
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{exam.description && (
|
||||||
|
<p className="text-apple-muted text-sm line-clamp-2 mb-6 h-10">
|
||||||
|
{exam.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between pt-4 border-t border-white/5">
|
||||||
|
<button
|
||||||
|
onClick={() => handleOpenModal(exam)}
|
||||||
|
className="text-apple-accent text-sm font-semibold hover:underline flex items-center"
|
||||||
|
>
|
||||||
|
<Edit2 size={14} className="mr-1" />
|
||||||
|
Details
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(exam.id)}
|
||||||
|
className="text-apple-muted hover:text-red-400 p-2 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Compute upcoming exams and next exam
|
||||||
|
const upcomingExams = exams.filter(e => isFuture(new Date(e.date)));
|
||||||
|
const completedExams = exams.filter(e => !isFuture(new Date(e.date))).sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||||||
|
const sortedUpcoming = [...upcomingExams].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
||||||
|
const nextExam = sortedUpcoming[0];
|
||||||
|
const preferredName = user.displayName || user.username;
|
||||||
|
|
||||||
|
let greetingText = "";
|
||||||
|
if (upcomingExams.length === 0) {
|
||||||
|
greetingText = `Welcome back, ${preferredName}.\nYou currently have no upcoming exams scheduled.`;
|
||||||
|
} else if (upcomingExams.length === 1) {
|
||||||
|
const days = differenceInCalendarDays(new Date(nextExam.date), new Date());
|
||||||
|
greetingText = `Hello, ${preferredName}.\nYour next exam is scheduled for ${format(new Date(nextExam.date), 'PPP')} (${days} day${days !== 1 ? 's' : ''} remaining).`;
|
||||||
|
} else {
|
||||||
|
greetingText = `Hello, ${preferredName}.\nYour next evaluation is ${nextExam.name} (${nextExam.subject}), scheduled for ${format(new Date(nextExam.date), 'PPP')}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return (
|
||||||
|
<div className="flex justify-center items-center h-64">
|
||||||
|
<Loader2 className="animate-spin w-10 h-10 text-apple-accent" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-10 animate-in fade-in duration-700">
|
||||||
|
{/* Greeting Section */}
|
||||||
|
<div className="glass rounded-apple-lg p-8 mb-4">
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight text-white mb-2 italic">
|
||||||
|
{greetingText.split('\n')[0]}
|
||||||
|
</h2>
|
||||||
|
<p className="text-apple-muted text-lg leading-relaxed">
|
||||||
|
{greetingText.split('\n')[1]}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-end px-2">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-4xl font-bold tracking-tight text-white mb-1">Upcoming</h1>
|
||||||
|
<p className="text-apple-muted">You have {exams.filter(e => isFuture(new Date(e.date))).length} scheduled evaluations.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowImportModal(true)}
|
||||||
|
className="apple-button bg-white/5 hover:bg-white/10 text-white px-6 py-3 flex items-center space-x-2 border border-white/10"
|
||||||
|
>
|
||||||
|
<Sparkles size={18} className="text-apple-accent" />
|
||||||
|
<span className="font-semibold text-sm">Import using AI</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleOpenModal()}
|
||||||
|
className="apple-button bg-apple-accent hover:bg-opacity-90 text-white px-6 py-3 flex items-center space-x-2 shadow-lg shadow-apple-accent/20"
|
||||||
|
>
|
||||||
|
<Plus size={20} />
|
||||||
|
<span className="font-semibold">Add Exam</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{sortedUpcoming.map(renderExamCard)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{completedExams.length > 0 && (
|
||||||
|
<div className="pt-10 border-t border-white/5">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCompleted(!showCompleted)}
|
||||||
|
className="flex items-center space-x-2 text-apple-muted hover:text-white transition-colors group mb-6"
|
||||||
|
>
|
||||||
|
{showCompleted ? <ChevronDown size={20} /> : <ChevronRight size={20} />}
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">Completed Exams</h2>
|
||||||
|
<span className="bg-white/5 px-2 py-0.5 rounded-md text-sm font-bold">{completedExams.length}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showCompleted && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 animate-in fade-in slide-in-from-top-4 duration-500">
|
||||||
|
{completedExams.map(renderExamCard)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{exams.length === 0 && (
|
||||||
|
<div className="text-center py-24 glass rounded-apple-lg">
|
||||||
|
<div className="bg-apple-secondary w-16 h-16 rounded-3xl flex items-center justify-center mx-auto mb-6">
|
||||||
|
<BookOpen size={32} className="text-apple-muted" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold text-white">No exams yet</h2>
|
||||||
|
<p className="text-apple-muted mt-2 max-w-sm mx-auto">Looks like you're all caught up. Add a new exam to start tracking your progress.</p>
|
||||||
|
<button
|
||||||
|
onClick={() => handleOpenModal()}
|
||||||
|
className="apple-button bg-white/5 hover:bg-white/10 text-white mt-8"
|
||||||
|
>
|
||||||
|
Create your first exam
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showModal && (
|
||||||
|
<ExamModal
|
||||||
|
show={showModal}
|
||||||
|
onClose={() => setShowModal(false)}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
formData={formData}
|
||||||
|
setFormData={setFormData}
|
||||||
|
editingExam={editingExam}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showImportModal && (
|
||||||
|
<ImportAIModal
|
||||||
|
show={showImportModal}
|
||||||
|
onClose={() => setShowImportModal(false)}
|
||||||
|
onSuccess={fetchExams}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Home;
|
||||||
112
frontend/src/pages/Login.tsx
Normal file
112
frontend/src/pages/Login.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { GraduationCap, Loader2 } from 'lucide-react';
|
||||||
|
import { User, ApiResponse } from '../types';
|
||||||
|
|
||||||
|
interface LoginProps {
|
||||||
|
onLogin: (user: User) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Login({ onLogin }: LoginProps) {
|
||||||
|
const [isRegistering, setIsRegistering] = useState(false);
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const endpoint = isRegistering ? '/api/account/register' : '/api/account/login';
|
||||||
|
const res = await axios.post<ApiResponse<any>>(endpoint, { username, password });
|
||||||
|
|
||||||
|
if (res.data.success) {
|
||||||
|
const userRes = await axios.get<ApiResponse<{ user: User }>>('/api/account/fetch');
|
||||||
|
if (userRes.data.success) {
|
||||||
|
onLogin(userRes.data.user);
|
||||||
|
} else {
|
||||||
|
setError('Failed to fetch user data');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setError(res.data.error || 'Authentication failed');
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.error || err.response?.data || 'An error occurred');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[70vh] animate-in fade-in zoom-in duration-500">
|
||||||
|
<div className="glass p-10 rounded-apple-lg w-full max-w-md shadow-2xl">
|
||||||
|
<div className="flex flex-col items-center mb-10">
|
||||||
|
<div className="bg-apple-accent p-4 rounded-2xl mb-6 shadow-lg shadow-apple-accent/20">
|
||||||
|
<GraduationCap className="text-white w-10 h-10" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-4xl font-bold tracking-tight text-white mb-2 leading-tight">
|
||||||
|
{isRegistering ? 'Join Us' : 'Welcome back.'}
|
||||||
|
</h1>
|
||||||
|
<p className="text-apple-muted text-center font-medium">
|
||||||
|
{isRegistering ? 'Create an account to start tracking your exams' : 'Enter your credentials to access your schedule'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-bold text-apple-muted uppercase tracking-wider ml-1">Username</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 focus:outline-none focus:ring-2 focus:ring-apple-accent/50 transition-all text-white placeholder:text-apple-muted/50"
|
||||||
|
placeholder="Username"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-bold text-apple-muted uppercase tracking-wider ml-1">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 focus:outline-none focus:ring-2 focus:ring-apple-accent/50 transition-all text-white placeholder:text-apple-muted/50"
|
||||||
|
placeholder="Password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-500/10 border border-red-500/20 text-red-400 text-sm px-4 py-3 rounded-xl animate-in shake duration-300">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="apple-button w-full bg-apple-accent hover:opacity-90 text-white font-semibold py-4 flex items-center justify-center space-x-2 shadow-lg shadow-apple-accent/20 mt-4 h-14"
|
||||||
|
>
|
||||||
|
{loading ? <Loader2 className="animate-spin" size={20} /> : <span>{isRegistering ? 'Create Account' : 'Sign In'}</span>}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-8 pt-8 border-t border-white/10 text-center">
|
||||||
|
<p className="text-apple-muted">
|
||||||
|
{isRegistering ? 'Already have an account?' : "Don't have an account?"}{' '}
|
||||||
|
<button
|
||||||
|
onClick={() => setIsRegistering(!isRegistering)}
|
||||||
|
className="text-apple-accent hover:underline font-semibold ml-1"
|
||||||
|
>
|
||||||
|
{isRegistering ? 'Sign In' : 'Sign Up'}
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Login;
|
||||||
28
frontend/src/types.ts
Normal file
28
frontend/src/types.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
displayName?: string;
|
||||||
|
iconUrl?: string;
|
||||||
|
subjectColors?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Exam {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
date: string;
|
||||||
|
result: string;
|
||||||
|
userId: string;
|
||||||
|
imageUrls?: string[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
subject: string;
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiResponse<T> {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
27
frontend/tailwind.config.js
Normal file
27
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
darkMode: 'class',
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
apple: {
|
||||||
|
dark: '#000000',
|
||||||
|
card: '#1c1c1e',
|
||||||
|
secondary: '#2c2c2e',
|
||||||
|
accent: '#0a84ff',
|
||||||
|
text: '#f5f5f7',
|
||||||
|
muted: '#86868b'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
'apple': '12px',
|
||||||
|
'apple-lg': '20px'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
25
frontend/tsconfig.json
Normal file
25
frontend/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
12
frontend/vite.config.ts
Normal file
12
frontend/vite.config.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:8000',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user