import type { DragEvent } from 'react'; import { useState } from 'react'; import { Link, useSearchParams } from 'react-router'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { AlertTriangle, CalendarClock, X } from 'lucide-react'; import { cn } from '@/lib/utils'; import type { TodoItem } from './useKanban'; import { useSelectedProject, useTodoItem, useTodoPatch, useTodoTree } from './useKanban'; /** * The board over one project's todo folder — one file per item. * * ── Carried across from the platform almost unchanged ── * * This was `TodoBoard/TodoBoardPanel.tsx`. The columns, the drag handling, the ordering and the detail * pane are as they were; what changed is where the data comes from — every read and write now names a * project, because there is more than one. * * Selection and the theme filter live in the URL (`?selected=`, `?theme=`, and now `?project=`), per the * navigation audit: a card is a real link, a filtered board is an addressable page, and back means what * it should. * * Writes go through `PATCH /kanban/item`, which touches frontmatter only and regenerates the index — * this board never holds state the files do not; a drag is a file edit and a refetch. */ const COLUMNS = [ { status: 'open', label: 'Open' }, { status: 'doing', label: 'Doing' }, { status: 'blocked', label: 'Blocked' }, { status: 'backburner', label: 'Backburner' }, { status: 'done', label: 'Done' }, ] as const; export const BoardPanel = () => { // Read from the URL rather than passed in: this panel and the projects panel are siblings that never // speak to each other, per the navigation audit. The selector writes `?project=`; this reads it. const { selected: project } = useSelectedProject(); const { data, isLoading, error } = useTodoTree(project); const patch = useTodoPatch(project); const [params] = useSearchParams(); const selected = params.get('selected'); const themeFilter = params.get('theme'); const [dragOver, setDragOver] = useState(null); const href = (changes: Record): string => { const next = new URLSearchParams(params); for (const [key, value] of Object.entries(changes)) { if (value === null) next.delete(key); else next.set(key, value); } const qs = next.toString(); return qs ? `?${qs}` : '.'; }; if (error) { return (
The todo board is owner-only, and this account was refused. Nothing here is broken — there is simply nothing to show.
); } if (isLoading || !data) { return
Reading todo/…
; } const themes = data.themes.filter((t) => t.items.length > 0); const items = themes.flatMap((t) => t.items); const visible = themeFilter ? items.filter((i) => i.theme === themeFilter) : items; const drop = (status: string) => (ev: DragEvent) => { ev.preventDefault(); setDragOver(null); const id = ev.dataTransfer.getData('text/todo-id'); if (id) patch.mutate({ id, status }); }; return (
All · {items.filter((i) => i.status !== 'done').length} {themes.map((t) => { const open = t.items.filter((i) => i.status !== 'done').length; return ( {t.heading.split('—')[0]?.trim()} · {open} ); })}
{COLUMNS.map((col) => { const cards = visible .filter((i) => i.status === col.status) .sort((a, b) => (b.priority === 'urgent' ? 1 : 0) - (a.priority === 'urgent' ? 1 : 0)); return (
{ ev.preventDefault(); setDragOver(col.status); }} onDragLeave={() => setDragOver(null)} onDrop={drop(col.status)} className={cn( 'flex w-64 shrink-0 flex-col rounded-lg border border-border bg-muted/30', dragOver === col.status && 'border-primary bg-primary/5', )} >
{col.label} {cards.length}
{cards.map((item) => ( ))}
); })}
{selected ? : null}
); }; type TodoCardProps = { item: TodoItem; href: string; selected: boolean }; const TodoCard = ({ item, href, selected }: TodoCardProps) => ( ev.dataTransfer.setData('text/todo-id', item.id)} className={cn( 'block rounded-md border border-border bg-card p-2.5 text-left shadow-sm hover:border-primary/50', selected && 'border-primary', )} >
{item.title}
{item.theme} {item.priority === 'urgent' && ( urgent )} {item.due && ( {item.due} )} {item.created}
); type TodoDetailProps = { id: string; closeHref: string }; const TodoDetail = ({ id, closeHref }: TodoDetailProps) => { // Same source as the board above it. Threading it down as a prop would work equally well; reading the // URL keeps the detail pane openable from a link without the board having rendered first. const { selected: project } = useSelectedProject(); const { data, isLoading } = useTodoItem(project, id); const patch = useTodoPatch(project); const status = data?.meta.status ?? 'open'; return (
{id}
{isLoading || !data ? ( Reading… ) : ( {data.body} )}
); };