Kanban — the todo board, extracted, over as many projects as you like
was `/todos` in the platform: a board, an API and a tree reader pointed at `resolve(process.cwd(), 'todo')`. that is not a setting — it is the belief that there is one project and it is the one the server happens to have been started in. THE READER CAME ACROSS UNCHANGED except for that one line. the frontmatter parser that never rewrites a body, the ordering, the index writer — all of it as it was, and its 8 tests came too and still pass. everything that changed is that the directory is now a parameter. `writeIndex` no longer writes to `process.cwd()` either. that was the same assumption wearing a different hat, and with two projects it would have had every board overwriting one file. the map goes beside the folder it describes. A PROJECT IS A NAME AND A PATH, and paths are confined to the owner's home by the same three checks av-club uses — absolute, realpath BEFORE the containment test, and `root` or `root + sep` rather than a bare startsWith. spelled out again rather than cross-referenced, because the next person to add a path-taking route reads the file they are in. it stores NO ITEMS. one table, a list of folders. uninstalling with "delete its data" ticked loses the list and never a line of anybody's work — which is the property the markdown format was chosen for. `/projects` is owner-only: pointing this at a directory is a statement about the filesystem. moving a card is not, so a member granted `kanban` can work any board they can see. removing a project removes the ROW. the route says so in its response and the button says so in its tooltip, because "delete" beside a folder full of somebody's work should not be ambiguous. README records the seam honestly: the platform keeps its own todo/ and its CLI, so there are two copies of the reader and they will drift. the alternative was making the platform's own backlog depend on a plugin being installed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { FolderPlus, FolderTree, Loader2, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useProjects, useSelectedProject } from './useKanban';
|
||||
|
||||
/**
|
||||
* Which board you are looking at, and the folders you can look at.
|
||||
*
|
||||
* ── This panel is the whole point of the extraction ──
|
||||
*
|
||||
* The version of this in the platform had no such panel and could not have had one: it read
|
||||
* `resolve(process.cwd(), 'todo')`, so there was exactly one board and no question to ask. Everything
|
||||
* else in this plugin came across nearly unchanged; this is the part that is new.
|
||||
*
|
||||
* ── Selecting writes the URL ──
|
||||
*
|
||||
* `?project=<id>`. The board is a sibling that reads it — the two never speak. So a board is linkable,
|
||||
* survives a reload, and the detail pane can be opened from a link without this panel having rendered.
|
||||
*/
|
||||
|
||||
export const ProjectsPanel = () => {
|
||||
const { projects, isLoading, add, remove } = useProjects();
|
||||
const { selected, select } = useSelectedProject();
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [path, setPath] = useState('');
|
||||
const [confirming, setConfirming] = useState<number | null>(null);
|
||||
|
||||
/*
|
||||
Land on something rather than on nothing.
|
||||
|
||||
With projects but none selected the board would render its empty state, which reads as "this project
|
||||
has no items" when the truth is "no project is chosen". Only when nothing is selected — never
|
||||
overriding a choice, and never fighting a link somebody followed to a specific board.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (selected === null && projects.length > 0) select(projects[0]!.id);
|
||||
}, [selected, projects, select]);
|
||||
|
||||
const submit = (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!name.trim() || !path.trim()) return;
|
||||
add.mutate(
|
||||
{ name: name.trim(), path: path.trim() },
|
||||
{
|
||||
onSuccess: (project) => {
|
||||
setName('');
|
||||
setPath('');
|
||||
setAdding(false);
|
||||
select(project.id);
|
||||
},
|
||||
onError: (err: unknown) => toast.error(err instanceof Error ? err.message : 'could not add that folder'),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="flex size-full flex-col overflow-auto">
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-border px-3 py-2">
|
||||
<span className="text-xs font-medium tracking-wide text-muted-foreground uppercase">Projects</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAdding((open) => !open)}
|
||||
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
title="Add a folder"
|
||||
>
|
||||
<FolderPlus className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{adding && (
|
||||
<form onSubmit={submit} className="flex flex-col gap-2 border-b border-border p-3">
|
||||
<Input autoFocus value={name} onChange={(e) => setName(e.target.value)} placeholder="Name" className="text-sm" />
|
||||
<Input
|
||||
value={path}
|
||||
onChange={(e) => setPath(e.target.value)}
|
||||
placeholder="~/projects/thing/todo"
|
||||
className="font-mono text-xs"
|
||||
spellCheck={false}
|
||||
/>
|
||||
{/* Said before they press it, not after it fails: the folder holding the theme directories is
|
||||
not the repository root, and the two are one level apart. */}
|
||||
<p className="text-[11px] text-muted-foreground/70">
|
||||
The folder that holds the theme directories — usually <code>todo/</code>, not the repo root.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="submit" size="sm" disabled={add.isPending || !name.trim() || !path.trim()}>
|
||||
{add.isPending && <Loader2 className="size-3.5 animate-spin" />}
|
||||
Add
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setAdding(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{isLoading && <p className="p-3 text-xs text-muted-foreground">Loading…</p>}
|
||||
|
||||
{!isLoading && projects.length === 0 && (
|
||||
<p className="p-3 text-xs text-muted-foreground">
|
||||
No folders yet. Add one that contains your <code>todo/</code> themes.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{projects.map((project) => (
|
||||
<div
|
||||
key={project.id}
|
||||
className={`group flex items-start gap-2.5 border-b border-border px-3 py-2.5 transition-colors ${
|
||||
selected === project.id ? 'bg-muted' : 'hover:bg-muted/50'
|
||||
}`}
|
||||
>
|
||||
<FolderTree className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
||||
|
||||
<button type="button" onClick={() => select(project.id)} className="min-w-0 flex-1 text-left">
|
||||
<span className="block truncate text-sm">{project.name}</span>
|
||||
<span className="block truncate font-mono text-[11px] text-muted-foreground">{project.path}</span>
|
||||
</button>
|
||||
|
||||
{/* Two presses, because the word "delete" beside a folder full of somebody's work should not
|
||||
be one. The confirmation says what is actually removed — see the route's own note. */}
|
||||
{confirming === project.id ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
remove.mutate(project.id);
|
||||
setConfirming(null);
|
||||
}}
|
||||
className="shrink-0 rounded px-1.5 py-0.5 text-[11px] text-destructive hover:bg-destructive/10"
|
||||
title="Remove from this list. The folder and its files are not touched."
|
||||
>
|
||||
remove?
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirming(project.id)}
|
||||
className="shrink-0 rounded p-1 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 hover:text-destructive"
|
||||
title="Remove from this list"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user