feat(admin): visibility toggle, fork/unsubscribe, readonly CardTable for subscribers

This commit is contained in:
2026-05-21 00:36:51 +02:00
parent 7eabd667ce
commit 3356767d21
2 changed files with 161 additions and 66 deletions

View File

@@ -2,7 +2,12 @@ import { useState } from 'react';
import type { Card, CardCreateInput } from '@flashcard/shared';
import { cardsApi } from '../api/cards.js';
export function CardTable({ lessonId, cards, onChange }: { lessonId: number; cards: Card[]; onChange: () => void }) {
export function CardTable({
lessonId,
cards,
onChange,
readOnly = false,
}: { lessonId: number; cards: Card[]; onChange: () => void; readOnly?: boolean; }) {
const [draft, setDraft] = useState<CardCreateInput>({ question: '', answer: '', hint: '' });
async function add() {
@@ -46,6 +51,7 @@ export function CardTable({ lessonId, cards, onChange }: { lessonId: number; car
<input
className="w-full rounded-lg bg-transparent px-2 py-1 outline-none transition focus:bg-white focus:ring-2 focus:ring-brand-200 dark:focus:bg-slate-900 dark:focus:ring-brand-900"
defaultValue={c.question}
disabled={readOnly}
onBlur={(e) => update(c, 'question', e.target.value)}
/>
</td>
@@ -53,6 +59,7 @@ export function CardTable({ lessonId, cards, onChange }: { lessonId: number; car
<input
className="w-full rounded-lg bg-transparent px-2 py-1 outline-none transition focus:bg-white focus:ring-2 focus:ring-brand-200 dark:focus:bg-slate-900 dark:focus:ring-brand-900"
defaultValue={c.answer}
disabled={readOnly}
onBlur={(e) => update(c, 'answer', e.target.value)}
/>
</td>
@@ -61,10 +68,12 @@ export function CardTable({ lessonId, cards, onChange }: { lessonId: number; car
className="w-full rounded-lg bg-transparent px-2 py-1 text-slate-500 outline-none transition focus:bg-white focus:text-slate-900 focus:ring-2 focus:ring-brand-200 dark:focus:bg-slate-900 dark:focus:text-slate-100 dark:focus:ring-brand-900"
defaultValue={c.hint ?? ''}
placeholder="—"
disabled={readOnly}
onBlur={(e) => update(c, 'hint', e.target.value)}
/>
</td>
<td className="px-4 py-2 text-right">
{!readOnly && (
<button
className="grid h-8 w-8 place-items-center rounded-lg text-danger-600 opacity-0 transition hover:bg-danger-50 group-hover:opacity-100 dark:hover:bg-danger-400/10"
onClick={() => remove(c.id)}
@@ -73,9 +82,11 @@ export function CardTable({ lessonId, cards, onChange }: { lessonId: number; car
>
</button>
)}
</td>
</tr>
))}
{!readOnly && (
<tr className="bg-brand-50/30 dark:bg-slate-800/20">
<td className="px-4 py-3">
<input
@@ -116,8 +127,12 @@ export function CardTable({ lessonId, cards, onChange }: { lessonId: number; car
</button>
</td>
</tr>
)}
</tbody>
</table>
{readOnly && (
<div className="p-3 text-center text-xs text-slate-500">Alleen lezen fork om aan te passen.</div>
)}
</div>
);
}

View File

@@ -1,18 +1,79 @@
import { useEffect, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import type { Card } from '@flashcard/shared';
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 { useAuth } from '../stores/authStore.js';
import { useLessons } from '../stores/lessonsStore.js';
import { CardTable } from '../components/CardTable.js';
import { ImportDialog } from '../components/ImportDialog.js';
import { ApiClientError } from '../api/client.js';
function findLesson(tree: LessonTreeNode[], id: number): LessonTreeNode | null {
for (const n of tree) {
if (n.id === id) return n;
const found = findLesson(n.children, id);
if (found) return found;
}
return null;
}
export function AdminLessonPage() {
const { id } = useParams();
const lessonId = Number(id);
const user = useAuth((s) => s.user);
const { tree, refresh: refreshTree } = useLessons();
const [cards, setCards] = useState<Card[]>([]);
const [showImport, setShowImport] = useState(false);
const [busy, setBusy] = useState(false);
async function refresh() { setCards(await cardsApi.list(lessonId)); }
useEffect(() => { refresh(); }, [lessonId]);
const node = findLesson(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; }
}
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 forkThis() {
setBusy(true);
try {
const fork = await lessonsApi.fork(lessonId);
await refreshTree();
window.location.href = `/admin/lessons/${fork.id}`;
} finally { setBusy(false); }
}
async function unsubscribeThis() {
setBusy(true);
try {
await lessonsApi.unsubscribe(lessonId);
await refreshTree();
window.location.href = '/admin';
} finally { setBusy(false); }
}
return (
<div className="mx-auto max-w-5xl space-y-6">
@@ -22,27 +83,46 @@ export function AdminLessonPage() {
<header className="surface flex flex-col gap-3 p-5 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="font-display text-2xl font-bold">Kaartenbeheer</h1>
<h1 className="font-display text-2xl font-bold">
Kaartenbeheer
{!isOwner && <span className="ml-2 rounded-full bg-amber-100 px-2 py-0.5 text-xs font-semibold text-amber-700 dark:bg-amber-900/30 dark:text-amber-200">📥 Geabonneerd</span>}
{isCurated && <span className="ml-2 rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-semibold text-yellow-700"> Curated</span>}
</h1>
<p className="mt-1 text-xs text-slate-500">{cards.length} {cards.length === 1 ? 'kaart' : 'kaarten'} in deze les</p>
</div>
<div className="flex flex-wrap gap-2">
<button className="btn-ghost" onClick={() => setShowImport(true)}>
📥 Importeer
{isOwner ? (
<>
<button className="btn-ghost" onClick={toggleVisibility} disabled={busy}>
{visibility === 'shared' ? '🔒 Maak privé' : '🌍 Deel publiek'}
</button>
<a className="btn-ghost" href={cardsApi.exportUrl(lessonId, false)}>
📤 Exporteer
</a>
<a className="btn-ghost" href={cardsApi.exportUrl(lessonId, true)}>
📤 + sublessen
</a>
<Link to={`/practice/${lessonId}/setup`} className="btn-success">
Start oefenen
</Link>
{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>
<Link to={`/practice/${lessonId}/setup`} className="btn-success">Start oefenen </Link>
</>
) : (
<>
<button className="btn-ghost" onClick={forkThis} disabled={busy}>🍴 Fork</button>
<button className="btn-ghost" onClick={unsubscribeThis} disabled={busy}>Abonnement opzeggen</button>
<Link to={`/practice/${lessonId}/setup`} className="btn-success">Start oefenen </Link>
</>
)}
</div>
</header>
{!isOwner && (
<div className="rounded-2xl bg-amber-50 p-3 text-sm text-amber-800 dark:bg-amber-900/30 dark:text-amber-200">
Je bent geabonneerd op deze les en kunt kaarten alleen bekijken. Klik op <strong>🍴 Fork</strong> voor een eigen, bewerkbare kopie.
</div>
)}
<div className="surface overflow-hidden p-1">
<CardTable lessonId={lessonId} cards={cards} onChange={refresh} />
<CardTable lessonId={lessonId} cards={cards} onChange={refresh} readOnly={!isOwner} />
</div>
{showImport && <ImportDialog lessonId={lessonId} onClose={() => setShowImport(false)} onDone={refresh} />}