the owner pointed a project at the platform's repo root — an easy mistake, since `todo/` sits inside one — and got "the todo board is owner-only, and this account was refused". three things were wrong and only one of them was the path. A THEME IS NOW A DIRECTORY WITH AN `_index.md`. readTree assumed every subdirectory was one, which was safe when the only possible root was the platform's own todo folder and is not safe now. against a repo root it read `src/_index.md`, threw ENOENT, and the route 500'd. skipping non-themes is also just correct for a real todo folder, which may hold a README beside its themes. THE ERROR MESSAGE WAS A LIE IT INHERITED. "owner-only, and this account was refused" was true of `/todos` in the platform, where the permission was `kind: 'admin'`. here it is grantable and there is no such gate — so the sentence survived the extraction describing a rule that no longer exists, and the first real failure it met was reported as a permission problem. it shows the actual error now. a wrong explanation is worse than none: it sent the owner to check their own account when the answer was in the panel beside them. AND AN EMPTY FOLDER SAYS SO. no themes at all is almost always this same mistake, so it names it — a theme is a directory with an `_index.md`, and if you pointed at a repository the folder you want is usually `todo/` inside it. an empty board otherwise reads as "no work outstanding", which is the opposite of the truth. verified: repo root now 200 with 0 themes and the explanation; the real folder reads 38 themes and 139 items. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
266 lines
10 KiB
TypeScript
266 lines
10 KiB
TypeScript
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<string | null>(null);
|
|
|
|
const href = (changes: Record<string, string | null>): 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 (
|
|
<div className="flex h-full items-center justify-center p-8 text-center text-sm text-muted-foreground">
|
|
Choose a project on the left, or add one.
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/*
|
|
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 (
|
|
<div className="flex h-full flex-col items-center justify-center gap-2 p-8 text-center text-sm text-muted-foreground">
|
|
<span>This board could not be read.</span>
|
|
<span className="max-w-md text-xs text-destructive">{errorText(error, 'unknown error')}</span>
|
|
</div>
|
|
);
|
|
}
|
|
if (isLoading || !data) {
|
|
return <div className="flex h-full items-center justify-center text-sm text-muted-foreground">Reading…</div>;
|
|
}
|
|
|
|
/*
|
|
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 (
|
|
<div className="flex h-full flex-col items-center justify-center gap-2 p-8 text-center text-sm text-muted-foreground">
|
|
<span>No themes in this folder.</span>
|
|
<span className="max-w-md text-xs">
|
|
A theme is a directory with an <code>_index.md</code> in it. If you pointed this at a repository, the folder
|
|
you want is usually <code>todo/</code> inside it.
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className="flex h-full min-h-0">
|
|
<div className="flex min-w-0 flex-1 flex-col">
|
|
<div className="flex flex-wrap items-center gap-1.5 border-b border-border px-4 py-2">
|
|
<Link
|
|
to={href({ theme: null })}
|
|
className={cn(
|
|
'rounded-full px-2.5 py-0.5 text-xs',
|
|
!themeFilter
|
|
? 'bg-primary text-primary-foreground'
|
|
: 'bg-muted text-muted-foreground hover:text-foreground',
|
|
)}
|
|
>
|
|
All · {items.filter((i) => i.status !== 'done').length}
|
|
</Link>
|
|
{themes.map((t) => {
|
|
const open = t.items.filter((i) => i.status !== 'done').length;
|
|
return (
|
|
<Link
|
|
key={t.theme}
|
|
to={href({ theme: t.theme === themeFilter ? null : t.theme })}
|
|
className={cn(
|
|
'rounded-full px-2.5 py-0.5 text-xs',
|
|
themeFilter === t.theme
|
|
? 'bg-primary text-primary-foreground'
|
|
: 'bg-muted text-muted-foreground hover:text-foreground',
|
|
)}
|
|
>
|
|
{t.heading.split('—')[0]?.trim()} · {open}
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div className="flex min-h-0 flex-1 gap-3 overflow-x-auto p-4">
|
|
{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 (
|
|
<div
|
|
key={col.status}
|
|
onDragOver={(ev) => {
|
|
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',
|
|
)}
|
|
>
|
|
<div className="flex items-baseline justify-between px-3 py-2 text-xs font-semibold text-muted-foreground">
|
|
<span>{col.label}</span>
|
|
<span>{cards.length}</span>
|
|
</div>
|
|
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto px-2 pb-2">
|
|
{cards.map((item) => (
|
|
<TodoCard
|
|
key={item.id}
|
|
item={item}
|
|
href={href({ selected: item.id })}
|
|
selected={selected === item.id}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{selected ? <TodoDetail id={selected} closeHref={href({ selected: null })} /> : null}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
type TodoCardProps = { item: TodoItem; href: string; selected: boolean };
|
|
|
|
const TodoCard = ({ item, href, selected }: TodoCardProps) => (
|
|
<Link
|
|
to={href}
|
|
draggable
|
|
onDragStart={(ev) => 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',
|
|
)}
|
|
>
|
|
<div className="text-xs leading-snug text-card-foreground">{item.title}</div>
|
|
<div className="mt-1.5 flex flex-wrap items-center gap-1.5 text-[10px] text-muted-foreground">
|
|
<span className="rounded bg-muted px-1 py-px">{item.theme}</span>
|
|
{item.priority === 'urgent' && (
|
|
<span className="flex items-center gap-0.5 rounded bg-destructive/15 px-1 py-px text-destructive">
|
|
<AlertTriangle className="h-2.5 w-2.5" /> urgent
|
|
</span>
|
|
)}
|
|
{item.due && (
|
|
<span className="flex items-center gap-0.5">
|
|
<CalendarClock className="h-2.5 w-2.5" /> {item.due}
|
|
</span>
|
|
)}
|
|
<span>{item.created}</span>
|
|
</div>
|
|
</Link>
|
|
);
|
|
|
|
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 (
|
|
<div className="flex w-[28rem] shrink-0 flex-col border-l border-border">
|
|
<div className="flex items-center justify-between gap-2 border-b border-border px-4 py-2">
|
|
<span className="truncate font-mono text-xs text-muted-foreground">{id}</span>
|
|
<div className="flex items-center gap-2">
|
|
<select
|
|
value={status}
|
|
onChange={(ev) => patch.mutate({ id, status: ev.target.value })}
|
|
className="rounded border border-border bg-background px-1.5 py-0.5 text-xs"
|
|
>
|
|
{COLUMNS.map((c) => (
|
|
<option key={c.status} value={c.status}>
|
|
{c.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<Link to={closeHref} className="text-muted-foreground hover:text-foreground">
|
|
<X className="h-4 w-4" />
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
<div className="file-viewer-md min-h-0 flex-1 overflow-y-auto px-4 py-3 text-sm">
|
|
{isLoading || !data ? (
|
|
<span className="text-sm text-muted-foreground">Reading…</span>
|
|
) : (
|
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{data.body}</ReactMarkdown>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|