feat(frontend): admin lesson tree CRUD
This commit is contained in:
57
packages/frontend/src/components/LessonTree.tsx
Normal file
57
packages/frontend/src/components/LessonTree.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import type { LessonTreeNode } from '@flashcard/shared';
|
||||||
|
import { lessonsApi } from '../api/lessons.js';
|
||||||
|
import { useLessons } from '../stores/lessonsStore.js';
|
||||||
|
|
||||||
|
export function LessonTree({ nodes, depth = 0 }: { nodes: LessonTreeNode[]; depth?: number }) {
|
||||||
|
const refresh = useLessons((s) => s.refresh);
|
||||||
|
const [addingTo, setAddingTo] = useState<number | null>(null);
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
|
||||||
|
async function addChild(parentId: number | null) {
|
||||||
|
if (!name.trim()) return;
|
||||||
|
await lessonsApi.create({ name: name.trim(), parentId });
|
||||||
|
setName(''); setAddingTo(null);
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rename(id: number, current: string) {
|
||||||
|
const next = prompt('Nieuwe naam', current);
|
||||||
|
if (next && next.trim() && next !== current) {
|
||||||
|
await lessonsApi.update(id, { name: next.trim() });
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: number) {
|
||||||
|
if (!confirm('Verwijder les en alle onderliggende lessen en kaarten?')) return;
|
||||||
|
await lessonsApi.remove(id);
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{nodes.map((n) => (
|
||||||
|
<li key={n.id} style={{ paddingLeft: depth * 16 }}>
|
||||||
|
<div className="group flex items-center gap-2 rounded px-2 py-1 hover:bg-slate-100 dark:hover:bg-slate-800">
|
||||||
|
<Link to={`/admin/lessons/${n.id}`} className="flex-1">
|
||||||
|
{n.name} <span className="text-xs text-slate-500">({n.cardCount})</span>
|
||||||
|
</Link>
|
||||||
|
<button className="text-xs opacity-0 group-hover:opacity-100" onClick={() => setAddingTo(n.id)}>+ subles</button>
|
||||||
|
<button className="text-xs opacity-0 group-hover:opacity-100" onClick={() => rename(n.id, n.name)}>rename</button>
|
||||||
|
<button className="text-xs text-red-600 opacity-0 group-hover:opacity-100" onClick={() => remove(n.id)}>delete</button>
|
||||||
|
</div>
|
||||||
|
{addingTo === n.id && (
|
||||||
|
<div className="ml-6 flex gap-1 py-1">
|
||||||
|
<input className="rounded border px-2 py-1 text-sm dark:bg-slate-900" value={name} onChange={(e) => setName(e.target.value)} placeholder="Naam" />
|
||||||
|
<button className="rounded bg-blue-600 px-2 py-1 text-sm text-white" onClick={() => addChild(n.id)}>Toevoegen</button>
|
||||||
|
<button className="text-sm" onClick={() => setAddingTo(null)}>Annuleren</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{n.children.length > 0 && <LessonTree nodes={n.children} depth={depth + 1} />}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
packages/frontend/src/pages/Admin.tsx
Normal file
29
packages/frontend/src/pages/Admin.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { lessonsApi } from '../api/lessons.js';
|
||||||
|
import { useLessons } from '../stores/lessonsStore.js';
|
||||||
|
import { LessonTree } from '../components/LessonTree.js';
|
||||||
|
|
||||||
|
export function AdminPage() {
|
||||||
|
const { tree, refresh, loading } = useLessons();
|
||||||
|
const [newRoot, setNewRoot] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => { refresh(); }, [refresh]);
|
||||||
|
|
||||||
|
async function addRoot() {
|
||||||
|
if (!newRoot.trim()) return;
|
||||||
|
await lessonsApi.create({ name: newRoot.trim(), parentId: null });
|
||||||
|
setNewRoot('');
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-3xl p-6">
|
||||||
|
<h1 className="mb-4 text-2xl font-semibold">Lessen beheer</h1>
|
||||||
|
<div className="mb-4 flex gap-2">
|
||||||
|
<input className="flex-1 rounded border px-3 py-2 dark:bg-slate-900" placeholder="Nieuwe wortel-les..." value={newRoot} onChange={(e) => setNewRoot(e.target.value)} />
|
||||||
|
<button className="rounded bg-blue-600 px-4 py-2 text-white" onClick={addRoot}>Toevoegen</button>
|
||||||
|
</div>
|
||||||
|
{loading ? <p>Laden...</p> : <LessonTree nodes={tree} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createBrowserRouter, Navigate } from 'react-router-dom';
|
import { createBrowserRouter, Navigate } from 'react-router-dom';
|
||||||
import { Layout } from './components/Layout.js';
|
import { Layout } from './components/Layout.js';
|
||||||
|
import { AdminPage } from './pages/Admin.js';
|
||||||
|
|
||||||
export const router = createBrowserRouter([
|
export const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
@@ -7,7 +8,7 @@ export const router = createBrowserRouter([
|
|||||||
element: <Layout />,
|
element: <Layout />,
|
||||||
children: [
|
children: [
|
||||||
{ index: true, element: <div className="p-6">Dashboard placeholder</div> },
|
{ index: true, element: <div className="p-6">Dashboard placeholder</div> },
|
||||||
{ path: 'admin', element: <div className="p-6">Admin placeholder</div> },
|
{ path: 'admin', element: <AdminPage /> },
|
||||||
{ path: '*', element: <Navigate to="/" replace /> },
|
{ path: '*', element: <Navigate to="/" replace /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user