Add study materials field to exam routes and update ExamModal for handling materials

This commit is contained in:
Space-Banane
2026-02-13 22:35:35 +01:00
parent 056519c0f1
commit d3cfca16f9
8 changed files with 124 additions and 14 deletions

View File

@@ -43,6 +43,7 @@ export = new fileRouter.Path("/").http(
date: new Date(exam.date), date: new Date(exam.date),
result: "", result: "",
imageUrls: [], imageUrls: [],
studyMaterials: [],
userId: auth.userId, userId: auth.userId,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,

View File

@@ -28,6 +28,7 @@ export = new fileRouter.Path("/").http(
}), }),
result: z.string().min(1).max(100).optional(), result: z.string().min(1).max(100).optional(),
imageUrls: z.array(z.string().url()).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), date: new Date(data.date),
result: data.result || "", result: data.result || "",
imageUrls: data.imageUrls || [], imageUrls: data.imageUrls || [],
studyMaterials: data.studyMaterials || [],
userId: auth.userId, userId: auth.userId,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,

View File

@@ -25,6 +25,7 @@ export = new fileRouter.Path("/").http(
date: z.string().refine((val) => !isNaN(Date.parse(val))).optional(), date: z.string().refine((val) => !isNaN(Date.parse(val))).optional(),
result: z.string().min(1).max(100).optional(), result: z.string().min(1).max(100).optional(),
imageUrls: z.array(z.string().url()).optional(), imageUrls: z.array(z.string().url()).optional(),
studyMaterials: z.array(z.string().url()).optional(),
}) })
); );

View File

@@ -33,6 +33,7 @@ interface Exam {
description?: string; // Optional field for additional details about the 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) 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; createdAt: Date;
updatedAt: Date; updatedAt: Date;

View File

@@ -1,6 +1,6 @@
services: services:
application: application:
image: node:24-slim image: node:24
working_dir: /app working_dir: /app
volumes: volumes:
- ./:/app - ./:/app
@@ -8,9 +8,4 @@ services:
- "$PORT:8080" - "$PORT:8080"
env_file: env_file:
- .env - .env
command: sh -c "git pull && npm i -g pnpm && cd frontend && pnpm install && pnpm run build && cd ../backend && pnpm install && pnpm run start" 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

View File

@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { X } from 'lucide-react'; import { X, Plus, Trash2, Link as LinkIcon } from 'lucide-react';
import { Exam } from '../types'; import { Exam } from '../types';
interface ExamModalProps { interface ExamModalProps {
@@ -13,6 +13,7 @@ interface ExamModalProps {
description: string; description: string;
result: string; result: string;
duration: string; duration: string;
studyMaterials: string[];
}; };
setFormData: React.Dispatch<React.SetStateAction<{ setFormData: React.Dispatch<React.SetStateAction<{
name: string; name: string;
@@ -21,6 +22,7 @@ interface ExamModalProps {
description: string; description: string;
result: string; result: string;
duration: string; duration: string;
studyMaterials: string[];
}>>; }>>;
editingExam: Exam | null; editingExam: Exam | null;
} }
@@ -33,6 +35,40 @@ const ExamModal: React.FC<ExamModalProps> = ({
setFormData, setFormData,
editingExam 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; if (!show) return null;
return ( 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="fixed inset-0 bg-black/80 backdrop-blur-md flex items-center justify-center z-[100] p-4 animate-in fade-in duration-300">
@@ -114,6 +150,50 @@ const ExamModal: React.FC<ExamModalProps> = ({
placeholder="Topic coverage, materials needed..." placeholder="Topic coverage, materials needed..."
/> />
</div> </div>
<div className="space-y-3">
<label className="block text-xs font-bold text-apple-muted uppercase tracking-widest ml-1">Study Materials</label>
<div className="flex gap-2">
<input
type="url"
value={newMaterial}
onChange={(e) => 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"
/>
<button
type="button"
onClick={addMaterial}
className="bg-apple-accent p-3 rounded-xl text-white hover:opacity-90 transition-opacity"
>
<Plus size={20} />
</button>
</div>
{formData.studyMaterials.length > 0 && (
<div className="space-y-2 max-h-32 overflow-y-auto pr-2 custom-scrollbar">
{formData.studyMaterials.map((url, index) => (
<div key={index} className="flex items-center justify-between bg-white/5 rounded-lg px-3 py-2 border border-white/5 group">
<div className="flex items-center gap-2 truncate text-sm text-apple-muted">
<LinkIcon size={14} className="flex-shrink-0" />
<span className="truncate">{url}</span>
</div>
<button
type="button"
onClick={() => removeMaterial(index)}
className="text-apple-muted hover:text-red-400 opacity-0 group-hover:opacity-100 transition-all"
>
<Trash2 size={14} />
</button>
</div>
))}
</div>
)}
</div>
<div className="pt-4"> <div className="pt-4">
<button <button
type="submit" type="submit"

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import axios from 'axios'; import axios from 'axios';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { Plus, Edit2, Trash2, Calendar, BookOpen, Loader2, Clock, ChevronDown, ChevronRight, Sparkles } from 'lucide-react'; import { Plus, Edit2, Trash2, Calendar, BookOpen, Loader2, Clock, ChevronDown, ChevronRight, Sparkles, Link as LinkIcon, ExternalLink } from 'lucide-react';
import { format, isFuture, differenceInCalendarDays, formatDistanceToNow } from 'date-fns'; import { format, isFuture, differenceInCalendarDays, formatDistanceToNow } from 'date-fns';
import { Exam, ApiResponse, User } from '../types'; import { Exam, ApiResponse, User } from '../types';
import ExamModal from '../modals/ExamModal'; import ExamModal from '../modals/ExamModal';
@@ -11,6 +11,15 @@ type HomeProps = {
user: User; user: User;
}; };
const getDomain = (url: string) => {
try {
const domain = new URL(url).hostname.replace('www.', '');
return domain;
} catch {
return url;
}
};
function Home({ user }: HomeProps) { function Home({ user }: HomeProps) {
const [exams, setExams] = useState<Exam[]>([]); const [exams, setExams] = useState<Exam[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -18,7 +27,7 @@ function Home({ user }: HomeProps) {
const [showImportModal, setShowImportModal] = useState(false); const [showImportModal, setShowImportModal] = useState(false);
const [showCompleted, setShowCompleted] = useState(false); const [showCompleted, setShowCompleted] = useState(false);
const [editingExam, setEditingExam] = useState<Exam | null>(null); const [editingExam, setEditingExam] = useState<Exam | null>(null);
const [formData, setFormData] = useState({ name: '', subject: '', date: '', description: '', result: '', duration: '' }); const [formData, setFormData] = useState({ name: '', subject: '', date: '', description: '', result: '', duration: '', studyMaterials: [] as string[] });
const fetchExams = async () => { const fetchExams = async () => {
try { try {
@@ -47,11 +56,12 @@ function Home({ user }: HomeProps) {
date: new Date(exam.date).toISOString().split('T')[0], date: new Date(exam.date).toISOString().split('T')[0],
description: exam.description || '', description: exam.description || '',
result: exam.result || '', result: exam.result || '',
duration: exam.duration?.toString() || '' duration: exam.duration?.toString() || '',
studyMaterials: exam.studyMaterials || []
}); });
} else { } else {
setEditingExam(null); setEditingExam(null);
setFormData({ name: '', subject: '', date: '', description: '', result: '', duration: '' }); setFormData({ name: '', subject: '', date: '', description: '', result: '', duration: '', studyMaterials: [] });
} }
setShowModal(true); setShowModal(true);
}; };
@@ -61,8 +71,8 @@ function Home({ user }: HomeProps) {
// Convert "" values to undefined // Convert "" values to undefined
const cleanedFormData = Object.fromEntries( const cleanedFormData = Object.fromEntries(
Object.entries(formData).map(([k, v]) => { Object.entries(formData).map(([k, v]) => {
if (k === 'duration') return [k, v === '' ? 0 : parseInt(v) || 0]; if (k === 'duration') return [k, (v as any) === '' ? 0 : parseInt(v as any) || 0];
return [k, v === '' ? undefined : v]; return [k, (v as any) === '' ? undefined : v];
}) })
); );
try { try {
@@ -143,6 +153,25 @@ function Home({ user }: HomeProps) {
</div> </div>
</div> </div>
{exam.studyMaterials && exam.studyMaterials.length > 0 && (
<div className="flex flex-wrap gap-2 mb-4">
{exam.studyMaterials.map((url, i) => (
<a
key={i}
href={url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => 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"
>
<LinkIcon size={12} />
{getDomain(url)}
<ExternalLink size={10} className="opacity-0 group-hover/link:opacity-100 transition-opacity" />
</a>
))}
</div>
)}
{exam.description && ( {exam.description && (
<p className="text-apple-muted text-sm line-clamp-2 mb-6 h-10"> <p className="text-apple-muted text-sm line-clamp-2 mb-6 h-10">
{exam.description} {exam.description}

View File

@@ -14,6 +14,7 @@ export interface Exam {
result: string; result: string;
userId: string; userId: string;
imageUrls?: string[]; imageUrls?: string[];
studyMaterials?: string[];
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
subject: string; subject: string;