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 { errorText } from 'helpers/error-text'; 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 (project === null) { return (
Choose a project on the left, or add one.
); } /* THE ERROR, not a guess at it. This said "the todo board is owner-only, and this account was refused" — true of the version in the platform, where `/todos` was `kind: 'admin'`, and false here, where the permission is grantable. It survived the extraction as a sentence describing a rule that no longer exists, and the first real failure it met was a 500 from a mis-pointed project, which it reported as a permission problem. A wrong explanation is worse than none: it sends somebody to check their account when the answer is on screen in the panel beside them. */ if (error) { return (
This board could not be read. {errorText(error, 'unknown error')}
); } if (isLoading || !data) { return
Reading…
; } /* No themes at all is almost always the same mistake — the project points at a repository rather than at the folder inside it that holds the themes. Said here, where somebody is looking, rather than left as an empty board that reads like "no work outstanding". */ if (data.themes.length === 0) { return (
No themes in this folder. A theme is a directory with an _index.md in it. If you pointed this at a repository, the folder you want is usually todo/ inside it.
); } 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} )}
); };