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:
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user