diff --git a/packages/frontend/src/components/Confetti.tsx b/packages/frontend/src/components/Confetti.tsx
new file mode 100644
index 0000000..93e7861
--- /dev/null
+++ b/packages/frontend/src/components/Confetti.tsx
@@ -0,0 +1,15 @@
+import confetti from 'canvas-confetti';
+import { useEffect } from 'react';
+
+export function Confetti({ trigger }: { trigger: boolean }) {
+ useEffect(() => {
+ if (!trigger) return;
+ const end = Date.now() + 1200;
+ (function frame() {
+ confetti({ particleCount: 4, angle: 60, spread: 55, origin: { x: 0 } });
+ confetti({ particleCount: 4, angle: 120, spread: 55, origin: { x: 1 } });
+ if (Date.now() < end) requestAnimationFrame(frame);
+ })();
+ }, [trigger]);
+ return null;
+}
diff --git a/packages/frontend/src/components/Flashcard.tsx b/packages/frontend/src/components/Flashcard.tsx
new file mode 100644
index 0000000..370760b
--- /dev/null
+++ b/packages/frontend/src/components/Flashcard.tsx
@@ -0,0 +1,48 @@
+import { AnimatePresence, motion } from 'framer-motion';
+
+export function Flashcard({
+ question, answer, hint, showAnswer, onReveal, onAnswer,
+}: {
+ question: string; answer: string; hint: string | null;
+ showAnswer: boolean;
+ onReveal: () => void;
+ onAnswer: (r: 'correct' | 'incorrect') => void;
+}) {
+ return (
+
+
+
+
+ {showAnswer ? answer : question}
+
+ {!showAnswer && hint && (
+ 💡 {hint}
+ )}
+
+ {!showAnswer ? (
+
+ ) : (
+ <>
+ onAnswer('incorrect')}>
+ Fout
+
+ onAnswer('correct')}>
+ Goed
+
+ >
+ )}
+
+
+
+
+ );
+}
diff --git a/packages/frontend/src/pages/Practice.tsx b/packages/frontend/src/pages/Practice.tsx
new file mode 100644
index 0000000..7177131
--- /dev/null
+++ b/packages/frontend/src/pages/Practice.tsx
@@ -0,0 +1,50 @@
+import { useEffect, useState } from 'react';
+import { useNavigate, useParams } from 'react-router-dom';
+import type { Card } from '@flashcard/shared';
+import { cardsApi } from '../api/cards.js';
+import { useSession } from '../stores/sessionStore.js';
+import { Flashcard } from '../components/Flashcard.js';
+
+export function PracticePage() {
+ const { lessonId } = useParams();
+ const { session, current, done, showAnswer, reveal, answer, end } = useSession();
+ const navigate = useNavigate();
+ const [card, setCard] = useState(null);
+
+ useEffect(() => {
+ if (!session) { navigate(`/practice/${lessonId}/setup`); return; }
+ }, [session, lessonId, navigate]);
+
+ useEffect(() => {
+ let cancel = false;
+ (async () => {
+ if (!current) { setCard(null); return; }
+ const c = await cardsApi.get(current.cardId);
+ if (!cancel) setCard(c);
+ })();
+ return () => { cancel = true; };
+ }, [current]);
+
+ useEffect(() => {
+ if (done && session) { (async () => { await end(); navigate(`/practice/${lessonId}/done`); })(); }
+ }, [done, session, end, navigate, lessonId]);
+
+ if (!current || !card) return Laden...
;
+
+ const isReverse = current.direction === 'backward';
+ return (
+
+
+
{session?.cardsShown ?? 0} kaarten behandeld
+
answer(r)}
+ />
+
+
+ );
+}
diff --git a/packages/frontend/src/pages/PracticeDone.tsx b/packages/frontend/src/pages/PracticeDone.tsx
new file mode 100644
index 0000000..e168120
--- /dev/null
+++ b/packages/frontend/src/pages/PracticeDone.tsx
@@ -0,0 +1,25 @@
+import { Link, useParams } from 'react-router-dom';
+import { useSession } from '../stores/sessionStore.js';
+import { Confetti } from '../components/Confetti.js';
+
+export function PracticeDonePage() {
+ const { lessonId } = useParams();
+ const session = useSession((s) => s.session);
+ const reset = useSession((s) => s.reset);
+ if (!session) return Geen sessie gegevens. Terug
;
+ const total = session.cardsShown || 1;
+ const pct = Math.round((session.cardsCorrect / total) * 100);
+ return (
+
+
= 80} />
+ Sessie klaar!
+ {pct}%
+ {session.cardsCorrect} goed, {session.cardsIncorrect} fout van {session.cardsShown}
+ Duur: {session.durationSeconds ?? 0}s
+
+ Opnieuw
+ Dashboard
+
+
+ );
+}
diff --git a/packages/frontend/src/pages/PracticeSetup.tsx b/packages/frontend/src/pages/PracticeSetup.tsx
new file mode 100644
index 0000000..6a388af
--- /dev/null
+++ b/packages/frontend/src/pages/PracticeSetup.tsx
@@ -0,0 +1,46 @@
+import { useState } from 'react';
+import { useNavigate, useParams } from 'react-router-dom';
+import { useSettings } from '../stores/settingsStore.js';
+import { useSession } from '../stores/sessionStore.js';
+
+export function PracticeSetupPage() {
+ const { lessonId } = useParams();
+ const id = Number(lessonId);
+ const defaultMax = useSettings((s) => s.defaultMaxCards);
+ const start = useSession((s) => s.start);
+ const navigate = useNavigate();
+
+ const [maxCards, setMaxCards] = useState(defaultMax);
+ const [shuffle, setShuffle] = useState(true);
+ const [direction, setDirection] = useState<'forward' | 'backward' | 'both'>('forward');
+ const [busy, setBusy] = useState(false);
+
+ async function begin() {
+ setBusy(true);
+ await start({ lessonId: id, maxCards: maxCards === 'all' ? null : maxCards, shuffle, direction });
+ navigate(`/practice/${id}`);
+ }
+
+ return (
+
+
Sessie starten
+
+
+
+
+
+ );
+}
diff --git a/packages/frontend/src/router.tsx b/packages/frontend/src/router.tsx
index 637e184..91f85ea 100644
--- a/packages/frontend/src/router.tsx
+++ b/packages/frontend/src/router.tsx
@@ -2,6 +2,9 @@ import { createBrowserRouter, Navigate } from 'react-router-dom';
import { Layout } from './components/Layout.js';
import { AdminPage } from './pages/Admin.js';
import { AdminLessonPage } from './pages/AdminLesson.js';
+import { PracticeSetupPage } from './pages/PracticeSetup.js';
+import { PracticePage } from './pages/Practice.js';
+import { PracticeDonePage } from './pages/PracticeDone.js';
export const router = createBrowserRouter([
{
@@ -11,6 +14,9 @@ export const router = createBrowserRouter([
{ index: true, element: Dashboard placeholder
},
{ path: 'admin', element: },
{ path: 'admin/lessons/:id', element: },
+ { path: 'practice/:lessonId/setup', element: },
+ { path: 'practice/:lessonId', element: },
+ { path: 'practice/:lessonId/done', element: },
{ path: '*', element: },
],
},