/** * Shared presentation primitives for the Interactive tab. * * Why one file for the lot: the Interactive surface re-uses the * same 6 primitives dozens of times (toast, button, panel, empty * state, skeleton, status badge). Keeping them co-located keeps * the import surface narrow and the visual language tight: * * bg tokens: #0f0f0f / #1a1a1a / #1f1f1f / #121212 * borders: #3f3f3f (default), #555 (hover) * accent: #3ea6ff → selected/ring/primary-CTA color * text tokens: #f1f1f1 (primary), #aaa (secondary), #777 (tertiary) * * Accessibility: * - `PrimaryButton` + `SecondaryButton` always render a ); } export function SecondaryButton({ size = "md", loading, icon, children, className, disabled, ...rest }: BaseButtonProps) { return ( ); } export function DangerButton({ size = "md", loading, icon, children, className, disabled, ...rest }: BaseButtonProps) { return ( ); } // ──────────────────────────────────────────────────────────────── // Panel — titled section wrapper used by editor tabs // ──────────────────────────────────────────────────────────────── export function Panel({ title, subtitle, actions, children, className, }: { title?: React.ReactNode; subtitle?: React.ReactNode; actions?: React.ReactNode; children: React.ReactNode; className?: string; }) { return (
{(title || actions) && (
{title &&

{title}

} {subtitle &&

{subtitle}

}
{actions &&
{actions}
}
)}
{children}
); } // ──────────────────────────────────────────────────────────────── // EmptyState // ──────────────────────────────────────────────────────────────── export function EmptyState({ icon, title, description, action, }: { icon: React.ReactNode; title: string; description?: string; action?: React.ReactNode; }) { return (
{icon}
{title}
{description && (

{description}

)} {action &&
{action}
}
); } // ──────────────────────────────────────────────────────────────── // Skeleton loaders (grid card + list row) // ──────────────────────────────────────────────────────────────── export function SkeletonCard() { return (
); } export function SkeletonRow() { return (
); } // ──────────────────────────────────────────────────────────────── // ErrorBanner — inline retriable error // ──────────────────────────────────────────────────────────────── export function ErrorBanner({ title = "Something went wrong", message, onRetry, }: { title?: string; message: string; onRetry?: () => void; }) { return (
{title}

{message}

{onRetry && ( )}
); } // ──────────────────────────────────────────────────────────────── // StatusBadge // ──────────────────────────────────────────────────────────────── const STATUS_STYLES: Record = { draft: "text-yellow-300 bg-yellow-400/10 border-yellow-400/30", in_review: "text-blue-300 bg-blue-400/10 border-blue-400/30", approved: "text-emerald-300 bg-emerald-400/10 border-emerald-400/30", archived: "text-[#aaa] bg-white/5 border-white/10", published: "text-green-300 bg-green-400/10 border-green-400/30", }; const STATUS_LABEL: Record = { draft: "Draft", in_review: "In review", approved: "Approved", archived: "Archived", published: "Published", }; export function StatusBadge({ status }: { status: ExperienceStatus }) { return ( {STATUS_LABEL[status] || status} ); } // ──────────────────────────────────────────────────────────────── // Toast system — accessible, auto-dismissing // ──────────────────────────────────────────────────────────────── type ToastVariant = "success" | "error" | "info" | "warning"; interface ToastItem { id: number; variant: ToastVariant; title: string; message?: string; timeoutMs: number; } interface ToastContextValue { toast(t: { variant?: ToastVariant; title: string; message?: string; timeoutMs?: number }): void; } const ToastContext = createContext(null); export function useToast(): ToastContextValue { const ctx = useContext(ToastContext); if (!ctx) { // Graceful no-op when the provider isn't mounted, so individual // panels stay testable outside . return { toast({ title }) { // eslint-disable-next-line no-console console.info(`[toast] ${title}`); }, }; } return ctx; } export function ToastProvider({ children }: { children: React.ReactNode }) { const [items, setItems] = useState([]); const remove = useCallback((id: number) => { setItems((xs) => xs.filter((x) => x.id !== id)); }, []); const toast = useCallback((t) => { const id = Date.now() + Math.random(); const next: ToastItem = { id, variant: t.variant ?? "info", title: t.title, message: t.message, timeoutMs: t.timeoutMs ?? (t.variant === "error" ? 6000 : 3500), }; setItems((xs) => [...xs, next]); window.setTimeout(() => remove(id), next.timeoutMs); }, [remove]); const ctx = useMemo(() => ({ toast }), [toast]); return ( {children}
{items.map((item) => ( remove(item.id)} /> ))}
); } function ToastCard({ item, onClose }: { item: ToastItem; onClose: () => void }) { const palette: Record = { success: { border: "border-emerald-500/40", bg: "bg-emerald-500/10", icon: , }, error: { border: "border-red-500/40", bg: "bg-red-500/10", icon: , }, warning: { border: "border-amber-500/40", bg: "bg-amber-500/10", icon: , }, info: { border: "border-[#3ea6ff]/40", bg: "bg-[#3ea6ff]/10", icon: , }, }; const p = palette[item.variant]; return (
{p.icon}
{item.title}
{item.message && (
{item.message}
)}
); } // ──────────────────────────────────────────────────────────────── // useAsyncResource — data loader with loading/error/data states // ──────────────────────────────────────────────────────────────── export interface AsyncResource { data: T | null; loading: boolean; error: string | null; /** HTTP status (or null) so callers can branch on 401 / 404 / * 5xx without parsing free-form error messages. */ errorStatus: number | null; /** Machine-readable code from the backend's uniform error shape * (e.g. 'not_authenticated', 'not_found'). Empty string on * non-API errors. */ errorCode: string; reload: () => void; setData: React.Dispatch>; } export function useAsyncResource( load: (signal: AbortSignal) => Promise, deps: React.DependencyList, ): AsyncResource { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [errorStatus, setErrorStatus] = useState(null); const [errorCode, setErrorCode] = useState(""); const [token, setToken] = useState(0); useEffect(() => { const ctrl = new AbortController(); let cancelled = false; setLoading(true); setError(null); setErrorStatus(null); setErrorCode(""); load(ctrl.signal) .then((result) => { if (!cancelled) setData(result); }) .catch((err: Error) => { if (!cancelled && err.name !== "AbortError") { setError(err.message || "Unexpected error"); if (err instanceof InteractiveApiError) { setErrorStatus(err.status); setErrorCode(err.code); } } }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; ctrl.abort(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [...deps, token]); const reload = useCallback(() => setToken((t) => t + 1), []); return { data, loading, error, errorStatus, errorCode, reload, setData }; } // ──────────────────────────────────────────────────────────────── // Modal — accessible dialog with backdrop + ESC to close // ──────────────────────────────────────────────────────────────── export function Modal({ open, onClose, title, children, footer, widthClass = "max-w-lg", }: { open: boolean; onClose: () => void; title: string; children: React.ReactNode; footer?: React.ReactNode; widthClass?: string; }) { useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [open, onClose]); if (!open) return null; return (
{ if (e.target === e.currentTarget) onClose(); }} >

{title}

{children}
{footer && ( )}
); }