From 3356767d21663bc20dc2d7de5fe05f78346d8ee1 Mon Sep 17 00:00:00 2001 From: Bert Hausmans Date: Thu, 21 May 2026 00:36:51 +0200 Subject: [PATCH] feat(admin): visibility toggle, fork/unsubscribe, readonly CardTable for subscribers --- .../frontend/src/components/CardTable.tsx | 113 +++++++++-------- packages/frontend/src/pages/AdminLesson.tsx | 114 +++++++++++++++--- 2 files changed, 161 insertions(+), 66 deletions(-) diff --git a/packages/frontend/src/components/CardTable.tsx b/packages/frontend/src/components/CardTable.tsx index 4b3a373..bf13343 100644 --- a/packages/frontend/src/components/CardTable.tsx +++ b/packages/frontend/src/components/CardTable.tsx @@ -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({ question: '', answer: '', hint: '' }); async function add() { @@ -46,6 +51,7 @@ export function CardTable({ lessonId, cards, onChange }: { lessonId: number; car update(c, 'question', e.target.value)} /> @@ -53,6 +59,7 @@ export function CardTable({ lessonId, cards, onChange }: { lessonId: number; car update(c, 'answer', e.target.value)} /> @@ -61,63 +68,71 @@ 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)} /> - + {!readOnly && ( + + )} ))} - - - setDraft({ ...draft, question: e.target.value })} - onKeyDown={(e) => e.key === 'Enter' && add()} - /> - - - setDraft({ ...draft, answer: e.target.value })} - onKeyDown={(e) => e.key === 'Enter' && add()} - /> - - - setDraft({ ...draft, hint: e.target.value })} - onKeyDown={(e) => e.key === 'Enter' && add()} - /> - - - - - + {!readOnly && ( + + + setDraft({ ...draft, question: e.target.value })} + onKeyDown={(e) => e.key === 'Enter' && add()} + /> + + + setDraft({ ...draft, answer: e.target.value })} + onKeyDown={(e) => e.key === 'Enter' && add()} + /> + + + setDraft({ ...draft, hint: e.target.value })} + onKeyDown={(e) => e.key === 'Enter' && add()} + /> + + + + + + )} + {readOnly && ( +
Alleen lezen β€” fork om aan te passen.
+ )} ); } diff --git a/packages/frontend/src/pages/AdminLesson.tsx b/packages/frontend/src/pages/AdminLesson.tsx index e4f82bf..103153e 100644 --- a/packages/frontend/src/pages/AdminLesson.tsx +++ b/packages/frontend/src/pages/AdminLesson.tsx @@ -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([]); 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 (
@@ -22,27 +83,46 @@ export function AdminLessonPage() {
-

Kaartenbeheer

+

+ Kaartenbeheer + {!isOwner && πŸ“₯ Geabonneerd} + {isCurated && ⭐ Curated} +

{cards.length} {cards.length === 1 ? 'kaart' : 'kaarten'} in deze les

- - - πŸ“€ Exporteer - - - πŸ“€ + sublessen - - - Start oefenen β†’ - + {isOwner ? ( + <> + + {user?.role === 'sysadmin' && visibility === 'shared' && ( + + )} + + πŸ“€ Exporteer + Start oefenen β†’ + + ) : ( + <> + + + Start oefenen β†’ + + )}
+ {!isOwner && ( +
+ Je bent geabonneerd op deze les en kunt kaarten alleen bekijken. Klik op 🍴 Fork voor een eigen, bewerkbare kopie. +
+ )} +
- +
{showImport && setShowImport(false)} onDone={refresh} />}