Spaces:
Runtime error
Runtime error
File size: 6,942 Bytes
070c931 66de9c8 070c931 66de9c8 070c931 bcfc897 070c931 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 | import { useCallback, useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { ApiError } from "../api/client";
import { videoTemplatesApi } from "../api/endpoints";
import type { TemplateGeneration } from "../api/types";
import { ConfirmButton, errorMessage, Loading, PageHeader, StatusPill } from "../components/ui";
import { usePoll } from "../hooks/usePoll";
type Template = {
id: number;
name: string;
theme: Record<string, unknown> & { colors?: Record<string, string> };
preview_colors?: Record<string, string>;
content_codes?: string[] | null;
};
type Versions = { current_version_id: number | null; versions: { id: number; label: string | null; created_at: string }[] };
/** One custom template: name, colors, logo, versions, regenerate. */
export default function VideoTemplateEditor() {
const { id: raw = "" } = useParams();
const id = Number(raw);
const navigate = useNavigate();
const [tpl, setTpl] = useState<Template | null>(null);
const [name, setName] = useState("");
const [colors, setColors] = useState<Record<string, string>>({});
const [versions, setVersions] = useState<Versions | null>(null);
const [regenerating, setRegenerating] = useState(false);
const [notFound, setNotFound] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
try {
const t = (await videoTemplatesApi.get(id)) as unknown as Template;
setTpl(t);
setName(t.name);
setColors({ ...(t.theme?.colors ?? {}) });
setVersions((await videoTemplatesApi.versions(id)) as Versions);
} catch (e) {
if (e instanceof ApiError && e.status === 404) setNotFound(true);
else setError(errorMessage(e));
}
}, [id]);
useEffect(() => {
load();
}, [load]);
const regen = usePoll<TemplateGeneration>(regenerating ? () => videoTemplatesApi.generationStatus(id) : null, {
intervalMs: 4000, key: regenerating, until: (s) => s.ready || s.status === "error" || s.status === "complete",
});
useEffect(() => {
if (!regen.done || !regenerating) return;
setMessage(regen.data?.error ? `Regenerating failed: ${regen.data.error}` : "The template was regenerated.");
setRegenerating(false);
load();
}, [regen.done, regen.data, regenerating, load]);
async function act(fn: () => Promise<unknown>, done?: string) {
setError(null);
setMessage(null);
try {
await fn();
if (done) setMessage(done);
await load();
} catch (e) {
setError(errorMessage(e));
}
}
if (notFound) {
return (
<div className="page-wrap">
<PageHeader eyebrow="Videos" title="Template not found" />
<Link to="/app/videos/templates" className="btn">Back to templates</Link>
</div>
);
}
if (!tpl) return error ? <p className="error-text">{error}</p> : <Loading label="Loading template" center="full" />;
return (
<div className="page-wrap">
<PageHeader eyebrow="Templates" title={tpl.name}>
<Link to="/app/videos/templates" className="btn">Back to templates</Link>
<ConfirmButton onConfirm={() => videoTemplatesApi.remove(id).then(() => navigate("/app/videos/templates"))
.catch((e) => setError(errorMessage(e)))}>
Delete
</ConfirmButton>
</PageHeader>
{message && <p className="notice">{message}</p>}
{error && <p className="error-text">{error}</p>}
{regenerating && <p className="notice mono">Regenerating{regen.data?.scenes_total ? ` ${regen.data.scenes_done ?? 0}/${regen.data.scenes_total}` : "..."}</p>}
<div className="stack">
<section className="card stack">
<h3>Name and colors</h3>
<input className="input" value={name} maxLength={255} onChange={(e) => setName(e.target.value)} aria-label="Name" />
<div className="vw-colors">
{Object.entries(colors).filter(([, v]) => typeof v === "string" && v.startsWith("#")).map(([k, v]) => (
<label key={k} className="vw-color">
<input type="color" value={v} onChange={(e) => setColors({ ...colors, [k]: e.target.value })} aria-label={k} />
<span>{k}</span>
</label>
))}
</div>
<div>
<button className="btn btn-primary btn-small"
onClick={() => act(() => videoTemplatesApi.update(id, { name: name.trim(), theme: { ...tpl.theme, colors } }), "Saved.")}>
Save
</button>
</div>
</section>
<section className="card stack">
<h3>Logo</h3>
<input className="input" type="file" accept="image/png,image/jpeg,image/webp,image/svg+xml"
onChange={(e) => { const f = e.target.files?.[0]; if (f) act(() => videoTemplatesApi.uploadLogo(id, f), "Logo uploaded."); }} />
</section>
<section className="card stack">
<h3>Versions</h3>
{(versions?.versions ?? []).length === 0 && <p className="muted small">No earlier versions.</p>}
{versions?.versions.map((v) => (
<div key={v.id} className="row between">
<span>{v.label || `Version ${v.id}`} <span className="muted small">{v.created_at?.slice(0, 16).replace("T", " ")}</span></span>
{v.id === versions.current_version_id ? <StatusPill status="current" /> : (
<ConfirmButton className="btn btn-small" onConfirm={() => act(() => videoTemplatesApi.rollback(id, v.id), "Rolled back.")}>
Roll back
</ConfirmButton>
)}
</div>
))}
</section>
<section className="card stack">
<h3>Regenerate all scenes</h3>
<p className="muted small">Designs every scene again. This uses one of your custom templates.</p>
<div className="row">
<ConfirmButton className="btn btn-small" confirmLabel="Uses a template. Sure?"
onConfirm={() => act(async () => { await videoTemplatesApi.regenerate(id); setRegenerating(true); })}>
Regenerate
</ConfirmButton>
<button className="btn btn-small" onClick={() => act(async () => { await videoTemplatesApi.resume(id); setRegenerating(true); })}>
Resume a stopped generation
</button>
</div>
</section>
<section className="card stack">
<h3>Rate this template</h3>
<div className="row">
{[1, 2, 3, 4, 5].map((n) => (
<button key={n} className="btn btn-small" onClick={() => act(() => videoTemplatesApi.rate(id, { rating: n }), "Thanks!")}>
{"★".repeat(n)}
</button>
))}
</div>
</section>
</div>
</div>
);
}
|