feat(frontend): rich lesson detail page
This commit is contained in:
182
packages/frontend/src/pages/LessonDetail.tsx
Normal file
182
packages/frontend/src/pages/LessonDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user