feat(frontend): apply UI/UX design system - purple/green palette, gradient buttons, 3D flip, polished pages

Applied ui-ux-pro-max design system recommendations:
- Tailwind theme: study purple primary + correct green accent
- Inter + Plus Jakarta Sans typography
- Glassmorphic surfaces with soft shadows and mesh background
- Real 3D card flip with spring physics + answer feedback flash
- Gradient stat cards, progress bar, animated done screen with score ring
- Polished Layout, Dashboard, Admin, AdminLesson, CardTable, ImportDialog, PracticeSetup, Practice, PracticeDone
- E2E smoke updated for new accessible names
This commit is contained in:
2026-05-20 21:48:47 +02:00
parent 9300af2820
commit b984e83e2b
16 changed files with 977 additions and 200 deletions

View File

@@ -2,15 +2,15 @@ import { test, expect } from '@playwright/test';
test('create lesson, add card, practice once', async ({ page }) => {
await page.goto('/admin');
await page.getByPlaceholder('Nieuwe wortel-les...').fill('E2E les');
await page.getByRole('button', { name: 'Toevoegen' }).click();
await page.getByRole('link', { name: /E2E les/ }).click();
await page.getByPlaceholder(/Nieuwe wortel-les/).fill('E2E les');
await page.getByRole('button', { name: /Toevoegen/ }).click();
await page.getByRole('link', { name: /E2E les/ }).first().click();
await page.getByPlaceholder('Nieuwe vraag').fill('q1');
await page.getByPlaceholder('Antwoord').fill('a1');
await page.getByRole('button', { name: '+' }).click();
await page.getByRole('button', { name: 'Kaart toevoegen' }).click();
await page.getByRole('link', { name: /Start oefenen/ }).click();
await page.getByRole('button', { name: 'Start' }).click();
await page.getByRole('button', { name: /Start sessie/ }).click();
await page.getByRole('button', { name: 'Toon antwoord' }).click();
await page.getByRole('button', { name: 'Goed' }).click();
await expect(page.getByText(/Sessie klaar!/)).toBeVisible();
await page.getByRole('button', { name: /Goed/ }).click();
await expect(page.getByText(/Sessie klaar/)).toBeVisible({ timeout: 8000 });
});

View File

@@ -4,8 +4,14 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Flashcards</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Plus+Jakarta+Sans:wght@600;700;800&display=swap"
rel="stylesheet"
/>
</head>
<body class="h-full bg-slate-50 text-slate-900 dark:bg-slate-950 dark:text-slate-100">
<body class="h-full bg-brand-50 text-slate-900 antialiased dark:bg-slate-950 dark:text-slate-100">
<div id="root" class="h-full"></div>
<script type="module" src="/src/main.tsx"></script>
</body>

View File

@@ -5,7 +5,7 @@ export default defineConfig({
webServer: [
{
command:
'rm -f data/e2e.db && DB_PATH=./data/e2e.db npm -w @flashcard/backend run db:migrate && DB_PATH=./data/e2e.db npm -w @flashcard/backend run dev',
'rm -f packages/backend/data/e2e.db data/e2e.db && DB_PATH=./data/e2e.db npm -w @flashcard/backend run db:migrate && DB_PATH=./data/e2e.db npm -w @flashcard/backend run dev',
cwd: '../..',
port: 3000,
reuseExistingServer: false,

View File

@@ -22,26 +22,102 @@ export function CardTable({ lessonId, cards, onChange }: { lessonId: number; car
}
return (
<table className="w-full text-sm">
<thead><tr className="text-left text-slate-500">
<th className="py-2">Vraag</th><th>Antwoord</th><th>Hint</th><th></th>
</tr></thead>
<tbody>
{cards.map((c) => (
<tr key={c.id} className="border-t border-slate-200 dark:border-slate-800">
<td><input className="w-full bg-transparent py-1" defaultValue={c.question} onBlur={(e) => update(c, 'question', e.target.value)} /></td>
<td><input className="w-full bg-transparent py-1" defaultValue={c.answer} onBlur={(e) => update(c, 'answer', e.target.value)} /></td>
<td><input className="w-full bg-transparent py-1" defaultValue={c.hint ?? ''} onBlur={(e) => update(c, 'hint', e.target.value)} /></td>
<td><button className="text-xs text-red-600" onClick={() => remove(c.id)}>x</button></td>
<div className="overflow-hidden rounded-2xl">
<table className="w-full text-sm">
<thead>
<tr className="bg-brand-50/60 text-left text-xs font-semibold uppercase tracking-wider text-brand-700 dark:bg-slate-800/60 dark:text-brand-200">
<th className="px-4 py-3">Vraag</th>
<th className="px-4 py-3">Antwoord</th>
<th className="px-4 py-3">Hint</th>
<th className="w-12 px-4 py-3" />
</tr>
))}
<tr className="border-t border-slate-200 dark:border-slate-800">
<td><input className="w-full bg-transparent py-1" placeholder="Nieuwe vraag" value={draft.question} onChange={(e) => setDraft({ ...draft, question: e.target.value })} /></td>
<td><input className="w-full bg-transparent py-1" placeholder="Antwoord" value={draft.answer} onChange={(e) => setDraft({ ...draft, answer: e.target.value })} /></td>
<td><input className="w-full bg-transparent py-1" placeholder="Hint (optioneel)" value={draft.hint ?? ''} onChange={(e) => setDraft({ ...draft, hint: e.target.value })} /></td>
<td><button className="rounded bg-blue-600 px-2 py-1 text-xs text-white" onClick={add}>+</button></td>
</tr>
</tbody>
</table>
</thead>
<tbody className="divide-y divide-brand-100/60 dark:divide-slate-800">
{cards.length === 0 && (
<tr>
<td colSpan={4} className="px-4 py-8 text-center text-sm text-slate-500">
Nog geen kaarten voeg er hieronder een toe.
</td>
</tr>
)}
{cards.map((c) => (
<tr key={c.id} className="group transition hover:bg-brand-50/40 dark:hover:bg-slate-800/40">
<td className="px-4 py-2">
<input
className="w-full rounded-lg bg-transparent px-2 py-1 outline-none transition focus:bg-white focus:ring-2 focus:ring-brand-200 dark:focus:bg-slate-900 dark:focus:ring-brand-900"
defaultValue={c.question}
onBlur={(e) => update(c, 'question', e.target.value)}
/>
</td>
<td className="px-4 py-2">
<input
className="w-full rounded-lg bg-transparent px-2 py-1 outline-none transition focus:bg-white focus:ring-2 focus:ring-brand-200 dark:focus:bg-slate-900 dark:focus:ring-brand-900"
defaultValue={c.answer}
onBlur={(e) => update(c, 'answer', e.target.value)}
/>
</td>
<td className="px-4 py-2">
<input
className="w-full rounded-lg bg-transparent px-2 py-1 text-slate-500 outline-none transition focus:bg-white focus:text-slate-900 focus:ring-2 focus:ring-brand-200 dark:focus:bg-slate-900 dark:focus:text-slate-100 dark:focus:ring-brand-900"
defaultValue={c.hint ?? ''}
placeholder="—"
onBlur={(e) => update(c, 'hint', e.target.value)}
/>
</td>
<td className="px-4 py-2 text-right">
<button
className="grid h-8 w-8 place-items-center rounded-lg text-danger-600 opacity-0 transition hover:bg-danger-50 group-hover:opacity-100 dark:hover:bg-danger-400/10"
onClick={() => remove(c.id)}
aria-label="Verwijder kaart"
title="Verwijder kaart"
>
</button>
</td>
</tr>
))}
<tr className="bg-brand-50/30 dark:bg-slate-800/20">
<td className="px-4 py-3">
<input
className="input-field"
placeholder="Nieuwe vraag"
value={draft.question}
onChange={(e) => setDraft({ ...draft, question: e.target.value })}
onKeyDown={(e) => e.key === 'Enter' && add()}
/>
</td>
<td className="px-4 py-3">
<input
className="input-field"
placeholder="Antwoord"
value={draft.answer}
onChange={(e) => setDraft({ ...draft, answer: e.target.value })}
onKeyDown={(e) => e.key === 'Enter' && add()}
/>
</td>
<td className="px-4 py-3">
<input
className="input-field"
placeholder="Hint (optioneel)"
value={draft.hint ?? ''}
onChange={(e) => setDraft({ ...draft, hint: e.target.value })}
onKeyDown={(e) => e.key === 'Enter' && add()}
/>
</td>
<td className="px-4 py-3 text-right">
<button
className="btn-primary h-9 w-9 px-0 py-0"
onClick={add}
disabled={!draft.question.trim() || !draft.answer.trim()}
aria-label="Kaart toevoegen"
title="Toevoegen"
>
+
</button>
</td>
</tr>
</tbody>
</table>
</div>
);
}

View File

@@ -1,4 +1,5 @@
import { AnimatePresence, motion } from 'framer-motion';
import { useState } from 'react';
export function Flashcard({
question, answer, hint, showAnswer, onReveal, onAnswer,
@@ -8,41 +9,114 @@ export function Flashcard({
onReveal: () => void;
onAnswer: (r: 'correct' | 'incorrect') => void;
}) {
const [flash, setFlash] = useState<'correct' | 'incorrect' | null>(null);
function handleAnswer(r: 'correct' | 'incorrect') {
setFlash(r);
setTimeout(() => {
setFlash(null);
onAnswer(r);
}, 280);
}
return (
<div className="card-perspective mx-auto w-full max-w-2xl">
<AnimatePresence mode="wait">
<motion.div
key={showAnswer ? 'a' : 'q'}
initial={{ opacity: 0, x: 40 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -40 }}
transition={{ duration: 0.2 }}
className="card-face rounded-2xl bg-white p-12 shadow-xl dark:bg-slate-900"
>
<div className="min-h-[160px] text-center text-3xl font-medium">
{showAnswer ? answer : question}
<div className="card-perspective relative mx-auto w-full max-w-2xl">
<motion.div
animate={{ rotateY: showAnswer ? 180 : 0 }}
transition={{ type: 'spring', stiffness: 220, damping: 22 }}
className="relative h-[360px] w-full"
style={{ transformStyle: 'preserve-3d' }}
>
<CardFace side="front">
<Content text={question} hint={hint} side="question" />
<button className="btn-primary mt-8" onClick={onReveal} aria-label="Toon antwoord">
Toon antwoord
</button>
</CardFace>
<CardFace side="back">
<Content text={answer} hint={null} side="answer" />
<div className="mt-8 flex gap-3">
<motion.button
whileTap={{ scale: 0.93 }}
className="btn-danger min-w-[120px]"
onClick={() => handleAnswer('incorrect')}
aria-label="Fout antwoord"
>
<span className="text-base"></span>
Fout
</motion.button>
<motion.button
whileTap={{ scale: 0.93 }}
className="btn-success min-w-[120px]"
onClick={() => handleAnswer('correct')}
aria-label="Goed antwoord"
>
<span className="text-base"></span>
Goed
</motion.button>
</div>
{!showAnswer && hint && (
<div className="mt-4 text-center text-sm text-slate-500">💡 {hint}</div>
)}
<div className="mt-8 flex justify-center gap-3">
{!showAnswer ? (
<button className="rounded-lg bg-blue-600 px-6 py-3 font-medium text-white hover:bg-blue-700" onClick={onReveal}>
Toon antwoord
</button>
) : (
<>
<motion.button whileTap={{ scale: 0.95 }} className="rounded-lg bg-red-600 px-6 py-3 font-medium text-white hover:bg-red-700" onClick={() => onAnswer('incorrect')}>
Fout
</motion.button>
<motion.button whileTap={{ scale: 0.95 }} className="rounded-lg bg-green-600 px-6 py-3 font-medium text-white hover:bg-green-700" onClick={() => onAnswer('correct')}>
Goed
</motion.button>
</>
)}
</div>
</motion.div>
</CardFace>
</motion.div>
<AnimatePresence>
{flash && (
<motion.div
key={flash}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 1.1 }}
transition={{ duration: 0.18 }}
className="pointer-events-none absolute inset-0 flex items-center justify-center"
>
<div
className={`flex h-24 w-24 items-center justify-center rounded-full text-5xl font-bold text-white shadow-2xl ${
flash === 'correct' ? 'bg-success-gradient' : 'bg-danger-gradient'
}`}
>
{flash === 'correct' ? '✓' : '✕'}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
function CardFace({ side, children }: { side: 'front' | 'back'; children: React.ReactNode }) {
return (
<div
className="card-face absolute inset-0 flex flex-col items-center justify-center rounded-3xl border border-white/60 bg-white/90 p-10 shadow-soft backdrop-blur dark:border-slate-800 dark:bg-slate-900/90"
style={{
transform: side === 'back' ? 'rotateY(180deg)' : undefined,
}}
>
{children}
</div>
);
}
function Content({ text, hint, side }: { text: string; hint: string | null; side: 'question' | 'answer' }) {
return (
<div className="flex flex-col items-center">
<span
className={`mb-3 inline-flex rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-wider ${
side === 'question'
? 'bg-brand-100 text-brand-700 dark:bg-brand-900/40 dark:text-brand-200'
: 'bg-success-100 text-success-700 dark:bg-success-700/30 dark:text-success-400'
}`}
>
{side === 'question' ? 'Vraag' : 'Antwoord'}
</span>
<div className="text-center font-display text-3xl font-bold leading-tight text-slate-900 dark:text-slate-50 md:text-4xl">
{text}
</div>
{hint && (
<div className="mt-4 max-w-md text-center text-sm text-slate-500 dark:text-slate-400">
<span className="mr-1">💡</span>
{hint}
</div>
)}
</div>
);
}

View File

@@ -1,4 +1,5 @@
import { useState } from 'react';
import { motion } from 'framer-motion';
import { cardsApi, type ImportResult } from '../api/cards.js';
export function ImportDialog({ lessonId, onClose, onDone }: { lessonId: number; onClose: () => void; onDone: () => void }) {
@@ -19,31 +20,88 @@ export function ImportDialog({ lessonId, onClose, onDone }: { lessonId: number;
}
return (
<div className="fixed inset-0 flex items-center justify-center bg-black/40">
<div className="w-full max-w-md rounded bg-white p-6 dark:bg-slate-900">
<h2 className="mb-3 text-lg font-semibold">Excel import</h2>
<p className="mb-3 text-xs text-slate-500">Kolommen: <code>question</code>, <code>answer</code>, <code>hint</code> (optioneel), <code>lesson_path</code> (optioneel, bv. "Spaans/Begroetingen").</p>
<input type="file" accept=".xlsx" onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
<label className="mt-3 flex items-center gap-2 text-sm"><input type="checkbox" checked={updateExisting} onChange={(e) => setUpdateExisting(e.target.checked)} /> Bestaande kaarten bijwerken</label>
<label className="mt-1 flex items-center gap-2 text-sm"><input type="checkbox" checked={createMissing} onChange={(e) => setCreateMissing(e.target.checked)} /> Onbekende lessen aanmaken</label>
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/50 p-4 backdrop-blur-sm">
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
transition={{ type: 'spring', stiffness: 240, damping: 22 }}
className="w-full max-w-md rounded-3xl border border-white/60 bg-white/95 p-6 shadow-2xl dark:border-slate-800 dark:bg-slate-900/95"
>
<h2 className="font-display text-xl font-bold">Excel import</h2>
<p className="mt-1 text-xs text-slate-500">
Verwachte kolommen: <code className="rounded bg-brand-50 px-1 py-0.5 text-brand-700 dark:bg-brand-900/30 dark:text-brand-200">question</code>,{' '}
<code className="rounded bg-brand-50 px-1 py-0.5 text-brand-700 dark:bg-brand-900/30 dark:text-brand-200">answer</code>,{' '}
<code className="rounded bg-brand-50 px-1 py-0.5 text-brand-700 dark:bg-brand-900/30 dark:text-brand-200">hint</code>,{' '}
<code className="rounded bg-brand-50 px-1 py-0.5 text-brand-700 dark:bg-brand-900/30 dark:text-brand-200">lesson_path</code>.
</p>
<label className="mt-4 block">
<span className="sr-only">Bestand</span>
<div className="rounded-2xl border-2 border-dashed border-brand-300 bg-brand-50/40 p-6 text-center transition hover:border-brand-500 dark:border-brand-700 dark:bg-brand-900/10">
<input
type="file"
accept=".xlsx"
className="block w-full text-sm file:mr-3 file:cursor-pointer file:rounded-xl file:border-0 file:bg-brand-gradient file:px-4 file:py-2 file:text-sm file:font-semibold file:text-white file:shadow-glow hover:file:brightness-110"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
/>
{file && <div className="mt-2 text-xs text-slate-600 dark:text-slate-300">{file.name}</div>}
</div>
</label>
<div className="mt-4 space-y-2">
<label className="flex cursor-pointer items-center gap-3 rounded-xl p-2 text-sm transition hover:bg-brand-50/60 dark:hover:bg-slate-800/40">
<input
type="checkbox"
className="h-4 w-4 rounded accent-brand-600"
checked={updateExisting}
onChange={(e) => setUpdateExisting(e.target.checked)}
/>
Bestaande kaarten bijwerken
</label>
<label className="flex cursor-pointer items-center gap-3 rounded-xl p-2 text-sm transition hover:bg-brand-50/60 dark:hover:bg-slate-800/40">
<input
type="checkbox"
className="h-4 w-4 rounded accent-brand-600"
checked={createMissing}
onChange={(e) => setCreateMissing(e.target.checked)}
/>
Onbekende lessen aanmaken
</label>
</div>
{result && (
<div className="mt-3 rounded bg-slate-100 p-2 text-xs dark:bg-slate-800">
<div>Toegevoegd: {result.inserted}</div>
<div>Bijgewerkt: {result.updated}</div>
<div>Overgeslagen: {result.skipped}</div>
<div className="mt-4 grid grid-cols-3 gap-2 text-center">
<div className="rounded-xl bg-success-50 p-2 dark:bg-success-700/20">
<div className="text-[10px] font-semibold uppercase tracking-wider text-success-700 dark:text-success-400">Toegevoegd</div>
<div className="font-display text-xl font-bold text-success-700 dark:text-success-400">{result.inserted}</div>
</div>
<div className="rounded-xl bg-brand-50 p-2 dark:bg-brand-900/30">
<div className="text-[10px] font-semibold uppercase tracking-wider text-brand-700 dark:text-brand-200">Bijgewerkt</div>
<div className="font-display text-xl font-bold text-brand-700 dark:text-brand-200">{result.updated}</div>
</div>
<div className="rounded-xl bg-slate-100 p-2 dark:bg-slate-800">
<div className="text-[10px] font-semibold uppercase tracking-wider text-slate-600 dark:text-slate-400">Overgeslagen</div>
<div className="font-display text-xl font-bold text-slate-600 dark:text-slate-400">{result.skipped}</div>
</div>
{result.errors.length > 0 && (
<div className="text-red-600">
Fouten: {result.errors.length}
<ul>{result.errors.map((e, i) => <li key={i}>rij {e.row}: {e.message}</li>)}</ul>
<div className="col-span-3 rounded-xl bg-danger-50 p-3 text-left text-xs text-danger-700 dark:bg-danger-400/10 dark:text-danger-400">
<div className="mb-1 font-semibold">Fouten ({result.errors.length})</div>
<ul className="space-y-0.5">
{result.errors.slice(0, 5).map((e, i) => <li key={i}>rij {e.row}: {e.message}</li>)}
{result.errors.length > 5 && <li> en {result.errors.length - 5} meer</li>}
</ul>
</div>
)}
</div>
)}
<div className="mt-4 flex justify-end gap-2">
<button className="px-3 py-1" onClick={onClose}>Sluiten</button>
<button className="rounded bg-blue-600 px-3 py-1 text-white disabled:opacity-50" onClick={run} disabled={!file || busy}>Importeer</button>
<div className="mt-6 flex justify-end gap-2">
<button className="btn-ghost" onClick={onClose}>Sluiten</button>
<button className="btn-primary" onClick={run} disabled={!file || busy}>
{busy ? 'Importeren…' : 'Importeer'}
</button>
</div>
</div>
</motion.div>
</div>
);
}

View File

@@ -1,22 +1,84 @@
import { Link, Outlet } from 'react-router-dom';
import { NavLink, Outlet } from 'react-router-dom';
import { useSettings } from '../stores/settingsStore.js';
const navItems = [
{ to: '/', label: 'Dashboard', end: true },
{ to: '/admin', label: 'Admin' },
{ to: '/stats', label: 'Stats' },
];
export function Layout() {
const { theme, toggleTheme } = useSettings();
return (
<div className="flex h-full flex-col">
<header className="border-b border-slate-200 bg-white px-6 py-3 dark:border-slate-800 dark:bg-slate-900">
<nav className="flex items-center gap-4 text-sm">
<Link to="/" className="font-semibold">Flashcards</Link>
<Link to="/admin">Admin</Link>
<Link to="/stats">Stats</Link>
<Link to="/settings" className="ml-auto">Instellingen</Link>
<button onClick={toggleTheme} className="rounded border px-2 py-0.5 text-xs">
{theme === 'dark' ? '☀️' : '🌙'}
</button>
<header className="sticky top-0 z-20 border-b border-white/40 bg-white/70 backdrop-blur-xl dark:border-slate-800/60 dark:bg-slate-950/70">
<div className="mx-auto flex max-w-6xl items-center gap-2 px-4 py-3 sm:px-6">
<NavLink to="/" className="flex items-center gap-2 font-display text-lg font-bold">
<span className="grid h-8 w-8 place-items-center rounded-xl bg-brand-gradient text-white shadow-glow"></span>
<span className="bg-brand-gradient bg-clip-text text-transparent">Flashcards</span>
</NavLink>
<nav className="ml-4 hidden gap-1 sm:flex">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
className={({ isActive }) =>
`rounded-xl px-3 py-1.5 text-sm font-medium transition ${
isActive
? 'bg-brand-100 text-brand-700 dark:bg-brand-900/40 dark:text-brand-200'
: 'text-slate-600 hover:bg-white/70 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-slate-900/60 dark:hover:text-white'
}`
}
>
{item.label}
</NavLink>
))}
</nav>
<div className="ml-auto flex items-center gap-2">
<NavLink
to="/settings"
className={({ isActive }) =>
`rounded-xl px-3 py-1.5 text-sm font-medium transition ${
isActive
? 'bg-brand-100 text-brand-700 dark:bg-brand-900/40 dark:text-brand-200'
: 'text-slate-600 hover:bg-white/70 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-slate-900/60'
}`
}
>
Instellingen
</NavLink>
<button
onClick={toggleTheme}
className="grid h-9 w-9 place-items-center rounded-xl border border-white/60 bg-white/70 text-base shadow-sm transition hover:scale-105 dark:border-slate-800 dark:bg-slate-900/70"
aria-label="Toggle dark mode"
>
{theme === 'dark' ? '☀️' : '🌙'}
</button>
</div>
</div>
<nav className="flex gap-1 overflow-x-auto px-4 pb-2 sm:hidden">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
className={({ isActive }) =>
`whitespace-nowrap rounded-full px-3 py-1 text-xs font-medium transition ${
isActive ? 'bg-brand-600 text-white' : 'bg-white/60 text-slate-700 dark:bg-slate-900/60 dark:text-slate-300'
}`
}
>
{item.label}
</NavLink>
))}
</nav>
</header>
<main className="flex-1 overflow-auto"><Outlet /></main>
<main className="flex-1 overflow-auto">
<div className="mx-auto max-w-6xl px-4 py-6 sm:px-6">
<Outlet />
</div>
</main>
</div>
);
}

View File

@@ -33,20 +33,51 @@ export function LessonTree({ nodes, depth = 0 }: { nodes: LessonTreeNode[]; dept
return (
<ul className="space-y-1">
{nodes.map((n) => (
<li key={n.id} style={{ paddingLeft: depth * 16 }}>
<div className="group flex items-center gap-2 rounded px-2 py-1 hover:bg-slate-100 dark:hover:bg-slate-800">
<Link to={`/admin/lessons/${n.id}`} className="flex-1">
{n.name} <span className="text-xs text-slate-500">({n.cardCount})</span>
<li key={n.id} style={{ paddingLeft: depth * 20 }}>
<div className="group flex items-center gap-2 rounded-2xl px-3 py-2 transition hover:bg-brand-50/70 dark:hover:bg-slate-800/60">
<span className={`h-2 w-2 rounded-full ${depth === 0 ? 'bg-brand-500' : 'bg-brand-300'}`} />
<Link to={`/admin/lessons/${n.id}`} className="flex-1 truncate font-medium text-slate-800 dark:text-slate-100">
{n.name}
<span className="ml-2 rounded-full bg-brand-100 px-2 py-0.5 text-xs font-semibold text-brand-700 dark:bg-brand-900/30 dark:text-brand-200">
{n.cardCount}
</span>
</Link>
<button className="text-xs opacity-0 group-hover:opacity-100" onClick={() => setAddingTo(n.id)}>+ subles</button>
<button className="text-xs opacity-0 group-hover:opacity-100" onClick={() => rename(n.id, n.name)}>rename</button>
<button className="text-xs text-red-600 opacity-0 group-hover:opacity-100" onClick={() => remove(n.id)}>delete</button>
<div className="flex items-center gap-1 opacity-0 transition group-hover:opacity-100">
<button
className="rounded-lg px-2 py-1 text-xs font-medium text-brand-700 hover:bg-brand-100 dark:text-brand-200 dark:hover:bg-brand-900/40"
onClick={() => setAddingTo(n.id)}
>
+ subles
</button>
<button
className="rounded-lg px-2 py-1 text-xs font-medium text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800"
onClick={() => rename(n.id, n.name)}
>
rename
</button>
<button
className="rounded-lg px-2 py-1 text-xs font-medium text-danger-600 hover:bg-danger-50 dark:hover:bg-danger-400/10"
onClick={() => remove(n.id)}
>
delete
</button>
</div>
</div>
{addingTo === n.id && (
<div className="ml-6 flex gap-1 py-1">
<input className="rounded border px-2 py-1 text-sm dark:bg-slate-900" value={name} onChange={(e) => setName(e.target.value)} placeholder="Naam" />
<button className="rounded bg-blue-600 px-2 py-1 text-sm text-white" onClick={() => addChild(n.id)}>Toevoegen</button>
<button className="text-sm" onClick={() => setAddingTo(null)}>Annuleren</button>
<div className="ml-6 mt-1 flex gap-2">
<input
autoFocus
className="input-field flex-1"
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') addChild(n.id);
if (e.key === 'Escape') { setAddingTo(null); setName(''); }
}}
placeholder="Naam van subles"
/>
<button className="btn-primary px-3" onClick={() => addChild(n.id)}>Toevoegen</button>
<button className="btn-ghost px-3" onClick={() => { setAddingTo(null); setName(''); }}>Annuleren</button>
</div>
)}
{n.children.length > 0 && <LessonTree nodes={n.children} depth={depth + 1} />}

View File

@@ -17,13 +17,38 @@ export function AdminPage() {
}
return (
<div className="mx-auto max-w-3xl p-6">
<h1 className="mb-4 text-2xl font-semibold">Lessen beheer</h1>
<div className="mb-4 flex gap-2">
<input className="flex-1 rounded border px-3 py-2 dark:bg-slate-900" placeholder="Nieuwe wortel-les..." value={newRoot} onChange={(e) => setNewRoot(e.target.value)} />
<button className="rounded bg-blue-600 px-4 py-2 text-white" onClick={addRoot}>Toevoegen</button>
<div className="mx-auto max-w-3xl space-y-6">
<header>
<h1 className="font-display text-3xl font-bold">Lessen beheren</h1>
<p className="mt-1 text-sm text-slate-600 dark:text-slate-400">
Maak een hiërarchie van lessen en sublessen. Klik op een les voor de kaarten.
</p>
</header>
<div className="surface flex flex-col gap-2 p-4 sm:flex-row">
<input
className="input-field"
placeholder="Nieuwe wortel-les…"
value={newRoot}
onChange={(e) => setNewRoot(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addRoot()}
/>
<button className="btn-primary shrink-0" onClick={addRoot} disabled={!newRoot.trim()}>
+ Toevoegen
</button>
</div>
<div className="surface p-4">
{loading ? (
<p className="text-sm text-slate-500">Laden</p>
) : tree.length === 0 ? (
<div className="py-8 text-center text-sm text-slate-500">
Nog geen lessen. Voeg er hierboven een toe.
</div>
) : (
<LessonTree nodes={tree} />
)}
</div>
{loading ? <p>Laden...</p> : <LessonTree nodes={tree} />}
</div>
);
}

View File

@@ -15,16 +15,36 @@ export function AdminLessonPage() {
useEffect(() => { refresh(); }, [lessonId]);
return (
<div className="mx-auto max-w-4xl p-6">
<Link to="/admin" className="text-sm text-blue-600"> Lessen</Link>
<h1 className="my-3 text-2xl font-semibold">Kaarten</h1>
<div className="mb-4 flex gap-2">
<button className="rounded bg-slate-200 px-3 py-1 dark:bg-slate-800" onClick={() => setShowImport(true)}>Importeer Excel</button>
<a className="rounded bg-slate-200 px-3 py-1 dark:bg-slate-800" href={cardsApi.exportUrl(lessonId, false)}>Exporteer Excel</a>
<a className="rounded bg-slate-200 px-3 py-1 dark:bg-slate-800" href={cardsApi.exportUrl(lessonId, true)}>Exporteer + sublessen</a>
<Link to={`/practice/${lessonId}/setup`} className="ml-auto rounded bg-green-600 px-3 py-1 text-white">Start oefenen </Link>
<div className="mx-auto max-w-5xl space-y-6">
<Link to="/admin" className="inline-flex items-center gap-1 text-sm font-medium text-brand-600 hover:text-brand-700 dark:text-brand-300">
Terug naar lessen
</Link>
<header className="surface flex flex-col gap-3 p-5 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="font-display text-2xl font-bold">Kaartenbeheer</h1>
<p className="mt-1 text-xs text-slate-500">{cards.length} {cards.length === 1 ? 'kaart' : 'kaarten'} in deze les</p>
</div>
<div className="flex flex-wrap gap-2">
<button className="btn-ghost" onClick={() => setShowImport(true)}>
📥 Importeer
</button>
<a className="btn-ghost" href={cardsApi.exportUrl(lessonId, false)}>
📤 Exporteer
</a>
<a className="btn-ghost" href={cardsApi.exportUrl(lessonId, true)}>
📤 + sublessen
</a>
<Link to={`/practice/${lessonId}/setup`} className="btn-success">
Start oefenen
</Link>
</div>
</header>
<div className="surface overflow-hidden p-1">
<CardTable lessonId={lessonId} cards={cards} onChange={refresh} />
</div>
<CardTable lessonId={lessonId} cards={cards} onChange={refresh} />
{showImport && <ImportDialog lessonId={lessonId} onClose={() => setShowImport(false)} onDone={refresh} />}
</div>
);

View File

@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion';
import { statsApi, type Overview } from '../api/stats.js';
import { sessionsApi } from '../api/sessions.js';
import type { SessionRow } from '@flashcard/shared';
@@ -22,50 +23,153 @@ export function DashboardPage() {
}, []);
return (
<div className="mx-auto max-w-4xl p-6">
<h1 className="mb-6 text-3xl font-semibold">Dashboard</h1>
<div className="space-y-8">
<header>
<h1 className="font-display text-3xl font-bold sm:text-4xl">Welkom terug 👋</h1>
<p className="mt-1 text-sm text-slate-600 dark:text-slate-400">
Houd je streak vast een paar minuten oefenen per dag werkt het beste.
</p>
</header>
{active && (
<div className="mb-4 flex items-center justify-between rounded bg-amber-100 p-3 text-sm dark:bg-amber-900/30">
<span>Je hebt een open sessie ({active.cardsShown} kaarten behandeld).</span>
<motion.div
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
className="flex flex-col gap-3 rounded-3xl border border-amber-200/60 bg-gradient-to-r from-amber-50 to-orange-50 p-5 shadow-soft sm:flex-row sm:items-center sm:justify-between dark:border-amber-900/40 dark:from-amber-900/20 dark:to-orange-900/20"
>
<div>
<div className="text-sm font-semibold text-amber-800 dark:text-amber-200">
Open sessie wacht op je
</div>
<div className="text-xs text-amber-700/80 dark:text-amber-300/80">
{active.cardsShown} kaarten al behandeld
</div>
</div>
<div className="flex gap-2">
<button className="rounded bg-amber-600 px-3 py-1 text-white" onClick={() => navigate(`/practice/${active.lessonId}`)}>Hervatten</button>
<button className="rounded bg-slate-200 px-3 py-1 dark:bg-slate-800" onClick={async () => { await sessionsApi.abandon(active.id); setActive(null); }}>Afsluiten</button>
<button className="btn-primary" onClick={() => navigate(`/practice/${active.lessonId}`)}>
Hervatten
</button>
<button
className="btn-ghost"
onClick={async () => { await sessionsApi.abandon(active.id); setActive(null); }}
>
Afsluiten
</button>
</div>
</motion.div>
)}
<section className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<StatCard
tone="brand"
label="Streak"
value={`${ov?.streakDays ?? 0}`}
unit={ov?.streakDays === 1 ? 'dag' : 'dagen'}
icon="🔥"
/>
<StatCard
tone="success"
label="Sessies"
value={String(ov?.totalSessions ?? 0)}
unit="totaal"
icon="🎯"
/>
<StatCard
tone="muted"
label="Geoefend"
value={ov ? formatDuration(ov.totalDurationSeconds) : '—'}
unit=""
icon="⏱️"
/>
</section>
<section>
<h2 className="mb-3 font-display text-xl font-bold">Lessen</h2>
{tree.length === 0 ? (
<div className="surface flex flex-col items-center gap-3 p-10 text-center">
<span className="text-4xl">📚</span>
<p className="text-sm text-slate-600 dark:text-slate-400">
Nog geen lessen. Maak er een via admin om te beginnen.
</p>
<Link to="/admin" className="btn-primary">Naar admin</Link>
</div>
) : (
<ul className="grid gap-3 sm:grid-cols-2">
{tree.map((n, i) => (
<motion.li
key={n.id}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.04 }}
className="surface group flex items-center justify-between gap-3 p-4 transition hover:-translate-y-0.5 hover:shadow-glow"
>
<div className="min-w-0">
<div className="truncate font-semibold">{n.name}</div>
<div className="text-xs text-slate-500 dark:text-slate-400">
{n.cardCount} {n.cardCount === 1 ? 'kaart' : 'kaarten'}
</div>
</div>
<Link to={`/practice/${n.id}/setup`} className="btn-success shrink-0 px-4 py-2">
Oefenen
</Link>
</motion.li>
))}
</ul>
)}
</section>
{ov?.recentSessions && ov.recentSessions.length > 0 && (
<section>
<h2 className="mb-3 font-display text-xl font-bold">Recente sessies</h2>
<ul className="surface divide-y divide-brand-100/60 dark:divide-slate-800">
{ov.recentSessions.map((s) => {
const pct = s.cardsShown > 0 ? Math.round((s.cardsCorrect / s.cardsShown) * 100) : 0;
return (
<li key={s.id} className="flex items-center justify-between gap-3 p-4 text-sm">
<span className="text-slate-600 dark:text-slate-300">{formatDate(s.startedAt)}</span>
<div className="flex items-center gap-3">
<span className="rounded-full bg-brand-100 px-2 py-0.5 text-xs font-semibold text-brand-700 dark:bg-brand-900/30 dark:text-brand-200">
{pct}%
</span>
<span className="text-xs text-slate-500">
{s.cardsCorrect}/{s.cardsShown} · {formatDuration(s.durationSeconds ?? 0)}
</span>
</div>
</li>
);
})}
</ul>
</section>
)}
</div>
);
}
function StatCard({
tone, label, value, unit, icon,
}: {
tone: 'brand' | 'success' | 'muted';
label: string; value: string; unit: string; icon: string;
}) {
const toneClass =
tone === 'brand'
? 'bg-brand-gradient text-white shadow-glow'
: tone === 'success'
? 'bg-success-gradient text-white shadow-success-glow'
: 'bg-white/80 text-slate-900 shadow-soft dark:bg-slate-900/70 dark:text-slate-100';
return (
<div className={`relative overflow-hidden rounded-3xl p-5 ${toneClass}`}>
<div className="flex items-start justify-between">
<div>
<div className="text-xs font-semibold uppercase tracking-wider opacity-80">{label}</div>
<div className="mt-2 font-display text-3xl font-bold">
{value}
{unit && <span className="ml-1 text-base font-medium opacity-80">{unit}</span>}
</div>
</div>
)}
<div className="mb-6 grid grid-cols-3 gap-4">
<Stat label="🔥 Streak" value={`${ov?.streakDays ?? 0} dagen`} />
<Stat label="Sessies" value={String(ov?.totalSessions ?? 0)} />
<Stat label="Totale tijd" value={ov ? formatDuration(ov.totalDurationSeconds) : '—'} />
<span className="text-3xl opacity-90">{icon}</span>
</div>
<h2 className="mb-2 text-lg font-medium">Lessen</h2>
<ul className="mb-6 space-y-1">
{tree.map((n) => (
<li key={n.id} className="flex items-center justify-between rounded p-2 hover:bg-slate-100 dark:hover:bg-slate-800">
<span>{n.name} <span className="text-xs text-slate-500">({n.cardCount} kaarten)</span></span>
<Link to={`/practice/${n.id}/setup`} className="rounded bg-green-600 px-3 py-1 text-sm text-white">Oefenen</Link>
</li>
))}
</ul>
<h2 className="mb-2 text-lg font-medium">Recente sessies</h2>
<ul className="space-y-1 text-sm">
{ov?.recentSessions.map((s) => (
<li key={s.id} className="rounded p-2 hover:bg-slate-100 dark:hover:bg-slate-800">
{formatDate(s.startedAt)} {s.cardsCorrect}/{s.cardsShown} goed · {formatDuration(s.durationSeconds ?? 0)}
</li>
))}
</ul>
</div>
);
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-xl bg-white p-4 shadow dark:bg-slate-900">
<div className="text-xs uppercase tracking-wide text-slate-500">{label}</div>
<div className="mt-1 text-2xl font-semibold">{value}</div>
</div>
);
}

View File

@@ -54,22 +54,55 @@ export function PracticePage() {
if (done && session) { (async () => { await end(); navigate(`/practice/${lessonId}/done`); })(); }
}, [done, session, end, navigate, lessonId]);
if (!current || !card) return <div className="p-6 text-center">Laden...</div>;
if (!current || !card) {
return (
<div className="mx-auto flex max-w-2xl flex-col items-center justify-center p-12">
<div className="h-2 w-32 animate-shimmer rounded-full bg-gradient-to-r from-brand-100 via-brand-200 to-brand-100 bg-[length:1000px_100%]" />
<p className="mt-4 text-sm text-slate-500">Laden</p>
</div>
);
}
const isReverse = current.direction === 'backward';
const shown = session?.cardsShown ?? 0;
const correct = session?.cardsCorrect ?? 0;
const incorrect = session?.cardsIncorrect ?? 0;
const accuracy = shown > 0 ? Math.round((correct / shown) * 100) : 0;
// Heuristic progress: we don't know queue length here, so show steady fill on the answered count
const progress = Math.min(100, shown * 4);
return (
<div className="flex h-full flex-col">
<div className="mx-auto w-full max-w-2xl p-6">
<div className="mb-2 text-xs text-slate-500">{session?.cardsShown ?? 0} kaarten behandeld</div>
<Flashcard
question={isReverse ? card.answer : card.question}
answer={isReverse ? card.question : card.answer}
hint={card.hint}
showAnswer={showAnswer}
onReveal={reveal}
onAnswer={(r) => answer(r)}
/>
<div className="mx-auto w-full max-w-2xl">
<div className="surface mb-6 p-4">
<div className="mb-2 flex items-center justify-between text-xs">
<span className="font-semibold uppercase tracking-wider text-slate-500">
Sessie
</span>
<span className="font-semibold text-brand-700 dark:text-brand-300">{accuracy}% goed</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-brand-100 dark:bg-slate-800">
<div
className="h-full bg-brand-gradient transition-all duration-500 ease-out"
style={{ width: `${progress}%` }}
/>
</div>
<div className="mt-2 flex items-center justify-between text-xs text-slate-500">
<span>{shown} behandeld</span>
<span className="flex gap-3">
<span className="text-success-600"> {correct}</span>
<span className="text-danger-600"> {incorrect}</span>
</span>
</div>
</div>
<Flashcard
question={isReverse ? card.answer : card.question}
answer={isReverse ? card.question : card.answer}
hint={card.hint}
showAnswer={showAnswer}
onReveal={reveal}
onAnswer={(r) => answer(r)}
/>
</div>
);
}

View File

@@ -1,25 +1,100 @@
import { Link, useParams } from 'react-router-dom';
import { motion } from 'framer-motion';
import { useSession } from '../stores/sessionStore.js';
import { Confetti } from '../components/Confetti.js';
import { formatDuration } from '../lib/format.js';
export function PracticeDonePage() {
const { lessonId } = useParams();
const session = useSession((s) => s.session);
const reset = useSession((s) => s.reset);
if (!session) return <div className="p-6">Geen sessie gegevens. <Link to="/" className="text-blue-600">Terug</Link></div>;
if (!session) {
return (
<div className="mx-auto max-w-md p-6 text-center">
Geen sessie gegevens.{' '}
<Link to="/" className="text-brand-600 underline">Terug</Link>
</div>
);
}
const total = session.cardsShown || 1;
const pct = Math.round((session.cardsCorrect / total) * 100);
const great = pct >= 80;
const ok = pct >= 60 && pct < 80;
const ringColor = great ? 'stroke-success-500' : ok ? 'stroke-brand-500' : 'stroke-danger-500';
const message = great
? 'Geweldig! Je beheerst dit goed.'
: ok
? 'Goed bezig — nog wat herhalen.'
: 'Niet getreurd, oefenen baart kunst.';
return (
<div className="mx-auto max-w-md p-6 text-center">
<Confetti trigger={pct >= 80} />
<h1 className="text-3xl font-semibold">Sessie klaar!</h1>
<p className="mt-4 text-5xl font-bold">{pct}%</p>
<p className="mt-2 text-slate-500">{session.cardsCorrect} goed, {session.cardsIncorrect} fout van {session.cardsShown}</p>
<p className="mt-1 text-sm text-slate-500">Duur: {session.durationSeconds ?? 0}s</p>
<div className="mt-6 flex justify-center gap-3">
<Link to={`/practice/${lessonId}/setup`} className="rounded bg-green-600 px-4 py-2 text-white" onClick={reset}>Opnieuw</Link>
<Link to="/" className="rounded bg-slate-200 px-4 py-2 dark:bg-slate-800" onClick={reset}>Dashboard</Link>
</div>
<div className="mx-auto max-w-md text-center">
<Confetti trigger={great} />
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: 'spring', stiffness: 200, damping: 18 }}
className="surface p-8"
>
<div className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Sessie klaar
</div>
<h1 className="mt-1 font-display text-3xl font-bold">{great ? '🎉 ' : ''}{message}</h1>
<div className="relative mx-auto mt-6 h-44 w-44">
<svg viewBox="0 0 100 100" className="h-full w-full -rotate-90">
<circle cx="50" cy="50" r="42" className="fill-none stroke-brand-100 dark:stroke-slate-800" strokeWidth="10" />
<motion.circle
cx="50"
cy="50"
r="42"
className={`fill-none ${ringColor}`}
strokeWidth="10"
strokeLinecap="round"
strokeDasharray={2 * Math.PI * 42}
initial={{ strokeDashoffset: 2 * Math.PI * 42 }}
animate={{ strokeDashoffset: 2 * Math.PI * 42 * (1 - pct / 100) }}
transition={{ duration: 1, ease: 'easeOut' }}
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<div className="font-display text-5xl font-bold">{pct}%</div>
<div className="text-xs text-slate-500">goed</div>
</div>
</div>
<div className="mt-6 grid grid-cols-3 gap-2 text-xs">
<Stat label="Goed" value={String(session.cardsCorrect)} tone="success" />
<Stat label="Fout" value={String(session.cardsIncorrect)} tone="danger" />
<Stat label="Duur" value={formatDuration(session.durationSeconds ?? 0)} tone="muted" />
</div>
<div className="mt-8 flex flex-col gap-2 sm:flex-row sm:justify-center">
<Link to={`/practice/${lessonId}/setup`} className="btn-primary" onClick={reset}>
Opnieuw oefenen
</Link>
<Link to="/" className="btn-ghost" onClick={reset}>
Naar dashboard
</Link>
</div>
</motion.div>
</div>
);
}
function Stat({ label, value, tone }: { label: string; value: string; tone: 'success' | 'danger' | 'muted' }) {
const toneClass =
tone === 'success'
? 'bg-success-50 text-success-700 dark:bg-success-700/20 dark:text-success-400'
: tone === 'danger'
? 'bg-danger-50 text-danger-700 dark:bg-danger-400/20 dark:text-danger-400'
: 'bg-brand-50 text-brand-700 dark:bg-brand-900/30 dark:text-brand-200';
return (
<div className={`rounded-2xl p-3 ${toneClass}`}>
<div className="text-[10px] uppercase tracking-wider opacity-80">{label}</div>
<div className="mt-1 font-display text-xl font-bold">{value}</div>
</div>
);
}

View File

@@ -1,8 +1,16 @@
import { useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { motion } from 'framer-motion';
import { useSettings } from '../stores/settingsStore.js';
import { useSession } from '../stores/sessionStore.js';
const sizeOptions: (number | 'all')[] = [10, 20, 30, 50, 'all'];
const directions: { value: 'forward' | 'backward' | 'both'; label: string; hint: string }[] = [
{ value: 'forward', label: 'Vraag → antwoord', hint: 'Standaard' },
{ value: 'backward', label: 'Antwoord → vraag', hint: 'Voor bidirectionele lessen' },
{ value: 'both', label: 'Beide richtingen', hint: 'Dubbele oefening' },
];
export function PracticeSetupPage() {
const { lessonId } = useParams();
const id = Number(lessonId);
@@ -22,25 +30,110 @@ export function PracticeSetupPage() {
}
return (
<div className="mx-auto max-w-md p-6">
<h1 className="mb-4 text-2xl font-semibold">Sessie starten</h1>
<label className="mb-2 block text-sm">Max. aantal kaarten
<select className="ml-2 rounded border px-2 py-1 dark:bg-slate-900" value={String(maxCards)} onChange={(e) => setMaxCards(e.target.value === 'all' ? 'all' : Number(e.target.value))}>
{[10, 20, 30, 50].map((n) => <option key={n} value={n}>{n}</option>)}
<option value="all">Alle</option>
</select>
</label>
<label className="mb-2 flex items-center gap-2 text-sm"><input type="checkbox" checked={shuffle} onChange={(e) => setShuffle(e.target.checked)} /> Shuffle</label>
<label className="mb-4 block text-sm">Richting
<select className="ml-2 rounded border px-2 py-1 dark:bg-slate-900" value={direction} onChange={(e) => setDirection(e.target.value as typeof direction)}>
<option value="forward">Vraag antwoord</option>
<option value="backward">Antwoord vraag</option>
<option value="both">Beide</option>
</select>
</label>
<button className="w-full rounded bg-green-600 py-3 font-medium text-white" onClick={begin} disabled={busy}>
Start
</button>
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className="mx-auto max-w-lg"
>
<div className="surface space-y-6 p-8">
<header>
<h1 className="font-display text-3xl font-bold">Klaar om te oefenen?</h1>
<p className="mt-1 text-sm text-slate-600 dark:text-slate-400">
Configureer je sessie en begin.
</p>
</header>
<Section title="Aantal kaarten">
<div className="flex flex-wrap gap-2">
{sizeOptions.map((n) => (
<Pill
key={String(n)}
active={maxCards === n}
onClick={() => setMaxCards(n)}
>
{n === 'all' ? 'Alle' : n}
</Pill>
))}
</div>
</Section>
<Section title="Richting">
<div className="space-y-2">
{directions.map((d) => (
<button
key={d.value}
onClick={() => setDirection(d.value)}
className={`flex w-full items-center justify-between rounded-2xl border px-4 py-3 text-left transition ${
direction === d.value
? 'border-brand-400 bg-brand-50 ring-2 ring-brand-200 dark:bg-brand-900/30 dark:ring-brand-700/40'
: 'border-brand-100 bg-white/60 hover:border-brand-300 dark:border-slate-800 dark:bg-slate-900/40'
}`}
>
<div>
<div className="text-sm font-semibold">{d.label}</div>
<div className="text-xs text-slate-500 dark:text-slate-400">{d.hint}</div>
</div>
<span
className={`h-4 w-4 rounded-full border-2 transition ${
direction === d.value
? 'border-brand-600 bg-brand-600'
: 'border-slate-300 dark:border-slate-600'
}`}
/>
</button>
))}
</div>
</Section>
<label className="flex cursor-pointer items-center justify-between rounded-2xl border border-brand-100 bg-white/60 px-4 py-3 dark:border-slate-800 dark:bg-slate-900/40">
<span className="text-sm">
<span className="font-semibold">Shuffle</span>
<span className="ml-2 text-xs text-slate-500">
Willekeurige volgorde binnen Leitner-dozen
</span>
</span>
<input
type="checkbox"
className="h-5 w-5 rounded accent-brand-600"
checked={shuffle}
onChange={(e) => setShuffle(e.target.checked)}
/>
</label>
<button
className="btn-primary w-full py-4 text-base"
onClick={begin}
disabled={busy}
>
{busy ? 'Bezig…' : 'Start sessie →'}
</button>
</div>
</motion.div>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div>
<div className="mb-2 text-xs font-semibold uppercase tracking-wider text-slate-500">
{title}
</div>
{children}
</div>
);
}
function Pill({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
return (
<button
onClick={onClick}
className={`rounded-full px-4 py-1.5 text-sm font-semibold transition ${
active
? 'bg-brand-gradient text-white shadow-glow'
: 'bg-white/70 text-slate-700 ring-1 ring-brand-100 hover:bg-white dark:bg-slate-900/40 dark:text-slate-200 dark:ring-slate-800'
}`}
>
{children}
</button>
);
}

View File

@@ -3,5 +3,62 @@
@tailwind utilities;
html, body, #root { height: 100%; }
.card-perspective { perspective: 1000px; }
.card-face { backface-visibility: hidden; }
body {
font-feature-settings: 'cv11', 'ss01', 'ss03';
}
@layer base {
body {
background-image:
radial-gradient(at 0% 0%, theme('colors.brand.100') 0px, transparent 50%),
radial-gradient(at 100% 0%, theme('colors.success.50') 0px, transparent 50%),
radial-gradient(at 50% 100%, theme('colors.brand.50') 0px, transparent 60%);
background-attachment: fixed;
}
.dark body {
background-image:
radial-gradient(at 0% 0%, rgba(124, 58, 237, 0.18) 0px, transparent 50%),
radial-gradient(at 100% 0%, rgba(16, 185, 129, 0.10) 0px, transparent 50%),
radial-gradient(at 50% 100%, rgba(76, 29, 149, 0.20) 0px, transparent 60%);
}
}
@layer components {
.btn {
@apply inline-flex items-center justify-center gap-2 rounded-2xl px-5 py-2.5 text-sm font-semibold transition-all duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50;
}
.btn-primary {
@apply btn bg-brand-gradient text-white shadow-glow hover:brightness-110 active:scale-[0.97];
}
.btn-success {
@apply btn bg-success-gradient text-white shadow-success-glow hover:brightness-110 active:scale-[0.97];
}
.btn-danger {
@apply btn bg-danger-gradient text-white shadow-danger-glow hover:brightness-110 active:scale-[0.97];
}
.btn-ghost {
@apply btn bg-white/70 text-slate-700 backdrop-blur hover:bg-white dark:bg-slate-900/60 dark:text-slate-200 dark:hover:bg-slate-900;
}
.surface {
@apply rounded-3xl border border-white/60 bg-white/70 shadow-soft backdrop-blur dark:border-slate-800/60 dark:bg-slate-900/60;
}
.input-field {
@apply w-full rounded-2xl border border-brand-100 bg-white/80 px-4 py-2.5 text-sm shadow-sm transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-200 dark:border-slate-800 dark:bg-slate-900/80 dark:focus:border-brand-500 dark:focus:ring-brand-900;
}
}
.card-perspective { perspective: 1500px; }
.card-face {
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
transform-style: preserve-3d;
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}

View File

@@ -1,15 +1,78 @@
import type { Config } from 'tailwindcss';
export default {
content: ['./index.html', './src/**/*.{ts,tsx}'],
darkMode: 'class',
theme: {
extend: {
animation: { 'flip': 'flip 0.4s ease-out forwards' },
colors: {
brand: {
50: '#FAF5FF',
100: '#F3E8FF',
200: '#E9D5FF',
300: '#D8B4FE',
400: '#A78BFA',
500: '#8B5CF6',
600: '#7C3AED',
700: '#6D28D9',
800: '#5B21B6',
900: '#4C1D95',
},
success: {
50: '#ECFDF5',
100: '#D1FAE5',
400: '#34D399',
500: '#10B981',
600: '#059669',
700: '#047857',
},
danger: {
50: '#FEF2F2',
400: '#F87171',
500: '#EF4444',
600: '#DC2626',
700: '#B91C1C',
},
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
display: ['"Plus Jakarta Sans"', 'Inter', 'system-ui', 'sans-serif'],
},
boxShadow: {
soft: '0 6px 24px -8px rgba(124, 58, 237, 0.18), 0 2px 8px -2px rgba(15, 23, 42, 0.06)',
glow: '0 10px 40px -12px rgba(124, 58, 237, 0.4)',
'success-glow': '0 10px 40px -12px rgba(16, 185, 129, 0.45)',
'danger-glow': '0 10px 40px -12px rgba(239, 68, 68, 0.45)',
},
backgroundImage: {
'brand-gradient': 'linear-gradient(135deg, #A78BFA 0%, #7C3AED 100%)',
'success-gradient': 'linear-gradient(135deg, #34D399 0%, #059669 100%)',
'danger-gradient': 'linear-gradient(135deg, #F87171 0%, #DC2626 100%)',
'mesh-light': 'radial-gradient(at 0% 0%, #F3E8FF 0px, transparent 50%), radial-gradient(at 100% 0%, #ECFDF5 0px, transparent 50%), radial-gradient(at 50% 100%, #FAF5FF 0px, transparent 60%)',
},
animation: {
'flip': 'flip 0.4s ease-out forwards',
'pulse-correct': 'pulseCorrect 0.6s ease-out',
'pulse-wrong': 'pulseWrong 0.6s ease-out',
'shimmer': 'shimmer 2s linear infinite',
},
keyframes: {
flip: {
'0%': { transform: 'rotateY(0)' },
'100%': { transform: 'rotateY(180deg)' },
},
pulseCorrect: {
'0%, 100%': { boxShadow: '0 0 0 0 rgba(16, 185, 129, 0)' },
'50%': { boxShadow: '0 0 0 16px rgba(16, 185, 129, 0.2)' },
},
pulseWrong: {
'0%, 100%': { boxShadow: '0 0 0 0 rgba(239, 68, 68, 0)' },
'50%': { boxShadow: '0 0 0 16px rgba(239, 68, 68, 0.2)' },
},
shimmer: {
'0%': { backgroundPosition: '-1000px 0' },
'100%': { backgroundPosition: '1000px 0' },
},
},
},
},