Text sizes now follow a --font-scale variable that is 1 on wider screens and 8/7 below 768px, so the 14px base text reads at 16px on phones (Safari stops zooming into fields at that size) and every other size grows by the same ratio. One-off pixel sizes in the shell move onto the shared text tokens so they scale too. The stage subtitle now truncates inside the screen instead of spilling past both edges. Claude-Session: https://claude.ai/code/session_01PFEDgbyJm12ExL3Kn4Dk8B Co-authored-by: Claude <noreply@anthropic.com>
203 lines
6.2 KiB
TypeScript
203 lines
6.2 KiB
TypeScript
import { Trans, useLingui } from "@lingui/react/macro";
|
|
import type { ScratchpadItem } from "@rakazo/contracts";
|
|
import { Button, Checkbox, Input } from "@rakazo/ui-web";
|
|
import { X } from "lucide-react";
|
|
import { useEffect, useRef, useState } from "react";
|
|
|
|
import { rpc } from "../lib/rpc";
|
|
|
|
export function ScratchpadSection({ botId }: { botId: string }) {
|
|
const { t } = useLingui();
|
|
const [items, setItems] = useState<ScratchpadItem[]>([]);
|
|
const [draft, setDraft] = useState("");
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const listGeneration = useRef(0);
|
|
|
|
async function refresh() {
|
|
const generation = ++listGeneration.current;
|
|
const list = await rpc.scratchpad.list({ botId });
|
|
if (generation !== listGeneration.current) return;
|
|
setItems(list);
|
|
}
|
|
|
|
useEffect(() => {
|
|
const generation = ++listGeneration.current;
|
|
void rpc.scratchpad
|
|
.list({ botId })
|
|
.then((list) => {
|
|
if (generation !== listGeneration.current) return;
|
|
setItems(list);
|
|
})
|
|
.catch(() => {
|
|
if (generation !== listGeneration.current) return;
|
|
setItems([]);
|
|
});
|
|
return () => {
|
|
listGeneration.current += 1;
|
|
};
|
|
}, [botId]);
|
|
|
|
async function addItem() {
|
|
const title = draft.trim();
|
|
if (!title || busy) return;
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
const created = await rpc.scratchpad.create({ botId, title });
|
|
setDraft("");
|
|
setItems((current) => [created, ...current.filter((item) => item.id !== created.id)]);
|
|
try {
|
|
await refresh();
|
|
} catch {
|
|
setError(t`Saved, but list refresh failed`);
|
|
}
|
|
} catch {
|
|
setError(t`Could not add`);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function setStatus(item: ScratchpadItem, status: ScratchpadItem["status"]) {
|
|
if (busy) return;
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
const updated = await rpc.scratchpad.update({ itemId: item.id, status });
|
|
setItems((current) => {
|
|
const next = current.map((entry) => (entry.id === updated.id ? updated : entry));
|
|
return status === "done" ? next.filter((entry) => entry.status !== "done") : next;
|
|
});
|
|
try {
|
|
await refresh();
|
|
} catch {
|
|
setError(t`Saved, but list refresh failed`);
|
|
}
|
|
} catch {
|
|
setError(t`Could not update`);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function removeItem(item: ScratchpadItem) {
|
|
if (busy) return;
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
await rpc.scratchpad.remove({ itemId: item.id });
|
|
setItems((current) => current.filter((entry) => entry.id !== item.id));
|
|
try {
|
|
await refresh();
|
|
} catch {
|
|
setError(t`Removed, but list refresh failed`);
|
|
}
|
|
} catch {
|
|
setError(t`Could not remove`);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="mt-6" data-testid="bot-scratchpad">
|
|
<div className="mb-3 text-ui-body text-muted-foreground">
|
|
<Trans>Open work</Trans>
|
|
</div>
|
|
{items.length === 0 ? (
|
|
<div className="py-1 text-ui-body text-muted-foreground">
|
|
<Trans>None yet</Trans>
|
|
</div>
|
|
) : (
|
|
items.map((item) => (
|
|
<div
|
|
key={item.id}
|
|
className="flex w-full items-start gap-2 rounded-xl px-2.5 py-2.5 hover:bg-accent"
|
|
>
|
|
<Checkbox
|
|
aria-label={item.status === "done" ? t`Reopen` : t`Complete`}
|
|
checked={item.status === "done"}
|
|
disabled={busy}
|
|
onCheckedChange={(checked) => void setStatus(item, checked ? "done" : "open")}
|
|
className="mt-0.5"
|
|
/>
|
|
<div className="min-w-0 flex-1">
|
|
<div
|
|
className={`text-start text-ui-body-sm ${item.status === "done" ? "text-muted-foreground/80 line-through" : "text-foreground"}`}
|
|
dir="auto"
|
|
>
|
|
{item.title}
|
|
</div>
|
|
{item.notes ? (
|
|
<div className="mt-0.5 text-ui-body-sm text-muted-foreground/80" dir="auto">
|
|
{item.notes}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
<span className="shrink-0 text-ui-body-sm text-muted-foreground/80">{item.status}</span>
|
|
{item.status === "open" ? (
|
|
<Button
|
|
variant="ghost"
|
|
size="xs"
|
|
aria-label={t`Park`}
|
|
disabled={busy}
|
|
onClick={() => void setStatus(item, "parked")}
|
|
className="shrink-0 text-muted-foreground/70"
|
|
>
|
|
<Trans>Park</Trans>
|
|
</Button>
|
|
) : item.status === "parked" ? (
|
|
<Button
|
|
variant="ghost"
|
|
size="xs"
|
|
aria-label={t`Reopen`}
|
|
disabled={busy}
|
|
onClick={() => void setStatus(item, "open")}
|
|
className="shrink-0 text-muted-foreground/70"
|
|
>
|
|
<Trans>Open</Trans>
|
|
</Button>
|
|
) : null}
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-xs"
|
|
aria-label={t`Remove`}
|
|
disabled={busy}
|
|
onClick={() => void removeItem(item)}
|
|
className="shrink-0 text-muted-foreground/70"
|
|
>
|
|
<X />
|
|
</Button>
|
|
</div>
|
|
))
|
|
)}
|
|
<form
|
|
className="mt-2 flex items-center gap-2"
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
void addItem();
|
|
}}
|
|
>
|
|
<Input
|
|
value={draft}
|
|
onChange={(event) => setDraft(event.target.value)}
|
|
placeholder={t`Add item`}
|
|
aria-label={t`New open-work item`}
|
|
maxLength={200}
|
|
className="min-w-0 flex-1"
|
|
/>
|
|
<Button
|
|
variant="secondary"
|
|
className="rounded-full"
|
|
disabled={busy || !draft.trim()}
|
|
onClick={() => void addItem()}
|
|
>
|
|
<Trans>Add</Trans>
|
|
</Button>
|
|
</form>
|
|
{error ? <div className="mt-2 text-ui-body-sm text-destructive">{error}</div> : null}
|
|
</div>
|
|
);
|
|
}
|