292 lines
12 KiB
TypeScript
292 lines
12 KiB
TypeScript
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, formatDistanceToNow } 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 ? formatDistanceToNow(examDate, { addSuffix: true }) : '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;
|