feat(frontend): practice setup, session and done flow
This commit is contained in:
15
packages/frontend/src/components/Confetti.tsx
Normal file
15
packages/frontend/src/components/Confetti.tsx
Normal file
@@ -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;
|
||||
}
|
||||
48
packages/frontend/src/components/Flashcard.tsx
Normal file
48
packages/frontend/src/components/Flashcard.tsx
Normal file
@@ -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 (
|
||||
<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>
|
||||
{!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>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
packages/frontend/src/pages/Practice.tsx
Normal file
50
packages/frontend/src/pages/Practice.tsx
Normal file
@@ -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<Card | null>(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 <div className="p-6 text-center">Laden...</div>;
|
||||
|
||||
const isReverse = current.direction === 'backward';
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
packages/frontend/src/pages/PracticeDone.tsx
Normal file
25
packages/frontend/src/pages/PracticeDone.tsx
Normal file
@@ -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 <div className="p-6">Geen sessie gegevens. <Link to="/" className="text-blue-600">Terug</Link></div>;
|
||||
const total = session.cardsShown || 1;
|
||||
const pct = Math.round((session.cardsCorrect / total) * 100);
|
||||
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>
|
||||
);
|
||||
}
|
||||
46
packages/frontend/src/pages/PracticeSetup.tsx
Normal file
46
packages/frontend/src/pages/PracticeSetup.tsx
Normal file
@@ -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<number | 'all'>(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 (
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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: <div className="p-6">Dashboard placeholder</div> },
|
||||
{ path: 'admin', element: <AdminPage /> },
|
||||
{ path: 'admin/lessons/:id', element: <AdminLessonPage /> },
|
||||
{ path: 'practice/:lessonId/setup', element: <PracticeSetupPage /> },
|
||||
{ path: 'practice/:lessonId', element: <PracticePage /> },
|
||||
{ path: 'practice/:lessonId/done', element: <PracticeDonePage /> },
|
||||
{ path: '*', element: <Navigate to="/" replace /> },
|
||||
],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user