first commit

This commit is contained in:
Space-Banane
2026-02-09 19:26:40 +01:00
commit ead561d10b
42 changed files with 2364 additions and 0 deletions

44
.github/copilot-instructions.md vendored Normal file
View 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
View 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
View 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
View 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
View 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,
};
}

View 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 });
})
);

View 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" });
})
);

View 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));
})
);

View 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
},
});
})
);

View 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 });
})
);

View 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" });
})
);

View 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" });
})
);

View 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" });
})
);

View 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 });
})
);

View 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" });
})
);

View 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
});
})
);

View 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
});
})
);

View 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"
});
})
);

View 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 });
})
);

View 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
});
})
);

View 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
View 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
View 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
View 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
View 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
View 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"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

90
frontend/src/App.tsx Normal file
View 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;

View 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
View 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
View 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>,
)

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

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

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

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

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View 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
View 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" }]
}

View 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
View 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',
}
}
})