first commit
This commit is contained in:
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Exams — Manage your schedule</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
33
frontend/package.json
Normal file
33
frontend/package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "exam-manager-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.22.0",
|
||||
"react-toastify": "^10.0.4",
|
||||
"axios": "^1.6.7",
|
||||
"lucide-react": "^0.323.0",
|
||||
"date-fns": "^3.3.1",
|
||||
"clsx": "^2.1.0",
|
||||
"tailwind-merge": "^2.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.11.16",
|
||||
"@types/react": "^18.2.55",
|
||||
"@types/react-dom": "^18.2.19",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.17",
|
||||
"postcss": "^8.4.35",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.1.0"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
90
frontend/src/App.tsx
Normal file
90
frontend/src/App.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Routes, Route, useNavigate, Navigate } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
import { ToastContainer, toast } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import Login from './pages/Login';
|
||||
import Home from './pages/Home';
|
||||
import Navbar from './components/Navbar';
|
||||
import { User, ApiResponse } from './types';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import AccountPage from './pages/AccountPage';
|
||||
|
||||
function App() {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
const res = await axios.get<ApiResponse<{ user: User }>>('/api/account/fetch');
|
||||
if (res.data.success) {
|
||||
setUser(res.data.user);
|
||||
}
|
||||
} catch (err) {
|
||||
setUser(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
const handleLogin = (user: User) => {
|
||||
setUser(user);
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await axios.post('/api/account/logout');
|
||||
toast.success('Logged out successfully');
|
||||
} catch (err) {
|
||||
console.error('Logout failed', err);
|
||||
toast.error('Logout failed');
|
||||
}
|
||||
setUser(null);
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-apple-dark flex items-center justify-center">
|
||||
<Loader2 className="animate-spin text-apple-accent w-10 h-10" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen w-full bg-apple-dark text-apple-text flex flex-col font-sans">
|
||||
{user && <Navbar onLogout={handleLogout} user={user} />}
|
||||
<main className="flex-grow container mx-auto px-4 py-8 max-w-6xl">
|
||||
<Routes>
|
||||
<Route
|
||||
path="/login"
|
||||
element={user ? <Navigate to="/" /> : <Login onLogin={handleLogin} />}
|
||||
/>
|
||||
<Route
|
||||
path="/"
|
||||
element={user ? <Home user={user} /> : <Navigate to="/login" />}
|
||||
/>
|
||||
<Route
|
||||
path="/account"
|
||||
element={
|
||||
user ? (
|
||||
<AccountPage onLogout={handleLogout} onUpdateUser={checkAuth} />
|
||||
) : (
|
||||
<Navigate to="/login" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</main>
|
||||
<ToastContainer position="bottom-right" theme="dark" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
51
frontend/src/components/Navbar.tsx
Normal file
51
frontend/src/components/Navbar.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { LogOut, GraduationCap } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { User } from '../types';
|
||||
|
||||
interface NavbarProps {
|
||||
onLogout: () => void;
|
||||
user: User;
|
||||
}
|
||||
|
||||
function Navbar({ onLogout, user }: NavbarProps) {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<nav className="glass sticky top-0 z-50 px-6 py-3 flex justify-between items-center mx-4 mt-4 rounded-2xl">
|
||||
<div
|
||||
className="flex items-center space-x-2 cursor-pointer transition-opacity hover:opacity-80"
|
||||
onClick={() => navigate('/')}
|
||||
>
|
||||
<div className="bg-apple-accent p-1.5 rounded-lg">
|
||||
<GraduationCap className="text-white w-6 h-6" />
|
||||
</div>
|
||||
<span className="text-lg font-semibold tracking-tight text-white">
|
||||
Exams
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => navigate('/account')}
|
||||
className="apple-button flex items-center space-x-2 bg-white/5 hover:bg-white/10 text-white p-1 pr-3"
|
||||
>
|
||||
{user.iconUrl ? (
|
||||
<img src={user.iconUrl} alt="Avatar" className="w-8 h-8 rounded-lg object-cover" />
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-lg bg-apple-accent flex items-center justify-center text-xs font-bold">
|
||||
{(user.displayName || user.username).charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<span className="hidden sm:inline font-medium">{user.displayName || user.username}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="apple-button flex items-center space-x-2 bg-white/5 hover:bg-white/10 text-white"
|
||||
>
|
||||
<LogOut size={18} />
|
||||
<span className="hidden sm:inline">Logout</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export default Navbar;
|
||||
44
frontend/src/index.css
Normal file
44
frontend/src/index.css
Normal file
@@ -0,0 +1,44 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: dark;
|
||||
color: #f5f5f7;
|
||||
background-color: #000000;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background-color: #000000;
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.glass {
|
||||
background: rgba(28, 28, 30, 0.7);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.apple-button {
|
||||
@apply px-4 py-2 rounded-full font-medium transition-all duration-200 active:scale-95;
|
||||
}
|
||||
|
||||
.apple-card {
|
||||
@apply bg-apple-card rounded-apple-lg border border-white/5 overflow-hidden;
|
||||
}
|
||||
13
frontend/src/main.tsx
Normal file
13
frontend/src/main.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
131
frontend/src/modals/ExamModal.tsx
Normal file
131
frontend/src/modals/ExamModal.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import React from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { Exam } from '../types';
|
||||
|
||||
interface ExamModalProps {
|
||||
show: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (e: React.FormEvent) => void;
|
||||
formData: {
|
||||
name: string;
|
||||
subject: string;
|
||||
date: string;
|
||||
description: string;
|
||||
result: string;
|
||||
duration: string;
|
||||
};
|
||||
setFormData: React.Dispatch<React.SetStateAction<{
|
||||
name: string;
|
||||
subject: string;
|
||||
date: string;
|
||||
description: string;
|
||||
result: string;
|
||||
duration: string;
|
||||
}>>;
|
||||
editingExam: Exam | null;
|
||||
}
|
||||
|
||||
const ExamModal: React.FC<ExamModalProps> = ({
|
||||
show,
|
||||
onClose,
|
||||
onSubmit,
|
||||
formData,
|
||||
setFormData,
|
||||
editingExam
|
||||
}) => {
|
||||
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">
|
||||
<div className="glass w-full max-w-lg rounded-apple-lg shadow-2xl overflow-hidden transform animate-in zoom-in duration-300 border border-white/10">
|
||||
<div className="px-8 py-6 flex justify-between items-center border-b border-white/5">
|
||||
<h2 className="text-2xl font-bold text-white tracking-tight">{editingExam ? 'Exam Details' : 'New Schedule'}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="bg-white/5 hover:bg-white/10 p-2 rounded-full transition-colors text-apple-muted hover:text-white"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={onSubmit} className="p-8 space-y-6">
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-bold text-apple-muted uppercase tracking-widest ml-1">Event Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({...formData, name: e.target.value})}
|
||||
className="w-full 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="e.g. Final Math Exam"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-bold text-apple-muted uppercase tracking-widest ml-1">Subject</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.subject}
|
||||
onChange={(e) => setFormData({...formData, subject: e.target.value})}
|
||||
className="w-full 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="Mathematics"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-bold text-apple-muted uppercase tracking-widest ml-1">Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.date}
|
||||
onChange={(e) => setFormData({...formData, date: e.target.value})}
|
||||
className="w-full 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 [color-scheme:dark]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-bold text-apple-muted uppercase tracking-widest ml-1">Result / Status</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.result}
|
||||
onChange={(e) => setFormData({ ...formData, result: e.target.value })}
|
||||
className="w-full 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="e.g. Pending, 95/100"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-bold text-apple-muted uppercase tracking-widest ml-1">Duration (min)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.duration}
|
||||
onChange={(e) => setFormData({...formData, duration: e.target.value})}
|
||||
className="w-full 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="60"
|
||||
min="0"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-bold text-apple-muted uppercase tracking-widest ml-1">Notes</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({...formData, description: e.target.value})}
|
||||
className="w-full 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 h-28 resize-none"
|
||||
placeholder="Topic coverage, materials needed..."
|
||||
/>
|
||||
</div>
|
||||
<div className="pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
className="apple-button w-full bg-apple-accent hover:opacity-90 text-white font-semibold py-4 shadow-lg shadow-apple-accent/20 h-14"
|
||||
>
|
||||
{editingExam ? 'Update Schedule' : 'Schedule Exam'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExamModal;
|
||||
150
frontend/src/modals/ImportAIModal.tsx
Normal file
150
frontend/src/modals/ImportAIModal.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X, Copy, Check, Sparkles, Terminal } from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import axios from 'axios';
|
||||
|
||||
interface ImportAIModalProps {
|
||||
show: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
const ImportAIModal: React.FC<ImportAIModalProps> = ({ show, onClose, onSuccess }) => {
|
||||
const [step, setStep] = useState(1);
|
||||
const [rawText, setRawText] = useState('');
|
||||
const [jsonText, setJsonText] = useState('');
|
||||
const [isCopying, setIsCopying] = useState(false);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
const promptTemplate = `Hello. Put the content at the end into an array format like this, respond with, yes and the codeblock with the array, i will do the rest, nothing else needed. An object in the array can have "name","description","date","subject", "duration"(number)\n\n`;
|
||||
|
||||
const fullPrompt = promptTemplate + rawText;
|
||||
|
||||
const handleCopyPrompt = () => {
|
||||
navigator.clipboard.writeText(fullPrompt);
|
||||
setIsCopying(true);
|
||||
toast.success('Prompt copied to clipboard!');
|
||||
setTimeout(() => setIsCopying(false), 2000);
|
||||
setStep(2);
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
try {
|
||||
setIsImporting(true);
|
||||
// Try to extract JSON from the text if it's wrapped in a codeblock
|
||||
const jsonMatch = jsonText.match(/```(?:json)?\s*([\s\S]*?)\s*```/) || [null, jsonText];
|
||||
const cleanJson = jsonMatch[1].trim();
|
||||
|
||||
const parsed = JSON.parse(cleanJson);
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error('Response must be an array of exams');
|
||||
}
|
||||
|
||||
await axios.post('/api/exams/bulk-create', { exams: parsed });
|
||||
toast.success(`Successfully imported ${parsed.length} exams!`);
|
||||
onSuccess();
|
||||
onClose();
|
||||
// Reset state for next time
|
||||
setStep(1);
|
||||
setRawText('');
|
||||
setJsonText('');
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
toast.error(err.message || 'Failed to parse or import JSON. Make sure it is a valid JSON array.');
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
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="glass w-full max-w-2xl rounded-apple-lg shadow-2xl overflow-hidden transform animate-in zoom-in duration-300 border border-white/10">
|
||||
<div className="px-8 py-6 flex justify-between items-center border-b border-white/5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="text-apple-accent" size={24} />
|
||||
<h2 className="text-2xl font-bold text-white tracking-tight">Import using AI</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="bg-white/5 hover:bg-white/10 p-2 rounded-full transition-colors text-apple-muted hover:text-white"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-8 space-y-6">
|
||||
{step === 1 && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-bold text-apple-muted uppercase tracking-widest ml-1">Paste Raw Exam Data</label>
|
||||
<p className="text-sm text-apple-muted ml-1 mb-2">Copy and paste your exams from a school portal, PDF, or email.</p>
|
||||
<textarea
|
||||
value={rawText}
|
||||
onChange={(e) => setRawText(e.target.value)}
|
||||
className="w-full 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 h-48 resize-none"
|
||||
placeholder="e.g. Mathematics - Jan 15th 2026 10:00... Physics - Feb 2nd..."
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
disabled={!rawText.trim()}
|
||||
onClick={handleCopyPrompt}
|
||||
className="w-full flex items-center justify-center gap-2 bg-apple-accent hover:bg-apple-accent-dark disabled:opacity-50 disabled:cursor-not-allowed text-white font-bold py-4 rounded-xl transition-all shadow-lg shadow-apple-accent/20"
|
||||
>
|
||||
{isCopying ? <Check size={20} /> : <Copy size={20} />}
|
||||
{isCopying ? 'Copied!' : 'Copy AI Prompt & Continue'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-white/5 border border-white/10 rounded-xl p-4 mb-6">
|
||||
<div className="flex items-center gap-2 mb-2 text-apple-accent">
|
||||
<Terminal size={16} />
|
||||
<span className="text-xs font-bold uppercase tracking-widest">Instructions</span>
|
||||
</div>
|
||||
<ol className="text-sm text-apple-muted space-y-2 list-decimal ml-4">
|
||||
<li>Paste the copied prompt into your favorite AI (ChatGPT, Claude, etc.)</li>
|
||||
<li>Copy the resulting JSON codeblock from the AI</li>
|
||||
<li>Paste it into the box below</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-bold text-apple-muted uppercase tracking-widest ml-1">Paste AI Response (JSON)</label>
|
||||
<textarea
|
||||
value={jsonText}
|
||||
onChange={(e) => setJsonText(e.target.value)}
|
||||
className="w-full font-mono text-sm 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 h-48 resize-none"
|
||||
placeholder='[{"name": "Math Exam", "date": "2026-01-15", ...}]'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 pt-2">
|
||||
<button
|
||||
onClick={() => setStep(1)}
|
||||
className="flex-1 bg-white/5 hover:bg-white/10 text-white font-bold py-4 rounded-xl transition-all"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
disabled={!jsonText.trim() || isImporting}
|
||||
onClick={handleImport}
|
||||
className="flex-[2] flex items-center justify-center gap-2 bg-green-500 hover:bg-green-600 disabled:opacity-50 disabled:cursor-not-allowed text-white font-bold py-4 rounded-xl transition-all shadow-lg shadow-green-500/20"
|
||||
>
|
||||
{isImporting ? <span className="animate-spin">⏳</span> : <Sparkles size={20} />}
|
||||
{isImporting ? 'Importing...' : 'Finalize Import'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImportAIModal;
|
||||
265
frontend/src/pages/AccountPage.tsx
Normal file
265
frontend/src/pages/AccountPage.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import axios from 'axios';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Trash2, Download, Loader2, Palette, Check, Save } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { User, Exam } from '../types';
|
||||
|
||||
interface AccountPageProps {
|
||||
onLogout: () => void;
|
||||
onUpdateUser: () => void;
|
||||
}
|
||||
|
||||
const PRESET_COLORS = [
|
||||
'#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6', '#ec4899', '#6366f1', '#14b8a6'
|
||||
];
|
||||
|
||||
const AccountPage: React.FC<AccountPageProps> = ({ onLogout, onUpdateUser }) => {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [subjects, setSubjects] = useState<string[]>([]);
|
||||
const [subjectColors, setSubjectColors] = useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [savingColors, setSavingColors] = useState(false);
|
||||
const [savingProfile, setSavingProfile] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [iconUrl, setIconUrl] = useState('');
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [userRes, examsRes] = await Promise.all([
|
||||
axios.get('/api/account/fetch'),
|
||||
axios.get('/api/exams/list')
|
||||
]);
|
||||
|
||||
setUser(userRes.data.user);
|
||||
setSubjectColors(userRes.data.user.subjectColors || {});
|
||||
setDisplayName(userRes.data.user.displayName || '');
|
||||
setIconUrl(userRes.data.user.iconUrl || '');
|
||||
|
||||
const allSubjects = (examsRes.data.exams as Exam[]).map(e => e.subject);
|
||||
setSubjects(Array.from(new Set(allSubjects)));
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const handleUpdateColor = (subject: string, color: string) => {
|
||||
setSubjectColors(prev => ({ ...prev, [subject]: color }));
|
||||
};
|
||||
|
||||
const handleSaveColors = async () => {
|
||||
setSavingColors(true);
|
||||
try {
|
||||
await axios.post('/api/account/subject-colors', { colors: subjectColors });
|
||||
await onUpdateUser();
|
||||
toast.success('Subject colors saved successfully!');
|
||||
} catch (err) {
|
||||
toast.error('Failed to save subject colors.');
|
||||
} finally {
|
||||
setSavingColors(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveProfile = async () => {
|
||||
setSavingProfile(true);
|
||||
try {
|
||||
await axios.post('/api/account/profile', { displayName, iconUrl });
|
||||
await onUpdateUser();
|
||||
toast.success('Profile updated successfully!');
|
||||
} catch (err) {
|
||||
toast.error('Failed to update profile.');
|
||||
} finally {
|
||||
setSavingProfile(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
try {
|
||||
const res = await axios.get('/api/account/download', { responseType: 'blob' });
|
||||
const url = window.URL.createObjectURL(new Blob([res.data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', 'account-data.json');
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.parentNode?.removeChild(link);
|
||||
} catch (err) {
|
||||
toast.error('Failed to download account data.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!window.confirm('Are you sure you want to delete your account? This cannot be undone.')) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await axios.delete('/api/account/delete');
|
||||
onLogout();
|
||||
navigate('/login');
|
||||
} catch {
|
||||
setDeleting(false);
|
||||
toast.error('Failed to delete account.');
|
||||
}
|
||||
};
|
||||
|
||||
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="max-w-2xl mx-auto mt-10 space-y-8 animate-in fade-in duration-500">
|
||||
<div className="flex flex-col items-center mb-10 text-center">
|
||||
<div className="bg-apple-accent w-20 h-20 rounded-3xl flex items-center justify-center mb-6 shadow-xl shadow-apple-accent/30 overflow-hidden">
|
||||
{user?.iconUrl ? (
|
||||
<img src={user.iconUrl} alt="Profile" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="text-3xl font-bold text-white uppercase">{(user?.displayName || user?.username || '?').charAt(0)}</span>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold tracking-tight text-white mb-2">{user?.displayName || user?.username}</h1>
|
||||
{user?.displayName && <p className="text-apple-muted font-medium mb-1">@{user.username}</p>}
|
||||
<p className="text-apple-muted/60 text-xs tracking-widest uppercase">User ID: {user?.id}</p>
|
||||
</div>
|
||||
|
||||
<div className="glass rounded-apple-lg overflow-hidden">
|
||||
<div className="p-8 space-y-8">
|
||||
<section>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-apple-muted uppercase tracking-widest mb-1">Profile Settings</h3>
|
||||
<p className="text-apple-muted text-sm">Personalize how you appear in the app.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSaveProfile}
|
||||
disabled={savingProfile}
|
||||
className="apple-button flex items-center space-x-2 bg-apple-accent hover:opacity-90 text-white py-2 px-4 text-sm"
|
||||
>
|
||||
{savingProfile ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
|
||||
<span className="font-semibold">Save Profile</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-bold text-apple-muted uppercase tracking-widest ml-1">Display Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
className="w-full 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="e.g. John Doe"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-bold text-apple-muted uppercase tracking-widest ml-1">Icon URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={iconUrl}
|
||||
onChange={(e) => setIconUrl(e.target.value)}
|
||||
className="w-full 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/avatar.png"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="h-px bg-white/10 w-full" />
|
||||
|
||||
<section>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-apple-muted uppercase tracking-widest mb-1">Subject Themes</h3>
|
||||
<p className="text-apple-muted text-sm">Personalize your calendar by giving each subject a unique color.</p>
|
||||
</div>
|
||||
{subjects.length > 0 && (
|
||||
<button
|
||||
onClick={handleSaveColors}
|
||||
disabled={savingColors}
|
||||
className="apple-button flex items-center space-x-2 bg-apple-accent hover:opacity-90 text-white py-2 px-4 text-sm"
|
||||
>
|
||||
{savingColors ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
|
||||
<span className="font-semibold">Save Colors</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{subjects.length > 0 ? (
|
||||
<div className="space-y-6">
|
||||
{subjects.map(subject => (
|
||||
<div key={subject} className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 p-4 rounded-2xl bg-white/5 border border-white/5">
|
||||
<span className="font-semibold text-white">{subject}</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{PRESET_COLORS.map(color => (
|
||||
<button
|
||||
key={color}
|
||||
onClick={() => handleUpdateColor(subject, color)}
|
||||
className={`w-8 h-8 rounded-full border-2 transition-transform hover:scale-110 ${subjectColors[subject] === color ? 'border-white' : 'border-transparent'}`}
|
||||
style={{ backgroundColor: color }}
|
||||
>
|
||||
{subjectColors[subject] === color && <Check size={14} className="mx-auto text-white drop-shadow-md" />}
|
||||
</button>
|
||||
))}
|
||||
<input
|
||||
type="color"
|
||||
value={subjectColors[subject] || '#3b82f6'}
|
||||
onChange={(e) => handleUpdateColor(subject, e.target.value)}
|
||||
className="w-8 h-8 bg-transparent border-none cursor-pointer rounded-full overflow-hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 bg-white/5 rounded-2xl border border-dashed border-white/10">
|
||||
<Palette className="mx-auto text-apple-muted mb-2" size={32} />
|
||||
<p className="text-apple-muted italic text-sm">No subjects found. Create an exam first to see them here.</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="h-px bg-white/10 w-full" />
|
||||
|
||||
<section>
|
||||
<h3 className="text-sm font-bold text-apple-muted uppercase tracking-widest mb-4">Export & Visibility</h3>
|
||||
<p className="text-apple-muted mb-6 text-sm">Download all your stored examination data in JSON format for backup or migration purposes.</p>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="apple-button w-full sm:w-auto flex items-center justify-center space-x-2 bg-white/5 hover:bg-white/10 text-white py-3 px-6"
|
||||
>
|
||||
<Download size={18} className="text-apple-accent" />
|
||||
<span className="font-semibold">Download Account Data</span>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div className="h-px bg-white/10 w-full" />
|
||||
|
||||
<section>
|
||||
<h3 className="text-sm font-bold text-red-400 uppercase tracking-widest mb-4">Danger Zone</h3>
|
||||
<p className="text-apple-muted mb-6 text-sm">Permanently delete your account and all associated data. This action is irreversible.</p>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
className="apple-button w-full sm:w-auto flex items-center justify-center space-x-2 bg-red-500/10 hover:bg-red-500/20 text-red-400 py-3 px-6 border border-red-500/20"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
<span className="font-semibold">{deleting ? 'Removing Account...' : 'Delete Account'}</span>
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccountPage;
|
||||
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;
|
||||
112
frontend/src/pages/Login.tsx
Normal file
112
frontend/src/pages/Login.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import React, { useState } from 'react';
|
||||
import axios from 'axios';
|
||||
import { GraduationCap, Loader2 } from 'lucide-react';
|
||||
import { User, ApiResponse } from '../types';
|
||||
|
||||
interface LoginProps {
|
||||
onLogin: (user: User) => void;
|
||||
}
|
||||
|
||||
function Login({ onLogin }: LoginProps) {
|
||||
const [isRegistering, setIsRegistering] = useState(false);
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const endpoint = isRegistering ? '/api/account/register' : '/api/account/login';
|
||||
const res = await axios.post<ApiResponse<any>>(endpoint, { username, password });
|
||||
|
||||
if (res.data.success) {
|
||||
const userRes = await axios.get<ApiResponse<{ user: User }>>('/api/account/fetch');
|
||||
if (userRes.data.success) {
|
||||
onLogin(userRes.data.user);
|
||||
} else {
|
||||
setError('Failed to fetch user data');
|
||||
}
|
||||
} else {
|
||||
setError(res.data.error || 'Authentication failed');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || err.response?.data || 'An error occurred');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[70vh] animate-in fade-in zoom-in duration-500">
|
||||
<div className="glass p-10 rounded-apple-lg w-full max-w-md shadow-2xl">
|
||||
<div className="flex flex-col items-center mb-10">
|
||||
<div className="bg-apple-accent p-4 rounded-2xl mb-6 shadow-lg shadow-apple-accent/20">
|
||||
<GraduationCap className="text-white w-10 h-10" />
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold tracking-tight text-white mb-2 leading-tight">
|
||||
{isRegistering ? 'Join Us' : 'Welcome back.'}
|
||||
</h1>
|
||||
<p className="text-apple-muted text-center font-medium">
|
||||
{isRegistering ? 'Create an account to start tracking your exams' : 'Enter your credentials to access your schedule'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-bold text-apple-muted uppercase tracking-wider ml-1">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full 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="Username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-bold text-apple-muted uppercase tracking-wider ml-1">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full 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="Password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/20 text-red-400 text-sm px-4 py-3 rounded-xl animate-in shake duration-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="apple-button w-full bg-apple-accent hover:opacity-90 text-white font-semibold py-4 flex items-center justify-center space-x-2 shadow-lg shadow-apple-accent/20 mt-4 h-14"
|
||||
>
|
||||
{loading ? <Loader2 className="animate-spin" size={20} /> : <span>{isRegistering ? 'Create Account' : 'Sign In'}</span>}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-8 pt-8 border-t border-white/10 text-center">
|
||||
<p className="text-apple-muted">
|
||||
{isRegistering ? 'Already have an account?' : "Don't have an account?"}{' '}
|
||||
<button
|
||||
onClick={() => setIsRegistering(!isRegistering)}
|
||||
className="text-apple-accent hover:underline font-semibold ml-1"
|
||||
>
|
||||
{isRegistering ? 'Sign In' : 'Sign Up'}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Login;
|
||||
28
frontend/src/types.ts
Normal file
28
frontend/src/types.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
displayName?: string;
|
||||
iconUrl?: string;
|
||||
subjectColors?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface Exam {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
date: string;
|
||||
result: string;
|
||||
userId: string;
|
||||
imageUrls?: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
subject: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
27
frontend/tailwind.config.js
Normal file
27
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,27 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
apple: {
|
||||
dark: '#000000',
|
||||
card: '#1c1c1e',
|
||||
secondary: '#2c2c2e',
|
||||
accent: '#0a84ff',
|
||||
text: '#f5f5f7',
|
||||
muted: '#86868b'
|
||||
}
|
||||
},
|
||||
borderRadius: {
|
||||
'apple': '12px',
|
||||
'apple-lg': '20px'
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
25
frontend/tsconfig.json
Normal file
25
frontend/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
12
frontend/vite.config.ts
Normal file
12
frontend/vite.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8000',
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user