/** * 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( (signal) => api.listActions(projectId, signal), [api, projectId], ); const [createOpen, setCreateOpen] = useState(false); const [confirmDelete, setConfirmDelete] = useState(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 ( setCreateOpen(true)} size="sm" icon={} > New action } > {resource.error ? ( ) : resource.loading && !resource.data ? (
{Array.from({ length: 4 }).map((_, i) => )}
) : items.length === 0 ? ( } title="No actions yet" description="Add at least one action to give viewers something to do during playback." action={ setCreateOpen(true)} icon={} > Add your first action } /> ) : ( )} setCreateOpen(false)} api={api} projectId={projectId} onCreated={(a) => { onCreated(a); setCreateOpen(false); toast.toast({ variant: "success", title: "Action created", message: a.label }); }} /> setConfirmDelete(null)} title="Delete action?" footer={ <> setConfirmDelete(null)}>Cancel }> Delete } >

This will remove {confirmDelete?.label} from the catalog. Existing sessions keep running; new sessions won't see it.

); } // ──────────────────────────────────────────────────────────────── // Table // ──────────────────────────────────────────────────────────────── function ActionTable({ items, onDelete, }: { items: ActionItem[]; onDelete: (a: ActionItem) => void; }) { return (
{items.map((a) => ( ))}
Label Intent Level gate Scheme XP Cooldown  
{a.label} {a.intent_code ? {a.intent_code} : } {a.required_level ?? 1} / {a.required_metric_key || "level"} {a.required_scheme || "xp_level"} {a.xp_award ? +{a.xp_award} : 0} {a.cooldown_sec ? `${a.cooldown_sec}s` : }
); } // ──────────────────────────────────────────────────────────────── // 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("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 ( { if (!submitting) { reset(); onClose(); } }} title="New action" widthClass="max-w-xl" footer={ <> { reset(); onClose(); }} disabled={submitting}> Cancel Create } >
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]" /> 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]" />
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]" />
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]" /> 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]" />
); } function FormField({ label, hint, required, children, }: { label: string; hint?: string; required?: boolean; children: React.ReactNode; }) { return (
{hint &&

{hint}

} {children}
); }