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),
result: "",
imageUrls: [],
studyMaterials: [],
userId: auth.userId,
createdAt: now,
updatedAt: now,

View File

@@ -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,

View File

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

View File

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

View File

@@ -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"

View File

@@ -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<React.SetStateAction<{
name: string;
@@ -21,6 +22,7 @@ interface ExamModalProps {
description: string;
result: string;
duration: string;
studyMaterials: string[];
}>>;
editingExam: Exam | null;
}
@@ -33,6 +35,40 @@ const ExamModal: React.FC<ExamModalProps> = ({
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 (
<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..."
/>
</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">
<button
type="submit"

View File

@@ -1,7 +1,7 @@
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 { 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 { Exam, ApiResponse, User } from '../types';
import ExamModal from '../modals/ExamModal';
@@ -11,6 +11,15 @@ type HomeProps = {
user: User;
};
const getDomain = (url: string) => {
try {
const domain = new URL(url).hostname.replace('www.', '');
return domain;
} catch {
return url;
}
};
function Home({ user }: HomeProps) {
const [exams, setExams] = useState<Exam[]>([]);
const [loading, setLoading] = useState(true);
@@ -18,7 +27,7 @@ function Home({ user }: HomeProps) {
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 [formData, setFormData] = useState({ name: '', subject: '', date: '', description: '', result: '', duration: '', studyMaterials: [] as string[] });
const fetchExams = async () => {
try {
@@ -47,11 +56,12 @@ function Home({ user }: HomeProps) {
date: new Date(exam.date).toISOString().split('T')[0],
description: exam.description || '',
result: exam.result || '',
duration: exam.duration?.toString() || ''
duration: exam.duration?.toString() || '',
studyMaterials: exam.studyMaterials || []
});
} else {
setEditingExam(null);
setFormData({ name: '', subject: '', date: '', description: '', result: '', duration: '' });
setFormData({ name: '', subject: '', date: '', description: '', result: '', duration: '', studyMaterials: [] });
}
setShowModal(true);
};
@@ -61,8 +71,8 @@ function Home({ user }: HomeProps) {
// 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];
if (k === 'duration') return [k, (v as any) === '' ? 0 : parseInt(v as any) || 0];
return [k, (v as any) === '' ? undefined : v];
})
);
try {
@@ -143,6 +153,25 @@ function Home({ user }: HomeProps) {
</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 && (
<p className="text-apple-muted text-sm line-clamp-2 mb-6 h-10">
{exam.description}

View File

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