From 3254e225e93339f10a24cde04b0af058f949e0a4 Mon Sep 17 00:00:00 2001 From: Bert Hausmans Date: Thu, 21 May 2026 07:09:26 +0200 Subject: [PATCH] feat(frontend): rich lesson detail page --- packages/frontend/src/pages/LessonDetail.tsx | 182 +++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 packages/frontend/src/pages/LessonDetail.tsx diff --git a/packages/frontend/src/pages/LessonDetail.tsx b/packages/frontend/src/pages/LessonDetail.tsx new file mode 100644 index 0000000..59b357d --- /dev/null +++ b/packages/frontend/src/pages/LessonDetail.tsx @@ -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([]); + const [stats, setStats] = useState(null); + const [recent, setRecent] = useState([]); + 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 ( +
+ + +
+
+

+ {node?.name ?? '…'} + + {visibilityBadge} + + {!isOwner && node && ( + πŸ“₯ Geabonneerd + )} +

+ {node?.description &&

{node.description}

} +
+
+ Start oefenen β†’ + {isOwner ? ( + <> + + {user?.role === 'sysadmin' && visibility === 'shared' && ( + + )} + + πŸ“€ Exporteer + + + ) : ( + <> + + + + )} +
+
+ + {stats && } + + {node && } + +
+

Kaarten

+ {cards.length === 0 ? ( +
+ {isOwner ? 'Nog geen kaarten β€” voeg er hieronder een toe.' : 'Deze les heeft nog geen kaarten.'} +
+ ) : ( +
+ + {cards.length > PREVIEW_LIMIT && !showAllCards && ( +
+ +
+ )} +
+ )} +
+ +
+

Recente sessies

+ +
+ + {showImport && setShowImport(false)} onDone={refresh} />} +
+ ); +}