diff --git a/backend/src/routes/exams/bulk-create.ts b/backend/src/routes/exams/bulk-create.ts index 1131b0f..de5bad7 100644 --- a/backend/src/routes/exams/bulk-create.ts +++ b/backend/src/routes/exams/bulk-create.ts @@ -43,6 +43,7 @@ export = new fileRouter.Path("/").http( date: new Date(exam.date), result: "", imageUrls: [], + studyMaterials: [], userId: auth.userId, createdAt: now, updatedAt: now, diff --git a/backend/src/routes/exams/create.ts b/backend/src/routes/exams/create.ts index 2910bee..506961c 100644 --- a/backend/src/routes/exams/create.ts +++ b/backend/src/routes/exams/create.ts @@ -28,6 +28,7 @@ export = new fileRouter.Path("/").http( }), result: z.string().min(1).max(100).optional(), imageUrls: z.array(z.string().url()).optional(), + studyMaterials: z.array(z.string().url()).optional(), }) ); @@ -46,6 +47,7 @@ export = new fileRouter.Path("/").http( date: new Date(data.date), result: data.result || "", imageUrls: data.imageUrls || [], + studyMaterials: data.studyMaterials || [], userId: auth.userId, createdAt: now, updatedAt: now, diff --git a/backend/src/routes/exams/update.ts b/backend/src/routes/exams/update.ts index 8250270..b950c79 100644 --- a/backend/src/routes/exams/update.ts +++ b/backend/src/routes/exams/update.ts @@ -25,6 +25,7 @@ export = new fileRouter.Path("/").http( 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(), + studyMaterials: z.array(z.string().url()).optional(), }) ); diff --git a/backend/src/types.ts b/backend/src/types.ts index 9a71758..a071885 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -33,6 +33,7 @@ interface Exam { 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) + studyMaterials?: string[]; // Optional array of study materials URLs createdAt: Date; updatedAt: Date; diff --git a/docker-compose.yml b/docker-compose.yml index e86cd45..069fb4b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: application: - image: node:24-slim + image: node:24 working_dir: /app volumes: - ./:/app @@ -8,9 +8,4 @@ services: - "$PORT:8080" env_file: - .env - command: sh -c "git pull && 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 + command: sh -c "npm i -g pnpm && cd frontend && pnpm install && pnpm run build && cd ../backend && pnpm install && pnpm run start" diff --git a/frontend/src/modals/ExamModal.tsx b/frontend/src/modals/ExamModal.tsx index 86b1ce1..dc3faa9 100644 --- a/frontend/src/modals/ExamModal.tsx +++ b/frontend/src/modals/ExamModal.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { X } from 'lucide-react'; +import { X, Plus, Trash2, Link as LinkIcon } from 'lucide-react'; import { Exam } from '../types'; interface ExamModalProps { @@ -13,6 +13,7 @@ interface ExamModalProps { description: string; result: string; duration: string; + studyMaterials: string[]; }; setFormData: React.Dispatch>; editingExam: Exam | null; } @@ -33,6 +35,40 @@ const ExamModal: React.FC = ({ setFormData, editingExam }) => { + const [newMaterial, setNewMaterial] = React.useState(''); + + const addMaterial = () => { + const trimmedMaterial = newMaterial.trim(); + + if (!trimmedMaterial) { + return; + } + + try { + // Validate URL format; will throw if invalid + // eslint-disable-next-line no-new + new URL(trimmedMaterial); + } catch { + // Invalid URL; do not add to study materials + return; + } + + if (!formData.studyMaterials.includes(trimmedMaterial)) { + setFormData({ + ...formData, + studyMaterials: [...formData.studyMaterials, trimmedMaterial] + }); + setNewMaterial(''); + } + }; + + const removeMaterial = (index: number) => { + setFormData({ + ...formData, + studyMaterials: formData.studyMaterials.filter((_, i) => i !== index) + }); + }; + if (!show) return null; return (
@@ -114,6 +150,50 @@ const ExamModal: React.FC = ({ placeholder="Topic coverage, materials needed..." />
+
+ +
+ setNewMaterial(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + addMaterial(); + } + }} + className="flex-1 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/notes" + /> + +
+ {formData.studyMaterials.length > 0 && ( +
+ {formData.studyMaterials.map((url, index) => ( +
+
+ + {url} +
+ +
+ ))} +
+ )} +
+ {exam.studyMaterials && exam.studyMaterials.length > 0 && ( +
+ {exam.studyMaterials.map((url, i) => ( + e.stopPropagation()} + className="group/link flex items-center gap-1.5 px-3 py-1.5 bg-white/5 hover:bg-white/10 border border-white/5 rounded-full text-[11px] font-medium text-apple-muted hover:text-white transition-all" + > + + {getDomain(url)} + + + ))} +
+ )} + {exam.description && (

{exam.description} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index c35e97b..fe2becf 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -14,6 +14,7 @@ export interface Exam { result: string; userId: string; imageUrls?: string[]; + studyMaterials?: string[]; createdAt: string; updatedAt: string; subject: string;