| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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> |
| ); |
| } |
|
|
| |
| |
| |
|
|
| 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"> </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> |
| ); |
| } |
|
|
| |
| |
| |
|
|
| 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> |
| ); |
| } |
|
|