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: }, ], },