first commit

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

291
frontend/src/pages/Home.tsx Normal file
View File

@@ -0,0 +1,291 @@
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import { toast } from 'react-toastify';
import { Plus, Edit2, Trash2, Calendar, BookOpen, Loader2, Clock, ChevronDown, ChevronRight, Sparkles } from 'lucide-react';
import { format, isFuture, differenceInCalendarDays } from 'date-fns';
import { Exam, ApiResponse, User } from '../types';
import ExamModal from '../modals/ExamModal';
import ImportAIModal from '../modals/ImportAIModal';
type HomeProps = {
user: User;
};
function Home({ user }: HomeProps) {
const [exams, setExams] = useState<Exam[]>([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [showImportModal, setShowImportModal] = useState(false);
const [showCompleted, setShowCompleted] = useState(false);
const [editingExam, setEditingExam] = useState<Exam | null>(null);
const [formData, setFormData] = useState({ name: '', subject: '', date: '', description: '', result: '', duration: '' });
const fetchExams = async () => {
try {
const res = await axios.get<ApiResponse<{ exams: Exam[] }>>('/api/exams/list');
if (res.data.success) {
setExams(res.data.exams);
}
} catch (err) {
console.error(err);
toast.error('Failed to load exams');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchExams();
}, []);
const handleOpenModal = (exam: Exam | null = null) => {
if (exam) {
setEditingExam(exam);
setFormData({
name: exam.name,
subject: exam.subject,
date: new Date(exam.date).toISOString().split('T')[0],
description: exam.description || '',
result: exam.result || '',
duration: exam.duration?.toString() || ''
});
} else {
setEditingExam(null);
setFormData({ name: '', subject: '', date: '', description: '', result: '', duration: '' });
}
setShowModal(true);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Convert "" values to undefined
const cleanedFormData = Object.fromEntries(
Object.entries(formData).map(([k, v]) => {
if (k === 'duration') return [k, v === '' ? 0 : parseInt(v) || 0];
return [k, v === '' ? undefined : v];
})
);
try {
if (editingExam) {
await axios.post('/api/exams/update/', { id: editingExam.id, ...cleanedFormData });
} else {
await axios.post('/api/exams/create', cleanedFormData);
}
setShowModal(false);
fetchExams();
toast.success(editingExam ? 'Exam updated successfully' : 'Exam created successfully');
} catch (err) {
toast.error('Error saving exam');
}
};
const handleDelete = async (id: string) => {
if (window.confirm('Are you sure you want to delete this exam?')) {
try {
await axios.delete(`/api/exams/delete/${id}`);
fetchExams();
toast.success('Exam deleted successfully');
} catch (err) {
toast.error('Error deleting exam');
}
}
};
const renderExamCard = (exam: Exam) => {
const examDate = new Date(exam.date);
const upcoming = isFuture(examDate);
return (
<div key={exam.id} className="apple-card group transition-all duration-300 hover:translate-y-[-4px] hover:shadow-2xl hover:shadow-white/5">
<div className="p-6">
<div className="flex items-start justify-between mb-6">
<div
className="p-3 rounded-2xl transition-colors"
style={{
backgroundColor: user.subjectColors?.[exam.subject] ? `${user.subjectColors[exam.subject]}15` : 'rgba(255,255,255,0.05)'
}}
>
<BookOpen
style={{ color: user.subjectColors?.[exam.subject] || '#0071e3' }}
size={24}
/>
</div>
<div className={`px-3 py-1 text-[10px] font-bold tracking-widest rounded-full uppercase ${upcoming ? 'bg-apple-accent/20 text-apple-accent' : 'bg-white/10 text-apple-muted'}`}>
{upcoming ? 'Upcoming' : 'Passed'}
</div>
</div>
<div className="mb-6">
<div className="flex items-center gap-2 mb-1">
<h3 className="text-xl font-semibold text-white truncate">{exam.name}</h3>
<span
className="text-[10px] px-2 py-0.5 rounded-full font-bold tracking-wider uppercase border border-white/5"
style={{
backgroundColor: user.subjectColors?.[exam.subject] ? `${user.subjectColors[exam.subject]}20` : 'rgba(255,255,255,0.05)',
color: user.subjectColors?.[exam.subject] || '#94a3b8',
borderColor: user.subjectColors?.[exam.subject] ? `${user.subjectColors[exam.subject]}40` : 'rgba(255,255,255,0.1)'
}}
>
{exam.subject}
</span>
</div>
<div className="flex items-center space-x-4 text-apple-muted text-sm font-medium">
<div className="flex items-center">
<Calendar size={14} className="mr-1.5" />
{format(examDate, 'MMMM d, yyyy')}
</div>
{exam.duration !== undefined && exam.duration > 0 && (
<div className="flex items-center">
<Clock size={14} className="mr-1.5" />
{exam.duration} min
</div>
)}
</div>
</div>
{exam.description && (
<p className="text-apple-muted text-sm line-clamp-2 mb-6 h-10">
{exam.description}
</p>
)}
<div className="flex items-center justify-between pt-4 border-t border-white/5">
<button
onClick={() => handleOpenModal(exam)}
className="text-apple-accent text-sm font-semibold hover:underline flex items-center"
>
<Edit2 size={14} className="mr-1" />
Details
</button>
<button
onClick={() => handleDelete(exam.id)}
className="text-apple-muted hover:text-red-400 p-2 transition-colors"
>
<Trash2 size={16} />
</button>
</div>
</div>
</div>
);
};
// Compute upcoming exams and next exam
const upcomingExams = exams.filter(e => isFuture(new Date(e.date)));
const completedExams = exams.filter(e => !isFuture(new Date(e.date))).sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
const sortedUpcoming = [...upcomingExams].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
const nextExam = sortedUpcoming[0];
const preferredName = user.displayName || user.username;
let greetingText = "";
if (upcomingExams.length === 0) {
greetingText = `Welcome back, ${preferredName}.\nYou currently have no upcoming exams scheduled.`;
} else if (upcomingExams.length === 1) {
const days = differenceInCalendarDays(new Date(nextExam.date), new Date());
greetingText = `Hello, ${preferredName}.\nYour next exam is scheduled for ${format(new Date(nextExam.date), 'PPP')} (${days} day${days !== 1 ? 's' : ''} remaining).`;
} else {
greetingText = `Hello, ${preferredName}.\nYour next evaluation is ${nextExam.name} (${nextExam.subject}), scheduled for ${format(new Date(nextExam.date), 'PPP')}.`;
}
if (loading) return (
<div className="flex justify-center items-center h-64">
<Loader2 className="animate-spin w-10 h-10 text-apple-accent" />
</div>
);
return (
<div className="space-y-10 animate-in fade-in duration-700">
{/* Greeting Section */}
<div className="glass rounded-apple-lg p-8 mb-4">
<h2 className="text-3xl font-bold tracking-tight text-white mb-2 italic">
{greetingText.split('\n')[0]}
</h2>
<p className="text-apple-muted text-lg leading-relaxed">
{greetingText.split('\n')[1]}
</p>
</div>
<div className="flex justify-between items-end px-2">
<div>
<h1 className="text-4xl font-bold tracking-tight text-white mb-1">Upcoming</h1>
<p className="text-apple-muted">You have {exams.filter(e => isFuture(new Date(e.date))).length} scheduled evaluations.</p>
</div>
<div className="flex gap-4">
<button
onClick={() => setShowImportModal(true)}
className="apple-button bg-white/5 hover:bg-white/10 text-white px-6 py-3 flex items-center space-x-2 border border-white/10"
>
<Sparkles size={18} className="text-apple-accent" />
<span className="font-semibold text-sm">Import using AI</span>
</button>
<button
onClick={() => handleOpenModal()}
className="apple-button bg-apple-accent hover:bg-opacity-90 text-white px-6 py-3 flex items-center space-x-2 shadow-lg shadow-apple-accent/20"
>
<Plus size={20} />
<span className="font-semibold">Add Exam</span>
</button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{sortedUpcoming.map(renderExamCard)}
</div>
{completedExams.length > 0 && (
<div className="pt-10 border-t border-white/5">
<button
onClick={() => setShowCompleted(!showCompleted)}
className="flex items-center space-x-2 text-apple-muted hover:text-white transition-colors group mb-6"
>
{showCompleted ? <ChevronDown size={20} /> : <ChevronRight size={20} />}
<h2 className="text-2xl font-bold tracking-tight">Completed Exams</h2>
<span className="bg-white/5 px-2 py-0.5 rounded-md text-sm font-bold">{completedExams.length}</span>
</button>
{showCompleted && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 animate-in fade-in slide-in-from-top-4 duration-500">
{completedExams.map(renderExamCard)}
</div>
)}
</div>
)}
{exams.length === 0 && (
<div className="text-center py-24 glass rounded-apple-lg">
<div className="bg-apple-secondary w-16 h-16 rounded-3xl flex items-center justify-center mx-auto mb-6">
<BookOpen size={32} className="text-apple-muted" />
</div>
<h2 className="text-2xl font-semibold text-white">No exams yet</h2>
<p className="text-apple-muted mt-2 max-w-sm mx-auto">Looks like you're all caught up. Add a new exam to start tracking your progress.</p>
<button
onClick={() => handleOpenModal()}
className="apple-button bg-white/5 hover:bg-white/10 text-white mt-8"
>
Create your first exam
</button>
</div>
)}
{showModal && (
<ExamModal
show={showModal}
onClose={() => setShowModal(false)}
onSubmit={handleSubmit}
formData={formData}
setFormData={setFormData}
editingExam={editingExam}
/>
)}
{showImportModal && (
<ImportAIModal
show={showImportModal}
onClose={() => setShowImportModal(false)}
onSuccess={fetchExams}
/>
)}
</div>
);
}
export default Home;