/**
* InteractiveView — landing page for the "Interactive" tab.
*
* Sits alongside Animate and Studio as a first-class top-level
* tab. Renders a responsive grid of existing interactive-video
* experiences (one card each) with:
*
* - Skeleton grid while fetching
* - Typed error banner with retry on failure
* - Empty state with primary CTA on zero results
* - Toolbar with search + status filter + "New project" button
* - Keyboard-navigable project cards
* - Responsive columns (1 / 2 / 3 at md / lg breakpoints)
*
* Data source: GET /v1/interactive/experiences (owner-scoped).
* Clicking a card hands the experience id up to the parent so
* App.tsx can mount on top of
* this view. Creating from the "New project" button switches
* directly to the wizard without navigating away.
*
* The landing view deliberately does no project-level mutation —
* rename / delete / duplicate live inside the editor where the
* destructive action is surfaced with a confirmation modal.
*/
import React, { useMemo, useState, useCallback } from "react";
import { LogIn, Play, Plus, Trash2, Workflow, Search, Filter, RefreshCw, Sparkles } from "lucide-react";
import { createInteractiveApi } from "./interactive/api";
import { InteractiveApiError } from "./interactive/types";
import { DangerButton, EmptyState, ErrorBanner, Modal, PrimaryButton, SecondaryButton, SkeletonCard, StatusBadge, ToastProvider, useAsyncResource, useToast, } from "./interactive/ui";
export default function InteractiveView(props) {
return ();
}
function InteractiveViewBody({ backendUrl, apiKey, onOpenProject, onCreateNew, onPlayProject, }) {
const api = useMemo(() => createInteractiveApi(backendUrl, apiKey), [backendUrl, apiKey]);
const toast = useToast();
const [query, setQuery] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [confirmingDelete, setConfirmingDelete] = useState(null);
const [deleting, setDeleting] = useState(false);
const resource = useAsyncResource((signal) => api.listExperiences(signal), [api]);
const filtered = useMemo(() => {
const items = resource.data || [];
const q = query.trim().toLowerCase();
return items.filter((e) => {
if (statusFilter !== "all" && e.status !== statusFilter)
return false;
if (!q)
return true;
const haystack = [
e.title || "", e.description || "", e.experience_mode || "",
].join(" ").toLowerCase();
return haystack.includes(q);
});
}, [resource.data, query, statusFilter]);
const totalCount = resource.data?.length ?? 0;
const visibleCount = filtered.length;
// Two non-alarming states we handle with a friendly panel
// instead of the red error banner. We inspect the HTTP status
// (always reliable) rather than regex over free-form messages,
// so any server-side phrasing — 'no user', 'not authenticated',
// 'session expired' — routes to the same empty state.
const serviceOff = resource.errorStatus === 404
|| isInteractiveServiceOff(resource.error || "");
const authRequired = resource.errorStatus === 401
|| resource.errorCode === "not_authenticated"
|| isInteractiveAuthRequired(resource.error || "");
const items = resource.data || [];
const hasProjects = items.length > 0;
const onConfirmDelete = useCallback(async () => {
const target = confirmingDelete;
if (!target || deleting)
return;
setDeleting(true);
// Optimistic: drop the row immediately; on failure put it back.
const snapshot = resource.data || [];
resource.setData((prev) => (prev || []).filter((e) => e.id !== target.id));
try {
await api.deleteExperience(target.id);
toast.toast({
variant: "success",
title: "Project deleted",
message: `"${target.title || "untitled"}" removed.`,
});
}
catch (err) {
resource.setData(snapshot);
const e = err;
toast.toast({
variant: "error",
title: "Couldn't delete the project",
message: e.message || "The project has been restored.",
});
}
finally {
setDeleting(false);
setConfirmingDelete(null);
}
}, [api, confirmingDelete, deleting, resource, toast]);
return (
{/* Progressive disclosure: search + filter chips + "Showing
N projects" only belong once the user actually has
projects to search through. Empty / error / first-run
states hide the toolbar so the hero empty panel is the
singular focal point of the page. */}
{hasProjects && ()}
{/* Confirm destructive delete. Uses the shared Modal primitive
with DangerButton so the red confirmation color is the
single signal that this action can't be undone. */}
!deleting && setConfirmingDelete(null)} title="Delete project?" footer={<>
setConfirmingDelete(null)} disabled={deleting}>
Cancel
}>
Delete project
>}>
This permanently removes{" "}
{confirmingDelete?.title || "(untitled)"}
{" "}
and every scene, action, rule, publication, and analytics
row it owns. Sessions already in flight keep running until
they end, but no new sessions can start.
Branching AI video experiences — scripts, scenes, and viewer choices
that adapt in real time. One workflow for education, training,
language practice, and social storytelling.
{(showReload || showCreate) && (
{showReload && (} aria-label="Refresh project list">
Refresh
)}
{showCreate && (} aria-label="Create new interactive project">
New project
)}
{/* Floating action row (bottom-right), appears on hover/focus
so the card's primary intent stays "open editor". Every
button stops propagation so clicking them doesn't also
open the editor. */}
{/* Delete — ghost icon button. Triggers the parent's
confirm modal instead of deleting outright. */}
{/* Play — primary pill. */}
);
}
// ────────────────────────────────────────────────────────────────
// Empty states
// ────────────────────────────────────────────────────────────────
function FirstRunEmptyState({ onCreate }) {
return (
// Onboarding focus: one icon, one headline, one secondary
// line, one action. Matches Animate's empty-state rhythm so
// both tabs feel like siblings under the HomePilot brand.
} title="No projects yet" description="Create your first interactive experience — branching stories, training flows, or lessons." cta={} size="lg">
Create project
}/>);
}
function ServiceOffEmptyState({ onCreate }) {
return (} title="Your interactive gallery is empty" description={<>
The interactive backend isn't enabled on this server yet.
Ask an operator to set{" "}
INTERACTIVE_ENABLED=true
{" "}
and restart the API. Meanwhile, you can still walk through the
new-project flow to preview what it looks like.
>} cta={} size="lg">
Preview new project flow
}/>);
}
function AuthRequiredEmptyState({ onRetry }) {
return (} title="Sign in to see your interactive projects" description="Projects are owner-scoped, so we need a signed-in user to show them. If you just signed in, retry below — the session cookie sometimes lands a moment late." cta={}>
Try again
}/>);
}
function HeroEmptyPanel({ icon, title, description, cta, }) {
return (
{/* Icon tile picks up a subtle violet tint — matches the
purple filmstrip that Animate uses for its empty state,
so the two features visibly share a design language. */}
{icon}
{title}
{description}
{cta}
);
}
function FilteredEmptyState({ onClear }) {
return (} title="No projects match your filters" description="Try clearing the search query or switching status filters to see more." action={Clear filters}/>);
}
// ────────────────────────────────────────────────────────────────
// Loading grid (first paint)
// ────────────────────────────────────────────────────────────────
function LoadingGrid() {
return (
{Array.from({ length: 6 }).map((_, i) => )}
);
}
// ────────────────────────────────────────────────────────────────
// Helpers
// ────────────────────────────────────────────────────────────────
function humanizeMode(mode) {
const map = {
sfw_general: "General",
sfw_education: "Education",
language_learning: "Language learning",
enterprise_training: "Enterprise training",
social_romantic: "Social / Romantic",
mature_gated: "Mature (gated)",
};
return map[mode] || mode.replace(/_/g, " ");
}
function friendlyError(message) {
if (!message)
return "Unexpected error.";
if (/NetworkError|Failed to fetch|Network error/i.test(message)) {
return "Couldn't reach the backend. Check your connection or server URL and try again.";
}
if (/401|not_authenticated/i.test(message)) {
return "You're signed out or your API key is missing. Open Settings to fix it.";
}
if (/503|service_disabled/i.test(message)) {
return "The interactive service is currently disabled on this backend.";
}
return message;
}
/** A 404 / not_found on /experiences means the interactive router
* isn't mounted — INTERACTIVE_ENABLED is off or the backend is an
* older build. Distinct from real errors so we can render a
* friendly empty state instead of a red alarm. */
function isInteractiveServiceOff(message) {
return /\b404\b|not[_ ]found/i.test(message);
}
/** A 401 / not_authenticated means the viewer resolver rejected
* the request — cookie missing, expired, or users table has a
* profile we can't auto-match. Renders a "sign in" empty state
* instead of a raw error. */
function isInteractiveAuthRequired(message) {
return /\b401\b|not[_ ]authenticated|unauthorized/i.test(message);
}
function relativeTime(iso) {
const then = Date.parse(iso);
if (!then || Number.isNaN(then))
return "";
const diffMs = Date.now() - then;
const mins = Math.floor(diffMs / 60_000);
if (mins < 1)
return "just now";
if (mins < 60)
return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24)
return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 30)
return `${days}d ago`;
const months = Math.floor(days / 30);
if (months < 12)
return `${months}mo ago`;
const years = Math.floor(months / 12);
return `${years}y ago`;
}
export { InteractiveApiError };