HomePilot / frontend /src /ui /interactive /CatalogPanel.tsx
HomePilot Deploy Bot
chore(hf): sync HomePilot to HF Space
921d377
Raw
History Blame Contribute Delete
13.8 kB
/**
* CatalogPanel — CRUD for ix_action_catalog.
*
* Table view of actions (label, intent, level gate, scheme, XP,
* cooldown) plus a "+ New action" modal. Delete is gated by a
* confirm modal so one misclick doesn't nuke a gating action.
*
* Mutations are optimistic: create injects a tombstone row into
* the cached data with a local id, rolls back on failure; delete
* removes the row immediately and re-inserts it if the server
* rejects. Both paths surface a toast.
*/
import React, { useCallback, useMemo, useState } from "react";
import { ListChecks, Plus, Trash2 } from "lucide-react";
import type { InteractiveApi } from "./api";
import { InteractiveApiError } from "./types";
import type { ActionItem, ProgressionScheme } from "./types";
import {
DangerButton,
EmptyState,
ErrorBanner,
Modal,
Panel,
PrimaryButton,
SecondaryButton,
SkeletonRow,
useAsyncResource,
useToast,
} from "./ui";
export interface CatalogPanelProps {
api: InteractiveApi;
projectId: string;
}
const SCHEMES: ProgressionScheme[] = [
"xp_level", "mastery", "cefr", "affinity_tier", "certification",
];
export function CatalogPanel({ api, projectId }: CatalogPanelProps) {
const toast = useToast();
const resource = useAsyncResource<ActionItem[]>(
(signal) => api.listActions(projectId, signal),
[api, projectId],
);
const [createOpen, setCreateOpen] = useState(false);
const [confirmDelete, setConfirmDelete] = useState<ActionItem | null>(null);
const items = useMemo(() => resource.data || [], [resource.data]);
const onCreated = useCallback((created: ActionItem) => {
resource.setData((prev) => [...(prev || []), created]);
}, [resource]);
const onDelete = useCallback(async () => {
const target = confirmDelete;
if (!target) return;
const snapshot = items;
resource.setData((prev) => (prev || []).filter((a) => a.id !== target.id));
setConfirmDelete(null);
try {
await api.deleteAction(target.id);
toast.toast({ variant: "success", title: "Action deleted" });
} catch (err) {
const e = err as InteractiveApiError;
resource.setData(snapshot);
toast.toast({
variant: "error",
title: "Couldn't delete",
message: e.message || "The action has been restored.",
});
}
}, [api, confirmDelete, items, resource, toast]);
return (
<Panel
title="Action catalog"
subtitle="Actions viewers can take during playback. Level gates, cooldowns, and XP rewards apply per-turn."
actions={
<PrimaryButton
onClick={() => setCreateOpen(true)}
size="sm"
icon={<Plus className="w-4 h-4" aria-hidden />}
>
New action
</PrimaryButton>
}
>
{resource.error ? (
<ErrorBanner
title="Couldn't load the catalog"
message={resource.error}
onRetry={resource.reload}
/>
) : resource.loading && !resource.data ? (
<div className="flex flex-col gap-2" aria-busy="true">
{Array.from({ length: 4 }).map((_, i) => <SkeletonRow key={i} />)}
</div>
) : items.length === 0 ? (
<EmptyState
icon={<ListChecks className="w-12 h-12" aria-hidden />}
title="No actions yet"
description="Add at least one action to give viewers something to do during playback."
action={
<PrimaryButton
onClick={() => setCreateOpen(true)}
icon={<Plus className="w-4 h-4" aria-hidden />}
>
Add your first action
</PrimaryButton>
}
/>
) : (
<ActionTable items={items} onDelete={setConfirmDelete} />
)}
<NewActionModal
open={createOpen}
onClose={() => setCreateOpen(false)}
api={api}
projectId={projectId}
onCreated={(a) => {
onCreated(a);
setCreateOpen(false);
toast.toast({ variant: "success", title: "Action created", message: a.label });
}}
/>
<Modal
open={!!confirmDelete}
onClose={() => setConfirmDelete(null)}
title="Delete action?"
footer={
<>
<SecondaryButton onClick={() => setConfirmDelete(null)}>Cancel</SecondaryButton>
<DangerButton onClick={onDelete} icon={<Trash2 className="w-4 h-4" aria-hidden />}>
Delete
</DangerButton>
</>
}
>
<p className="text-sm text-[#cfd8dc]">
This will remove <span className="font-medium">{confirmDelete?.label}</span> from the
catalog. Existing sessions keep running; new sessions won't see it.
</p>
</Modal>
</Panel>
);
}
// ────────────────────────────────────────────────────────────────
// Table
// ────────────────────────────────────────────────────────────────
function ActionTable({
items, onDelete,
}: {
items: ActionItem[];
onDelete: (a: ActionItem) => void;
}) {
return (
<div className="overflow-x-auto -mx-5 px-5">
<table className="w-full min-w-[720px] text-sm">
<thead>
<tr className="text-left text-[11px] uppercase tracking-wide text-[#777] border-b border-[#3f3f3f]">
<th className="py-2 pr-3 font-medium">Label</th>
<th className="py-2 pr-3 font-medium">Intent</th>
<th className="py-2 pr-3 font-medium">Level gate</th>
<th className="py-2 pr-3 font-medium">Scheme</th>
<th className="py-2 pr-3 font-medium text-right">XP</th>
<th className="py-2 pr-3 font-medium text-right">Cooldown</th>
<th className="py-2 pl-3 font-medium text-right">&nbsp;</th>
</tr>
</thead>
<tbody className="divide-y divide-[#2a2a2a]">
{items.map((a) => (
<tr key={a.id} className="hover:bg-[#121212]">
<td className="py-2.5 pr-3 text-[#f1f1f1] font-medium">{a.label}</td>
<td className="py-2.5 pr-3 text-[#cfd8dc]">
{a.intent_code
? <code className="text-xs bg-[#121212] border border-[#3f3f3f] rounded px-1.5 py-0.5">{a.intent_code}</code>
: <span className="text-[#777]"></span>}
</td>
<td className="py-2.5 pr-3 text-[#cfd8dc]">
{a.required_level ?? 1}
<span className="text-[#777] text-xs"> / {a.required_metric_key || "level"}</span>
</td>
<td className="py-2.5 pr-3 text-[#cfd8dc]">{a.required_scheme || "xp_level"}</td>
<td className="py-2.5 pr-3 text-[#cfd8dc] text-right">
{a.xp_award ? <span>+{a.xp_award}</span> : <span className="text-[#777]">0</span>}
</td>
<td className="py-2.5 pr-3 text-[#cfd8dc] text-right">
{a.cooldown_sec ? `${a.cooldown_sec}s` : <span className="text-[#777]"></span>}
</td>
<td className="py-2.5 pl-3 text-right">
<button
type="button"
onClick={() => onDelete(a)}
aria-label={`Delete ${a.label}`}
className="text-[#aaa] hover:text-red-400 p-1 rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500"
>
<Trash2 className="w-4 h-4" aria-hidden />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
// ────────────────────────────────────────────────────────────────
// New-action modal
// ────────────────────────────────────────────────────────────────
function NewActionModal({
open, onClose, api, projectId, onCreated,
}: {
open: boolean;
onClose: () => void;
api: InteractiveApi;
projectId: string;
onCreated: (a: ActionItem) => void;
}) {
const toast = useToast();
const [label, setLabel] = useState("");
const [intentCode, setIntentCode] = useState("");
const [requiredLevel, setRequiredLevel] = useState(1);
const [scheme, setScheme] = useState<ProgressionScheme>("xp_level");
const [xpAward, setXpAward] = useState(0);
const [cooldown, setCooldown] = useState(0);
const [submitting, setSubmitting] = useState(false);
const reset = useCallback(() => {
setLabel(""); setIntentCode(""); setRequiredLevel(1);
setScheme("xp_level"); setXpAward(0); setCooldown(0);
}, []);
const canSubmit = label.trim().length > 0 && !submitting;
const submit = useCallback(async () => {
if (!canSubmit) return;
setSubmitting(true);
try {
const created = await api.createAction(projectId, {
label: label.trim(),
intent_code: intentCode.trim(),
required_level: requiredLevel,
required_scheme: scheme,
required_metric_key: scheme === "xp_level" ? "level" : "",
xp_award: xpAward,
cooldown_sec: cooldown,
});
reset();
onCreated(created);
} catch (err) {
const e = err as InteractiveApiError;
toast.toast({
variant: "error",
title: "Couldn't create action",
message: e.message || "Check the inputs and try again.",
});
setSubmitting(false);
}
}, [api, canSubmit, cooldown, intentCode, label, onCreated, projectId, requiredLevel, reset, scheme, toast, xpAward]);
return (
<Modal
open={open}
onClose={() => { if (!submitting) { reset(); onClose(); } }}
title="New action"
widthClass="max-w-xl"
footer={
<>
<SecondaryButton onClick={() => { reset(); onClose(); }} disabled={submitting}>
Cancel
</SecondaryButton>
<PrimaryButton onClick={submit} disabled={!canSubmit} loading={submitting}>
Create
</PrimaryButton>
</>
}
>
<div className="flex flex-col gap-4">
<FormField label="Label" required>
<input
type="text"
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="e.g. Greet the host"
maxLength={80}
className="w-full bg-[#121212] border border-[#3f3f3f] rounded-md px-3 py-2 text-sm outline-none focus:border-[#3ea6ff]"
/>
</FormField>
<FormField label="Intent code" hint="Free-form string used to route to edges; e.g. 'greeting', 'flirt'.">
<input
type="text"
value={intentCode}
onChange={(e) => setIntentCode(e.target.value)}
placeholder="(optional)"
maxLength={40}
className="w-full bg-[#121212] border border-[#3f3f3f] rounded-md px-3 py-2 text-sm outline-none focus:border-[#3ea6ff]"
/>
</FormField>
<div className="grid grid-cols-2 gap-3">
<FormField label="Required level">
<input
type="number"
min={1} max={50}
value={requiredLevel}
onChange={(e) => setRequiredLevel(Math.max(1, parseInt(e.target.value, 10) || 1))}
className="w-full bg-[#121212] border border-[#3f3f3f] rounded-md px-3 py-2 text-sm outline-none focus:border-[#3ea6ff]"
/>
</FormField>
<FormField label="Progression scheme">
<select
value={scheme}
onChange={(e) => setScheme(e.target.value as ProgressionScheme)}
className="w-full bg-[#121212] border border-[#3f3f3f] rounded-md px-3 py-2 text-sm outline-none focus:border-[#3ea6ff]"
>
{SCHEMES.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
</FormField>
</div>
<div className="grid grid-cols-2 gap-3">
<FormField label="XP award" hint="XP granted when this action is taken (xp_level scheme).">
<input
type="number"
min={0} max={500}
value={xpAward}
onChange={(e) => setXpAward(Math.max(0, parseInt(e.target.value, 10) || 0))}
className="w-full bg-[#121212] border border-[#3f3f3f] rounded-md px-3 py-2 text-sm outline-none focus:border-[#3ea6ff]"
/>
</FormField>
<FormField label="Cooldown (seconds)" hint="Per-session cooldown between repeat uses.">
<input
type="number"
min={0} max={3600}
value={cooldown}
onChange={(e) => setCooldown(Math.max(0, parseInt(e.target.value, 10) || 0))}
className="w-full bg-[#121212] border border-[#3f3f3f] rounded-md px-3 py-2 text-sm outline-none focus:border-[#3ea6ff]"
/>
</FormField>
</div>
</div>
</Modal>
);
}
function FormField({
label, hint, required, children,
}: {
label: string;
hint?: string;
required?: boolean;
children: React.ReactNode;
}) {
return (
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-[#cfd8dc]">
{label}
{required && <span className="text-[#3ea6ff] ml-0.5" aria-label="required">*</span>}
</label>
{hint && <p className="text-[11px] text-[#777] -mt-0.5">{hint}</p>}
{children}
</div>
);
}