feat(frontend): lesson tree with filter + dnd-kit drag reorder

This commit is contained in:
2026-05-21 07:11:13 +02:00
parent 3254e225e9
commit 0529e2a5e8
3 changed files with 203 additions and 78 deletions

56
package-lock.json generated
View File

@@ -483,6 +483,59 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@drizzle-team/brocli": { "node_modules/@drizzle-team/brocli": {
"version": "0.10.2", "version": "0.10.2",
"resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz",
@@ -7960,6 +8013,9 @@
"name": "@flashcard/frontend", "name": "@flashcard/frontend",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@flashcard/shared": "*", "@flashcard/shared": "*",
"canvas-confetti": "^1.9.0", "canvas-confetti": "^1.9.0",
"framer-motion": "^11.0.0", "framer-motion": "^11.0.0",

View File

@@ -11,6 +11,9 @@
"test": "vitest run" "test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@flashcard/shared": "*", "@flashcard/shared": "*",
"canvas-confetti": "^1.9.0", "canvas-confetti": "^1.9.0",
"framer-motion": "^11.0.0", "framer-motion": "^11.0.0",

View File

@@ -1,92 +1,158 @@
import { useState } from 'react'; import { useMemo, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import type { LessonTreeNode } from '@flashcard/shared'; import type { LessonTreeNode } from '@flashcard/shared';
import { lessonsApi } from '../api/lessons.js'; import { lessonsApi } from '../api/lessons.js';
import { useLessons } from '../stores/lessonsStore.js'; import { useLessons } from '../stores/lessonsStore.js';
import { useAuth } from '../stores/authStore.js'; import { useAuth } from '../stores/authStore.js';
import {
DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors,
type DragEndEvent,
} from '@dnd-kit/core';
import { sortableKeyboardCoordinates, useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
export function LessonTree({ nodes, depth = 0 }: { nodes: LessonTreeNode[]; depth?: number }) { function filterTree(nodes: LessonTreeNode[], q: string): LessonTreeNode[] {
if (!q.trim()) return nodes;
const term = q.trim().toLowerCase();
function visit(n: LessonTreeNode): LessonTreeNode | null {
const matches = n.name.toLowerCase().includes(term);
const kids = n.children.map(visit).filter((x): x is LessonTreeNode => x !== null);
if (matches || kids.length > 0) return { ...n, children: kids };
return null;
}
return nodes.map(visit).filter((x): x is LessonTreeNode => x !== null);
}
function collectFlat(nodes: LessonTreeNode[]): LessonTreeNode[] {
const out: LessonTreeNode[] = [];
function walk(arr: LessonTreeNode[]) {
for (const n of arr) { out.push(n); walk(n.children); }
}
walk(nodes);
return out;
}
export function LessonTree({ nodes, filter = '' }: { nodes: LessonTreeNode[]; filter?: string }) {
const filtered = useMemo(() => filterTree(nodes, filter), [nodes, filter]);
const refresh = useLessons((s) => s.refresh); const refresh = useLessons((s) => s.refresh);
const currentUserId = useAuth((s) => s.user?.id); const sensors = useSensors(
const [addingTo, setAddingTo] = useState<number | null>(null); useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
const [name, setName] = useState(''); useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
async function addChild(parentId: number | null) { async function handleDragEnd(e: DragEndEvent) {
if (!name.trim()) return; const { active, over } = e;
await lessonsApi.create({ name: name.trim(), parentId }); if (!over || active.id === over.id) return;
setName(''); setAddingTo(null); const flat = collectFlat(filtered);
await refresh(); const movedId = Number(active.id);
} const overId = Number(over.id);
const moved = flat.find((x) => x.id === movedId);
async function rename(id: number, current: string) { const overNode = flat.find((x) => x.id === overId);
const next = prompt('Nieuwe naam', current); if (!moved || !overNode || moved.parentId !== overNode.parentId) return;
if (next && next.trim() && next !== current) { const siblings = flat.filter((x) => x.parentId === moved.parentId);
await lessonsApi.update(id, { name: next.trim() }); const newIdx = siblings.findIndex((x) => x.id === overId);
if (newIdx < 0) return;
try {
await lessonsApi.move(movedId, { parentId: moved.parentId, position: newIdx });
await refresh(); await refresh();
} } catch {/* ignore */}
}
async function remove(id: number) {
if (!confirm('Verwijder les en alle onderliggende lessen en kaarten?')) return;
await lessonsApi.remove(id);
await refresh();
} }
return ( return (
<ul className="space-y-1"> <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
{nodes.map((n) => { <ul className="space-y-1">
const isOwner = n.ownerId === currentUserId; {filtered.map((n) => <TreeRow key={n.id} n={n} depth={0} />)}
const visibilityBadge = </ul>
n.isCurated ? '⭐ Curated' </DndContext>
: n.visibility === 'shared' ? '🌍 Gedeeld' );
: '🔒 Privé'; }
return (
<li key={n.id} style={{ paddingLeft: depth * 20 }}> function TreeRow({ n, depth }: { n: LessonTreeNode; depth: number }) {
<div className="group flex items-center gap-2 rounded-2xl px-3 py-2 transition hover:bg-brand-50/70 dark:hover:bg-slate-800/60"> const refresh = useLessons((s) => s.refresh);
<span className={`h-2 w-2 rounded-full ${depth === 0 ? 'bg-brand-500' : 'bg-brand-300'}`} /> const currentUserId = useAuth((s) => s.user?.id);
<Link to={`/admin/lessons/${n.id}`} className="flex-1 truncate font-medium text-slate-800 dark:text-slate-100"> const isOwner = n.ownerId === currentUserId;
{n.name} const [addingTo, setAddingTo] = useState<number | null>(null);
<span className="ml-2 rounded-full bg-brand-100 px-2 py-0.5 text-xs font-semibold text-brand-700 dark:bg-brand-900/30 dark:text-brand-200"> const [name, setName] = useState('');
{n.cardCount} const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
</span> id: n.id, disabled: !isOwner,
<span className="ml-2 rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-semibold text-slate-600 dark:bg-slate-800 dark:text-slate-300"> });
{visibilityBadge} const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1 };
</span>
{!isOwner && ( async function addChild() {
<span className="ml-1 rounded-full bg-amber-50 px-2 py-0.5 text-[10px] font-semibold text-amber-700 dark:bg-amber-900/30 dark:text-amber-200"> if (!name.trim()) return;
📥 Geabonneerd await lessonsApi.create({ name: name.trim(), parentId: n.id });
</span> setName(''); setAddingTo(null); await refresh();
)} }
</Link> async function rename() {
{isOwner && ( const next = prompt('Nieuwe naam', n.name);
<div className="flex items-center gap-1 opacity-0 transition group-hover:opacity-100"> if (next && next.trim() && next !== n.name) {
<button className="rounded-lg px-2 py-1 text-xs font-medium text-brand-700 hover:bg-brand-100 dark:text-brand-200 dark:hover:bg-brand-900/40" onClick={() => setAddingTo(n.id)}>+ subles</button> await lessonsApi.update(n.id, { name: next.trim() });
<button className="rounded-lg px-2 py-1 text-xs font-medium text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800" onClick={() => rename(n.id, n.name)}>rename</button> await refresh();
<button className="rounded-lg px-2 py-1 text-xs font-medium text-danger-600 hover:bg-danger-50 dark:hover:bg-danger-400/10" onClick={() => remove(n.id)}>delete</button> }
</div> }
)} async function remove() {
</div> if (!confirm('Verwijder les en alle sublessen + kaarten?')) return;
{addingTo === n.id && ( await lessonsApi.remove(n.id);
<div className="ml-6 mt-1 flex gap-2"> await refresh();
<input }
autoFocus
className="input-field flex-1" const visibilityBadge =
value={name} n.isCurated ? '⭐ Curated' : n.visibility === 'shared' ? '🌍 Gedeeld' : '🔒 Privé';
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => { return (
if (e.key === 'Enter') addChild(n.id); <li style={{ paddingLeft: depth * 20 }} ref={setNodeRef}>
if (e.key === 'Escape') { setAddingTo(null); setName(''); } <div
}} style={style}
placeholder="Naam van subles" className="group flex items-center gap-2 rounded-2xl px-3 py-2 transition hover:bg-brand-50/70 dark:hover:bg-slate-800/60"
/> >
<button className="btn-primary px-3" onClick={() => addChild(n.id)}>Toevoegen</button> {isOwner && (
<button className="btn-ghost px-3" onClick={() => { setAddingTo(null); setName(''); }}>Annuleren</button> <span
</div> {...attributes} {...listeners}
)} className="cursor-grab text-slate-400 hover:text-slate-700 active:cursor-grabbing"
{n.children.length > 0 && <LessonTree nodes={n.children} depth={depth + 1} />} title="Sleep om te herordenen"
</li> aria-label="Drag handle"
); ></span>
})} )}
</ul> <span className={`h-2 w-2 rounded-full ${depth === 0 ? 'bg-brand-500' : 'bg-brand-300'}`} />
<Link to={`/admin/lessons/${n.id}`} className="flex-1 truncate font-medium text-slate-800 dark:text-slate-100">
{n.name}
<span className="ml-2 rounded-full bg-brand-100 px-2 py-0.5 text-xs font-semibold text-brand-700 dark:bg-brand-900/30 dark:text-brand-200">
{n.cardCount}
</span>
<span className="ml-2 rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-semibold text-slate-600 dark:bg-slate-800 dark:text-slate-300">
{visibilityBadge}
</span>
{!isOwner && (
<span className="ml-1 rounded-full bg-amber-50 px-2 py-0.5 text-[10px] font-semibold text-amber-700 dark:bg-amber-900/30 dark:text-amber-200">
📥 Geabonneerd
</span>
)}
</Link>
{isOwner && (
<div className="flex items-center gap-1 opacity-0 transition group-hover:opacity-100">
<button className="rounded-lg px-2 py-1 text-xs font-medium text-brand-700 hover:bg-brand-100 dark:text-brand-200 dark:hover:bg-brand-900/40" onClick={() => setAddingTo(n.id)}>+ subles</button>
<button className="rounded-lg px-2 py-1 text-xs font-medium text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800" onClick={rename}>rename</button>
<button className="rounded-lg px-2 py-1 text-xs font-medium text-danger-600 hover:bg-danger-50 dark:hover:bg-danger-400/10" onClick={remove}>delete</button>
</div>
)}
</div>
{addingTo === n.id && (
<div className="ml-6 mt-1 flex gap-2">
<input
autoFocus className="input-field flex-1" value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') addChild(); if (e.key === 'Escape') { setAddingTo(null); setName(''); } }}
placeholder="Naam van subles"
/>
<button className="btn-primary px-3" onClick={addChild}>Toevoegen</button>
<button className="btn-ghost px-3" onClick={() => { setAddingTo(null); setName(''); }}>Annuleren</button>
</div>
)}
{n.children.length > 0 && (
<ul className="space-y-1">
{n.children.map((c) => <TreeRow key={c.id} n={c} depth={depth + 1} />)}
</ul>
)}
</li>
); );
} }