manifest, schema, router and two panels. the editor is hosted and saving; the last step is not finished and the readme says so. designs are rows scoped per user, with userId in every WHERE rather than checked after the read — another person's design is 'not found', so the id space cannot be probed. the Doc is stored whole as jsonb because upstream adds fields to it constantly and none of them should be a migration here. the handoff is the reason the plugin exists and it is the part that does not work: /api/chat has NO session-creation route, a session is constructed by the browser opening a websocket, so a plugin has nothing to POST to. onExport returns false and the clipboard still runs, which is upstream's own behaviour — degraded, not broken. docs/handoff.md names the one platform change that would finish it, and argues it should be generic rather than ours. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
120 lines
4.5 KiB
TypeScript
120 lines
4.5 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { useSearchParams } from "react-router";
|
|
import { FilePlus2, PenTool, RefreshCw, Trash2 } from "lucide-react";
|
|
import { useClient } from "hooks/useClient";
|
|
|
|
/**
|
|
* The list of designs — the one concept upstream does not have.
|
|
*
|
|
* Standalone M3E Canvas holds exactly one document, in one browser, in `localStorage`. Everything here
|
|
* exists because Officer gives it several, belonging to a person rather than to a browser.
|
|
*
|
|
* Selection is `?doc=<id>` in the URL rather than a channel, so a design is addressable and survives a
|
|
* reload — `docs/navigation-audit.md`. The canvas panel reads the same parameter rather than being
|
|
* handed the id.
|
|
*/
|
|
|
|
type Row = { id: number; title: string; updatedAt: string };
|
|
|
|
export const DesignsPanel = () => {
|
|
const client = useClient();
|
|
const clientRef = useRef(client);
|
|
clientRef.current = client;
|
|
|
|
const [rows, setRows] = useState<Row[] | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [params, setParams] = useSearchParams();
|
|
const selected = params.get("doc");
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
setRows(await clientRef.current.get<Row[]>("/m3e-canvas/documents"));
|
|
setError(null);
|
|
} catch {
|
|
setError("Could not load designs.");
|
|
setRows(null);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void load();
|
|
}, [load]);
|
|
|
|
// The canvas creates a row on its first save, so this only has to clear the selection — a design
|
|
// with nothing drawn in it should not exist, and creating one here would leave empties behind
|
|
// every time somebody clicked New and changed their mind.
|
|
const startNew = () => setParams((p) => { p.delete("doc"); return p; }, { replace: true });
|
|
|
|
const remove = async (id: number, title: string) => {
|
|
if (!confirm(`Delete "${title}"? This cannot be undone.`)) return;
|
|
try {
|
|
await clientRef.current.delete(`/m3e-canvas/documents/${id}`);
|
|
if (selected === String(id)) startNew();
|
|
await load();
|
|
} catch {
|
|
setError("Could not delete that design.");
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="flex size-full flex-col">
|
|
<header className="flex items-center justify-between border-b px-2 py-1.5">
|
|
<button
|
|
type="button"
|
|
onClick={startNew}
|
|
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-sm hover:bg-muted"
|
|
>
|
|
<FilePlus2 className="size-4" /> New
|
|
</button>
|
|
<button type="button" onClick={() => void load()} className="p-1 text-muted-foreground hover:text-foreground">
|
|
<RefreshCw className="size-3.5" />
|
|
</button>
|
|
</header>
|
|
|
|
{error ? <p className="px-3 py-2 text-xs text-destructive">{error}</p> : null}
|
|
|
|
{rows === null ? (
|
|
<Centred>Loading…</Centred>
|
|
) : rows.length === 0 ? (
|
|
<Centred>No designs yet. Draw something and it saves itself.</Centred>
|
|
) : (
|
|
<ul className="flex-1 overflow-auto">
|
|
{rows.map((row) => (
|
|
<li key={row.id} className="group flex items-center">
|
|
<button
|
|
type="button"
|
|
onClick={() => setParams((p) => { p.set("doc", String(row.id)); return p; }, { replace: true })}
|
|
className={`flex min-w-0 flex-1 items-center gap-2 px-3 py-2 text-left hover:bg-muted/50 ${
|
|
selected === String(row.id) ? "bg-muted" : ""
|
|
}`}
|
|
>
|
|
<PenTool className="size-3.5 shrink-0 text-muted-foreground" />
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block truncate text-sm">{row.title}</span>
|
|
<span className="block truncate text-xs text-muted-foreground">
|
|
{new Date(row.updatedAt).toLocaleDateString()}
|
|
</span>
|
|
</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => void remove(row.id, row.title)}
|
|
aria-label={`Delete ${row.title}`}
|
|
className="px-2 text-muted-foreground opacity-0 transition-opacity hover:text-destructive group-hover:opacity-100"
|
|
>
|
|
<Trash2 className="size-3.5" />
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const Centred = ({ children }: { children: React.ReactNode }) => (
|
|
<div className="flex flex-1 items-center justify-center p-4 text-center text-xs text-muted-foreground">
|
|
{children}
|
|
</div>
|
|
);
|