From 0529e2a5e8215ef1083de48a28e48057ec86c856 Mon Sep 17 00:00:00 2001 From: Bert Hausmans Date: Thu, 21 May 2026 07:11:13 +0200 Subject: [PATCH] feat(frontend): lesson tree with filter + dnd-kit drag reorder --- package-lock.json | 56 +++++ packages/frontend/package.json | 3 + .../frontend/src/components/LessonTree.tsx | 222 ++++++++++++------ 3 files changed, 203 insertions(+), 78 deletions(-) diff --git a/package-lock.json b/package-lock.json index abd8bff..ac6d795 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 9542666..0b88956 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -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", diff --git a/packages/frontend/src/components/LessonTree.tsx b/packages/frontend/src/components/LessonTree.tsx index cce710e..31d2e7b 100644 --- a/packages/frontend/src/components/LessonTree.tsx +++ b/packages/frontend/src/components/LessonTree.tsx @@ -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(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 ( - + +
    + {filtered.map((n) => )} +
+
+ ); +} + +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(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 ( +
  • +
    + {isOwner && ( + ⋮⋮ + )} + + + {n.name} + + {n.cardCount} + + + {visibilityBadge} + + {!isOwner && ( + + 📥 Geabonneerd + + )} + + {isOwner && ( +
    + + + +
    + )} +
    + {addingTo === n.id && ( +
    + setName(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') addChild(); if (e.key === 'Escape') { setAddingTo(null); setName(''); } }} + placeholder="Naam van subles" + /> + + +
    + )} + {n.children.length > 0 && ( +
      + {n.children.map((c) => )} +
    + )} +
  • ); }