- Page scroll container is overflow-x-hidden so the interface stays fixed; long content can no longer push the page wider than the viewport (tree was ~50% cut off). - CardTable wrapped in overflow-x-auto with a min-width so only the table scrolls horizontally on small screens. - Sublesson and lesson-tree rows get min-w-0 so truncate works in flex; long names now ellipsize instead of overflowing. Tree drag handle + hover actions hidden on mobile (were unusable via touch anyway), freeing width for the name. - Lesson detail title wraps and scales down on mobile.
163 lines
7.1 KiB
TypeScript
163 lines
7.1 KiB
TypeScript
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 { SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
|
import { CSS } from '@dnd-kit/utilities';
|
|
|
|
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 sensors = useSensors(
|
|
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
|
|
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
|
);
|
|
|
|
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();
|
|
} catch {/* ignore */}
|
|
}
|
|
|
|
return (
|
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
|
<SortableContext items={filtered.map((n) => n.id)} strategy={verticalListSortingStrategy}>
|
|
<ul className="space-y-1">
|
|
{filtered.map((n) => <TreeRow key={n.id} n={n} depth={0} />)}
|
|
</ul>
|
|
</SortableContext>
|
|
</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 min-w-0 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="hidden shrink-0 cursor-grab text-slate-400 hover:text-slate-700 active:cursor-grabbing sm:inline"
|
|
title="Sleep om te herordenen"
|
|
aria-label="Drag handle"
|
|
>⋮⋮</span>
|
|
)}
|
|
<span className={`h-2 w-2 shrink-0 rounded-full ${depth === 0 ? 'bg-brand-500' : 'bg-brand-300'}`} />
|
|
<Link to={`/lessons/${n.id}`} className="min-w-0 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="hidden shrink-0 items-center gap-1 opacity-0 transition group-hover:opacity-100 sm:flex">
|
|
<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 && (
|
|
<SortableContext items={n.children.map((c) => c.id)} strategy={verticalListSortingStrategy}>
|
|
<ul className="space-y-1">
|
|
{n.children.map((c) => <TreeRow key={c.id} n={c} depth={depth + 1} />)}
|
|
</ul>
|
|
</SortableContext>
|
|
)}
|
|
</li>
|
|
);
|
|
}
|