Files
kanban/web/ProjectsPanel.tsx
pastilhasandClaude Opus 5 24354049f4 browse for the folder instead of typing it
the project form asked for a path and gave a text field, while fofos-admin — built
hours earlier — had a directory picker for exactly the same question. that picker
is now shared from the officerdev barrel, so this uses it.

browse OR type: the picker fills the field and the FIELD is what saves, so the path
stays visible and editable before it is stored. a folder outside home is still
reachable by typing, and the server refuses it with a reason rather than the picker
silently making it unreachable.

picking a folder offers its last two segments as the name when the name is empty —
`thing/todo` — because that is what somebody would have typed. left editable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 07:09:49 +01:00

184 lines
7.5 KiB
TypeScript

import { useEffect, useState } from 'react';
import { FolderPlus, FolderTree, Loader2, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { DirectoryPicker } from 'officerdev';
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);
const [picking, setPicking] = useState(false);
/*
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"
/>
{/*
Browse OR type. The picker fills the field and the field is what saves, so the path stays
visible and editable before it is stored — and a folder outside home, which the picker
cannot reach, can still be typed and will be refused by the server with a reason.
*/}
<div className="flex gap-2">
<Input
value={path}
onChange={(e) => setPath(e.target.value)}
placeholder="~/projects/thing/todo"
className="min-w-0 flex-1 font-mono text-xs"
spellCheck={false}
/>
<Button type="button" variant="outline" size="sm" onClick={() => setPicking((open) => !open)}>
{picking ? 'Close' : 'Browse'}
</Button>
</div>
{picking && (
<DirectoryPicker
onCancel={() => setPicking(false)}
onPick={(absolutePath) => {
setPath(absolutePath);
setPicking(false);
// A folder is usually named after itself — offer that as the name rather than an empty
// field, and leave it editable.
if (!name.trim()) setName(absolutePath.split('/').filter(Boolean).slice(-2).join('/'));
}}
/>
)}
{/* 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>
);
};