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>
184 lines
7.5 KiB
TypeScript
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>
|
|
);
|
|
};
|