feat(frontend): lesson tree with filter + dnd-kit drag reorder
This commit is contained in:
56
package-lock.json
generated
56
package-lock.json
generated
@@ -483,6 +483,59 @@
|
||||
"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": {
|
||||
"version": "0.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz",
|
||||
@@ -7960,6 +8013,9 @@
|
||||
"name": "@flashcard/frontend",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@flashcard/shared": "*",
|
||||
"canvas-confetti": "^1.9.0",
|
||||
"framer-motion": "^11.0.0",
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@flashcard/shared": "*",
|
||||
"canvas-confetti": "^1.9.0",
|
||||
"framer-motion": "^11.0.0",
|
||||
|
||||
@@ -1,92 +1,158 @@
|
||||
import { useState } from 'react';
|
||||
import { useMemo, 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';
|
||||
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 currentUserId = useAuth((s) => s.user?.id);
|
||||
const [addingTo, setAddingTo] = useState<number | null>(null);
|
||||
const [name, setName] = useState('');
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
);
|
||||
|
||||
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() });
|
||||
async function handleDragEnd(e: DragEndEvent) {
|
||||
const { active, over } = e;
|
||||
if (!over || active.id === over.id) return;
|
||||
const flat = collectFlat(filtered);
|
||||
const movedId = Number(active.id);
|
||||
const overId = Number(over.id);
|
||||
const moved = flat.find((x) => x.id === movedId);
|
||||
const overNode = flat.find((x) => x.id === overId);
|
||||
if (!moved || !overNode || moved.parentId !== overNode.parentId) return;
|
||||
const siblings = flat.filter((x) => x.parentId === moved.parentId);
|
||||
const newIdx = siblings.findIndex((x) => x.id === overId);
|
||||
if (newIdx < 0) return;
|
||||
try {
|
||||
await lessonsApi.move(movedId, { parentId: moved.parentId, position: newIdx });
|
||||
await refresh();
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id: number) {
|
||||
if (!confirm('Verwijder les en alle onderliggende lessen en kaarten?')) return;
|
||||
await lessonsApi.remove(id);
|
||||
await refresh();
|
||||
} catch {/* ignore */}
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="space-y-1">
|
||||
{nodes.map((n) => {
|
||||
const isOwner = n.ownerId === currentUserId;
|
||||
const visibilityBadge =
|
||||
n.isCurated ? '⭐ Curated'
|
||||
: n.visibility === 'shared' ? '🌍 Gedeeld'
|
||||
: '🔒 Privé';
|
||||
return (
|
||||
<li key={n.id} style={{ paddingLeft: depth * 20 }}>
|
||||
<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">
|
||||
<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(n.id, n.name)}>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(n.id)}>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(n.id);
|
||||
if (e.key === 'Escape') { setAddingTo(null); setName(''); }
|
||||
}}
|
||||
placeholder="Naam van subles"
|
||||
/>
|
||||
<button className="btn-primary px-3" onClick={() => addChild(n.id)}>Toevoegen</button>
|
||||
<button className="btn-ghost px-3" onClick={() => { setAddingTo(null); setName(''); }}>Annuleren</button>
|
||||
</div>
|
||||
)}
|
||||
{n.children.length > 0 && <LessonTree nodes={n.children} depth={depth + 1} />}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<ul className="space-y-1">
|
||||
{filtered.map((n) => <TreeRow key={n.id} n={n} depth={0} />)}
|
||||
</ul>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
function TreeRow({ n, depth }: { n: LessonTreeNode; depth: number }) {
|
||||
const refresh = useLessons((s) => s.refresh);
|
||||
const currentUserId = useAuth((s) => s.user?.id);
|
||||
const isOwner = n.ownerId === currentUserId;
|
||||
const [addingTo, setAddingTo] = useState<number | null>(null);
|
||||
const [name, setName] = useState('');
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: n.id, disabled: !isOwner,
|
||||
});
|
||||
const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1 };
|
||||
|
||||
async function addChild() {
|
||||
if (!name.trim()) return;
|
||||
await lessonsApi.create({ name: name.trim(), parentId: n.id });
|
||||
setName(''); setAddingTo(null); await refresh();
|
||||
}
|
||||
async function rename() {
|
||||
const next = prompt('Nieuwe naam', n.name);
|
||||
if (next && next.trim() && next !== n.name) {
|
||||
await lessonsApi.update(n.id, { name: next.trim() });
|
||||
await refresh();
|
||||
}
|
||||
}
|
||||
async function remove() {
|
||||
if (!confirm('Verwijder les en alle sublessen + kaarten?')) return;
|
||||
await lessonsApi.remove(n.id);
|
||||
await refresh();
|
||||
}
|
||||
|
||||
const visibilityBadge =
|
||||
n.isCurated ? '⭐ Curated' : n.visibility === 'shared' ? '🌍 Gedeeld' : '🔒 Privé';
|
||||
|
||||
return (
|
||||
<li style={{ paddingLeft: depth * 20 }} ref={setNodeRef}>
|
||||
<div
|
||||
style={style}
|
||||
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"
|
||||
>
|
||||
{isOwner && (
|
||||
<span
|
||||
{...attributes} {...listeners}
|
||||
className="cursor-grab text-slate-400 hover:text-slate-700 active:cursor-grabbing"
|
||||
title="Sleep om te herordenen"
|
||||
aria-label="Drag handle"
|
||||
>⋮⋮</span>
|
||||
)}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user