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": ">=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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user