first commit
This commit is contained in:
291
frontend/src/pages/Home.tsx
Normal file
291
frontend/src/pages/Home.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user