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