feat(frontend): rich lesson detail page

This commit is contained in:
2026-05-21 07:09:26 +02:00
parent d9b913aab7
commit 3254e225e9

View File

@@ -0,0 +1,182 @@
import { useEffect, useMemo, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import type { Card, LessonTreeNode } from '@flashcard/shared';
import { cardsApi } from '../api/cards.js';
import { lessonsApi } from '../api/lessons.js';
import { adminLessonsApi } from '../api/admin-lessons.js';
import { statsApi, type LessonStats } from '../api/stats.js';
import { useAuth } from '../stores/authStore.js';
import { useLessons } from '../stores/lessonsStore.js';
import { CardTable } from '../components/CardTable.js';
import { ImportDialog } from '../components/ImportDialog.js';
import { LessonStatsPanel } from '../components/LessonStatsPanel.js';
import { SublessonList } from '../components/SublessonList.js';
import { RecentSessionsList, type RecentSessionRow } from '../components/RecentSessionsList.js';
import { ApiClientError } from '../api/client.js';
function findNode(tree: LessonTreeNode[], id: number, path: LessonTreeNode[] = []): { node: LessonTreeNode | null; path: LessonTreeNode[] } {
for (const n of tree) {
if (n.id === id) return { node: n, path: [...path, n] };
const found = findNode(n.children, id, [...path, n]);
if (found.node) return found;
}
return { node: null, path: [] };
}
const PREVIEW_LIMIT = 30;
export function LessonDetailPage() {
const { id } = useParams();
const lessonId = Number(id);
const user = useAuth((s) => s.user);
const { tree, refresh: refreshTree } = useLessons();
const navigate = useNavigate();
const [cards, setCards] = useState<Card[]>([]);
const [stats, setStats] = useState<LessonStats | null>(null);
const [recent, setRecent] = useState<RecentSessionRow[]>([]);
const [showImport, setShowImport] = useState(false);
const [showAllCards, setShowAllCards] = useState(false);
const [busy, setBusy] = useState(false);
const { node, path } = useMemo(() => findNode(tree, lessonId), [tree, lessonId]);
const isOwner = node?.ownerId === user?.id;
const visibility = node?.visibility ?? 'private';
const isCurated = node?.isCurated ?? false;
async function refresh() {
try { setCards(await cardsApi.list(lessonId)); }
catch (e) { if (e instanceof ApiClientError && e.status === 403) setCards([]); else throw e; }
statsApi.lesson(lessonId).then(setStats).catch(() => {});
statsApi.overview().then((ov) => {
setRecent(ov.recentSessions.filter((s) => s.lessonId === lessonId).slice(0, 5));
}).catch(() => {});
}
useEffect(() => { refresh(); refreshTree(); }, [lessonId]);
async function toggleVisibility() {
setBusy(true);
try {
const next = visibility === 'shared' ? 'private' : 'shared';
await lessonsApi.setVisibility(lessonId, next);
await refreshTree();
} finally { setBusy(false); }
}
async function toggleCurated() {
if (!user || user.role !== 'sysadmin') return;
setBusy(true);
try { await adminLessonsApi.setCurated(lessonId, !isCurated); await refreshTree(); }
finally { setBusy(false); }
}
async function deleteLesson() {
if (!confirm('Verwijder les en alle sublessen + kaarten?')) return;
setBusy(true);
try { await lessonsApi.remove(lessonId); navigate('/lessons'); }
finally { setBusy(false); }
}
async function forkThis() {
setBusy(true);
try { const f = await lessonsApi.fork(lessonId); await refreshTree(); navigate(`/lessons/${f.id}`); }
finally { setBusy(false); }
}
async function unsubscribeThis() {
setBusy(true);
try { await lessonsApi.unsubscribe(lessonId); await refreshTree(); navigate('/lessons'); }
finally { setBusy(false); }
}
const visibilityBadge =
isCurated ? '⭐ Curated' : visibility === 'shared' ? '🌍 Gedeeld' : '🔒 Privé';
const visibleCards = showAllCards ? cards : cards.slice(0, PREVIEW_LIMIT);
return (
<div className="space-y-8">
<nav className="text-sm text-slate-500">
<Link to="/lessons" className="hover:text-brand-700">Lessen</Link>
{path.slice(0, -1).map((p) => (
<span key={p.id}>
<span className="mx-1">/</span>
<Link to={`/lessons/${p.id}`} className="hover:text-brand-700">{p.name}</Link>
</span>
))}
{node && (
<>
<span className="mx-1">/</span>
<span className="font-semibold text-slate-700 dark:text-slate-200">{node.name}</span>
</>
)}
</nav>
<header className="surface flex flex-col gap-4 p-5 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="flex items-center gap-2 font-display text-3xl font-bold">
{node?.name ?? '…'}
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-xs font-semibold text-slate-600 dark:bg-slate-800 dark:text-slate-300">
{visibilityBadge}
</span>
{!isOwner && node && (
<span className="rounded-full bg-amber-50 px-2 py-0.5 text-xs font-semibold text-amber-700 dark:bg-amber-900/30 dark:text-amber-200">📥 Geabonneerd</span>
)}
</h1>
{node?.description && <p className="mt-1 text-sm text-slate-600 dark:text-slate-300">{node.description}</p>}
</div>
<div className="flex flex-wrap gap-2">
<Link to={`/practice/${lessonId}/setup`} className="btn-success text-base">Start oefenen </Link>
{isOwner ? (
<>
<button className="btn-ghost" onClick={toggleVisibility} disabled={busy}>
{visibility === 'shared' ? '🔒 Maak privé' : '🌍 Deel publiek'}
</button>
{user?.role === 'sysadmin' && visibility === 'shared' && (
<button className="btn-ghost" onClick={toggleCurated} disabled={busy}>
{isCurated ? '☆ Verwijder curated' : '⭐ Markeer als curated'}
</button>
)}
<button className="btn-ghost" onClick={() => setShowImport(true)}>📥 Importeer</button>
<a className="btn-ghost" href={cardsApi.exportUrl(lessonId, false)}>📤 Exporteer</a>
<button className="btn-ghost text-danger-700" onClick={deleteLesson} disabled={busy}>🗑 Verwijder</button>
</>
) : (
<>
<button className="btn-ghost" onClick={forkThis} disabled={busy}>🍴 Fork</button>
<button className="btn-ghost" onClick={unsubscribeThis} disabled={busy}>Abonnement opzeggen</button>
</>
)}
</div>
</header>
{stats && <LessonStatsPanel stats={stats} lastSessionAt={recent[0]?.startedAt ?? null} />}
{node && <SublessonList items={node.children} />}
<section>
<h2 className="mb-3 font-display text-xl font-bold">Kaarten</h2>
{cards.length === 0 ? (
<div className="surface p-6 text-center text-sm text-slate-500">
{isOwner ? 'Nog geen kaarten — voeg er hieronder een toe.' : 'Deze les heeft nog geen kaarten.'}
</div>
) : (
<div className="surface overflow-hidden p-1">
<CardTable lessonId={lessonId} cards={visibleCards} onChange={refresh} readOnly={!isOwner} />
{cards.length > PREVIEW_LIMIT && !showAllCards && (
<div className="p-3 text-center">
<button className="btn-ghost" onClick={() => setShowAllCards(true)}>
Toon alle {cards.length} kaarten
</button>
</div>
)}
</div>
)}
</section>
<section>
<h2 className="mb-3 font-display text-xl font-bold">Recente sessies</h2>
<RecentSessionsList rows={recent} />
</section>
{showImport && <ImportDialog lessonId={lessonId} onClose={() => setShowImport(false)} onDone={refresh} />}
</div>
);
}