NoteStack-Test / frontend /src /pages /VideoCreate.tsx
HumeraR's picture
Refactor VideoTemplateEditor component: remove AI editing features and related state management, streamline template loading, and update styles for improved layout. Enhance CSS for responsive design and visual consistency across various components, including dropdowns, sync stats, and quiz elements. Remove deprecated premium badge styles and adjust layout for chat components.
66de9c8
Raw History Blame Contribute Delete
23.2 kB
import { useCallback, useEffect, useRef, useState } from "react";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { videosApi, videoVoicesApi, voiceApi } from "../api/endpoints";
import type {
VideoCatalog,
VideoConfig,
VideoCreateBody,
VideoFocusSource,
VideoFocusTopic,
VideoOptions,
VideoQuota,
VideoSavedVoice,
VideoStyleItem,
VideoTemplate,
VideoVoicesResponse,
} from "../api/types";
import { Dropdown } from "../components/Dropdown";
import { CheckIcon, SparkleIcon } from "../components/icons/Icons";
import { ErrorText, errorMessage, Loading, PageHeader } from "../components/ui";
import { SourcePicker, type VideoSource } from "../components/video/SourcePicker";
import { loadArchiveId, sourceCache } from "../components/video/sourceCache";
import { PlayButton, TemplateThumb, useAudio } from "../components/video/parts";
import { useUpgrade } from "../hooks/useUpgrade";
const FALLBACK_COLORS = { accent_color: "#7c3aed", bg_color: "#ffffff", text_color: "#000000" };
/** A template and the colours it was designed with. */
const templateFields = (t: VideoTemplate) => ({
template: t.id,
accent_color: t.preview_colors?.accent ?? FALLBACK_COLORS.accent_color,
bg_color: t.preview_colors?.bg ?? FALLBACK_COLORS.bg_color,
text_color: t.preview_colors?.text ?? FALLBACK_COLORS.text_color,
});
const cap = (s?: string | null) => (s ? s[0].toUpperCase() + s.slice(1) : "");
function Steps({ step }: { step: number }) {
const names = ["Project", "Template", "Voice"];
return (
<div className="vw-steps" aria-label={`Step ${step} of 3: ${names[step - 1]}`}>
<ol>
{names.map((name, i) => (
<li key={i} className={i + 1 < step ? "done" : i + 1 === step ? "on" : ""} title={name}
aria-current={i + 1 === step ? "step" : undefined}>
<span>{i + 1 < step ? <CheckIcon size={14} /> : i + 1}</span>
</li>
))}
</ol>
</div>
);
}
/** The New video page (/app/videos/new?notebook_id=… | document_id=…). */
export default function VideoCreate() {
const [params] = useSearchParams();
return <VideoCreateForm startNotebook={params.get("notebook_id")} startDoc={params.get("document_id")} />;
}
/** The three-step video wizard: as a page, or inside a modal (inModal: no page header, no step-1 Back, no outer card;
* the modal's own close button leaves it). */
export function VideoCreateForm({ startNotebook = null, startDoc = null, inModal = false }: {
startNotebook?: string | null; startDoc?: string | null; inModal?: boolean;
}) {
const navigate = useNavigate();
const { openUpgrade } = useUpgrade();
const [config, setConfig] = useState<VideoConfig | null>(null);
const [catalog, setCatalog] = useState<VideoCatalog | null>(null);
const [voices, setVoices] = useState<VideoVoicesResponse | null>(null);
const [quota, setQuota] = useState<VideoQuota | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [step, setStep] = useState(1);
const [source, setSource] = useState<VideoSource | null>(
startNotebook ? { kind: "notebook", id: startNotebook } : startDoc ? { kind: "post", id: startDoc }
: sourceCache.archiveId ? { kind: "notebook", id: sourceCache.archiveId } : null,
);
const [postIds, setPostIds] = useState<string[]>([]);
// Step 1's "focus on": the AI's three suggestions for the source chosen (sourceKey), and the one picked (none =
// the whole material).
const [topics, setTopics] = useState<VideoFocusTopic[] | null>(null);
const topicsFor = useRef(""); // the sourceKey of the suggestions shown or on their way
const [focus, setFocus] = useState<VideoFocusTopic | null>(null);
// Or the user's own topic: one or the other (or neither, for a general video).
const [custom, setCustom] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const { playing, play, stop } = useAudio();
/** Notestack voices have no stored sample: the Voice page speaks a line in them. */
function playVoice(v: VideoSavedVoice) {
if (v.source !== "notestack") return play(v.voice_id, v.preview_url);
if (playing === v.voice_id) return stop();
voiceApi.preview(v.voice_id).then((r) => play(v.voice_id, r.url)).catch((e) => setError(errorMessage(e)));
}
const [form, setForm] = useState<VideoOptions>({
stock_footage_enabled: true,
aspect_ratio: "landscape",
video_length: "medium", // no length picker: the server sets it from a house style
logo_position: "bottom_right",
logo_opacity: 0.9,
template: "default",
video_style: "auto",
...FALLBACK_COLORS,
content_language: null,
voice_gender: "female",
voice_accent: "american",
bgm_volume: 0.1,
});
const set = (patch: Partial<VideoOptions>) => setForm((f) => ({ ...f, ...patch }));
const gridBox = useRef<HTMLDivElement>(null);
const loadVoices = useCallback(
() => videoVoicesApi.list().then((v) => {
setVoices(v);
return v;
}),
[],
);
useEffect(() => {
videosApi.config().then(setConfig).catch((e) => setLoadError(errorMessage(e)));
videosApi.quota().then(setQuota).catch(() => undefined);
// Nothing picked on the way in: start from every post, in the "All posts" notebook.
if (!startNotebook && !startDoc && !sourceCache.archiveId) {
loadArchiveId().then((id) => setSource((s) => s ?? { kind: "notebook", id })).catch(() => undefined);
}
}, [startNotebook, startDoc]);
useEffect(() => {
if (!config?.configured) return;
videosApi.catalog().then((c) => {
setCatalog(c);
// Start on a random built-in template, unless the form already names a real one.
const first = c.templates[Math.floor(Math.random() * c.templates.length)];
const known = new Set([...c.my_templates, ...c.templates].map((t) => t.id));
if (first) setForm((f) => (known.has(f.template) ? f : { ...f, ...templateFields(first) }));
// Start on the first house style (Overview) unless the form already names one on offer.
const house = c.video_styles.filter((s) => s.kind === "house");
if (house.length) setForm((f) => (house.some((s) => s.id === f.video_style) ? f : { ...f, video_style: house[0].id }));
}).catch((e) => setLoadError(errorMessage(e)));
loadVoices()
.then((v) => v.saved[0] && pickVoice(v.saved[0]))
.catch(() => setVoices({ saved: [], library: [], notestack: [] }));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config?.configured]);
// Keep the chosen template visible inside the template grid's own scroll box (never scrolls the page).
useEffect(() => {
const box = gridBox.current;
const card = box?.querySelector<HTMLElement>(`[data-id="${CSS.escape(form.template)}"]`);
if (!box || !card) return;
const top = card.offsetTop; // the box is position: relative, so this is measured from its top
if (top < box.scrollTop || top + card.offsetHeight > box.scrollTop + box.clientHeight) {
box.scrollTop = top - 8;
}
}, [form.template, step, catalog]);
const templates = catalog?.templates ?? [];
const shown: VideoTemplate[] = templates;
// The house styles alone when the account has them; otherwise "Auto" and the rest, as before.
const listed = catalog?.video_styles ?? [];
const houseStyles = listed.some((s) => s.kind === "house");
const styles: VideoStyleItem[] = houseStyles ? listed : [{ id: "auto", name: "Auto", kind: "builtin" }, ...listed];
const picked = styles.find((s) => s.id === form.video_style);
// A video speaks in a free built-in voice; a Notestack voice (made on the Voice page) is for audio overviews.
const saved = (voices?.saved ?? []).filter((v) => v.source !== "notestack");
const left = quota ? Math.max(quota.limit - quota.used, 0) : null;
function pickTemplate(t: VideoTemplate) {
set(templateFields(t));
}
function pickVoice(v: VideoSavedVoice) {
setForm((f) => ({
...f,
voice_gender: v.gender === "male" ? "male" : "female",
voice_accent: v.accent === "british" ? "british" : "american",
custom_voice_id: v.voice_id,
}));
}
const sourceReady = source?.kind === "post" || (source?.kind === "notebook" && postIds.length > 0);
/** Step 1's choice as API fields: the same for the focus suggestions and the create. */
const sourceFields = (): VideoFocusSource | null => !source ? null
: source.kind === "post" ? { document_id: source.id }
: { document_ids: postIds, notebook_id: source.id };
// The focus suggestions belong to one source choice. They are asked for as soon as the choice is complete and show
// below it on step 1; a new source or other ticks asks again. The short wait lets a run of ticks settle into one
// request.
const sourceKey = !source ? ""
: `${source.kind}:${source.id}:${source.kind === "notebook" ? [...postIds].sort().join(",") : ""}`;
useEffect(() => {
if (!sourceReady || sourceKey === topicsFor.current) return;
const fields = sourceFields();
if (!fields) return;
const timer = setTimeout(() => {
topicsFor.current = sourceKey;
setTopics(null);
setFocus(null);
// An answer for a choice that has since changed is dropped (topicsFor moved on). A failure counts as no
// topics: the block hides, and this choice is not asked again.
videosApi.focusTopics(fields)
.then((r) => topicsFor.current === sourceKey && setTopics(r.topics))
.catch(() => topicsFor.current === sourceKey && setTopics([]));
}, 600);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sourceKey, sourceReady]);
// Suggestions made for an earlier choice are never shown for this one: the cards wait for the new ones (and so
// does Go to step 2). null = still loading; [] = none to show.
const shownTopics = topicsFor.current === sourceKey ? topics : null;
/** The focus for the create: a suggested topic, the user's own, or none (a general video). */
function focusFields(): Pick<VideoCreateBody, "focus" | "focus_detail" | "focus_prompt"> {
if (focus) return { focus: focus.title, focus_detail: focus.description };
return custom.trim() ? { focus_prompt: custom.trim() } : {};
}
function options(): VideoOptions {
const o: VideoOptions = { ...form };
if (o.voice_gender === "none") delete o.custom_voice_id;
return o;
}
async function submit() {
setError(null);
setBusy(true);
try {
const from = sourceFields();
if (!from) return;
const a = await videosApi.create({ ...options(), ...from, ...focusFields() });
navigate(`/app/videos/${a.id}`);
} catch (e) {
setError(errorMessage(e)); // 402 also opens the upgrade popup (api/client.ts)
setBusy(false);
}
}
if (loadError) {
return (
<div className={inModal ? "vw-in-modal" : "page-wrap"}>
{!inModal && <PageHeader eyebrow="Videos" title="New video" />}
<ErrorText message={loadError} page />
</div>
);
}
if (!config) return <Loading label="Loading" center="full" />;
if (!config.configured) {
return (
<div className={inModal ? "vw-in-modal" : "page-wrap"}>
{!inModal && <PageHeader eyebrow="Videos" title="New video" />}
<p className="notice">Video is not set up on this server yet.</p>
</div>
);
}
const currentVoice = saved.find((v) => v.voice_id === form.custom_voice_id);
return (
<div className={inModal ? "stack vw-in-modal" : "page-wrap"}>
{inModal ? (
left !== null && <span className="mono muted small vw-modal-quota">{left} of {quota!.limit} video{quota!.limit === 1 ? "" : "s"} left</span>
) : (
<PageHeader eyebrow="Videos" title="New video">
{left !== null && (
<span className="mono muted">{left} of {quota!.limit} video{quota!.limit === 1 ? "" : "s"} left</span>
)}
</PageHeader>
)}
{step === 1 && !inModal && (
// Leaves the wizard for wherever it was opened from. Steps 2 and 3 have their own Back at the bottom.
<button type="button" className="vw-back"
onClick={() => (window.history.length > 1 ? navigate(-1) : navigate("/app/archive?tab=videos"))}>
Back
</button>
)}
<Steps step={step} />
<section className={inModal ? "stack vw" : "card stack vw"}>
{/* Stays mounted on steps 2 and 3, so going Back keeps the loaded posts and the ticks */}
<div style={{ display: step === 1 ? "contents" : "none" }}>
<SourcePicker source={source} onSource={setSource} postIds={postIds} onPostIds={setPostIds} />
</div>
{step === 1 && (
<>
{/* Landscape with stock footage, always: the form's defaults, no picker */}
<div className="vw-style-row">
<div className="field">
<span className="vw-label">Video style</span>
{catalog === null ? <Loading label="Loading styles" /> : (
<div className="vw-chips vw-style-chips">
{styles.map((s) => (
<button key={s.id} type="button" className={`vw-chip${form.video_style === s.id ? " on" : ""}`}
aria-pressed={form.video_style === s.id} title={s.description ?? undefined}
onClick={() => set({ video_style: s.id })}>
{s.name}{s.kind === "custom" ? " (yours)" : ""}
</button>
))}
</div>
)}
{houseStyles && picked?.description && (
<small className="muted vw-style-note">{picked.description}</small>
)}
</div>
</div>
{/* The suggestions are asked for as soon as the source is ready (hidden if there are none); the custom
topic is always there */}
{sourceReady && (
<div className="field vw-focus">
<span className="vw-label">What should the video focus on?</span>
{shownTopics?.length !== 0 && (
<div className="vw-focus-grid" aria-busy={shownTopics === null}>
{shownTopics === null
? [0, 1, 2].map((i) => (
<div key={i} className="vw-focus-card wait" aria-hidden="true">
<SparkleIcon size={16} />
<span className="vw-focus-text">
<span className="vw-focus-bar" />
<span className="vw-focus-bar short" />
</span>
</div>
))
: shownTopics.map((t) => {
const on = focus?.title === t.title;
return (
<button key={t.title} type="button" className={`vw-focus-card${on ? " on" : ""}`}
aria-pressed={on} onClick={() => {
setFocus(on ? null : t);
setCustom("");
}}>
<SparkleIcon size={16} />
<span className="vw-focus-text">
<strong>{t.title}</strong>
{t.description && <small className="vw-focus-desc">{t.description}</small>}
</span>
{on && <CheckIcon size={16} />}
</button>
);
})}
</div>
)}
<label className={`vw-focus-custom${custom.trim() ? " on" : ""}`}>
<span className="vw-focus-custom-head">Custom topic</span>
<textarea rows={2} maxLength={500} value={custom}
placeholder="Describe your own, e.g. how rebalancing protects a long-term portfolio"
onFocus={() => setFocus(null)} // choosing the custom topic unpicks a suggested one
onChange={(e) => {
setCustom(e.target.value);
setFocus(null);
}} />
<span className="vw-focus-custom-foot">
<small className="muted">
Keep it to what the selected posts cover; anything they don't cover is left out of the video.
</small>
<small className="muted mono">{custom.length}/500</small>
</span>
</label>
</div>
)}
{error && <ErrorText message={error} />}
<div className="vw-nav">
{/* Waits for the focus suggestions (shown, or none to show), so they are never skipped past */}
<button className="btn btn-primary vw-next" disabled={!sourceReady || shownTopics === null}
onClick={() => setStep(2)}>
Go to step 2
</button>
</div>
</>
)}
{step === 2 && (
<>
<div>
{catalog === null ? (
<Loading label="Loading templates" />
) : shown.length === 0 ? (
<p className="muted">No templates available.</p>
) : (
<div className="vw-templates-scroll" ref={gridBox}>
<div className="vw-templates">
{shown.map((t) => (
<button key={t.id} type="button" data-id={t.id} className={`vw-template${form.template === t.id ? " on" : ""}`}
aria-pressed={form.template === t.id} onClick={() => pickTemplate(t)}>
<TemplateThumb template={t} portrait={form.aspect_ratio === "portrait"} />
{t.custom ? <span className="vw-badge mono">Yours</span>
: (t.badge || t.popular_template) && <span className="vw-badge mono">{t.badge ?? "Popular"}</span>}
<strong>{t.name}</strong>
{form.template === t.id && <span className="vw-template-check" aria-hidden="true"><CheckIcon size={14} /></span>}
</button>
))}
</div>
</div>
)}
</div>
<div className="vw-style-row">
<div className="field">
<span className="vw-label">Video colors</span>
<div className="vw-colors">
{([["accent_color", "Accent"], ["bg_color", "Background"], ["text_color", "Text"]] as const).map(([k, label]) => (
<label key={k} className="vw-color">
<input type="color" value={form[k] ?? "#000000"} onChange={(e) => set({ [k]: e.target.value })} aria-label={label} />
<span>{label}</span>
</label>
))}
</div>
</div>
</div>
<div className="vw-nav">
<button className="btn" onClick={() => setStep(1)}>Back</button>
<button className="btn btn-primary vw-next" onClick={() => setStep(3)}>
Go to step 3
</button>
</div>
</>
)}
{step === 3 && (
<>
<div className="field">
<span className="vw-label">Language</span>
{/* "" is Auto: the language is detected from the content */}
<Dropdown
label="Language"
value={form.content_language ?? ""}
options={[
{ value: "", label: "Auto", hint: "Detect from the content" },
...(catalog?.languages ?? []).map((l) => ({ value: l.code, label: l.name })),
]}
onChange={(v) => set({ content_language: v || null })}
/>
<small className="muted">Language of the video content</small>
</div>
<label className="vw-option">
<input type="checkbox" checked={form.voice_gender === "none"}
onChange={(e) => {
if (e.target.checked) return set({ voice_gender: "none" });
const v = currentVoice ?? saved[0];
if (v) pickVoice(v);
else set({ voice_gender: "female" });
}} />
No voiceover
</label>
<span className="vw-label">Voice: play to preview</span>
<div className={`stack${form.voice_gender === "none" ? " vw-dim" : ""}`}>
{voices === null ? (
<Loading label="Loading voices" />
) : saved.length === 0 ? (
<p className="muted">No voices yet. Add some in <Link to="/app/voices">Manage voices</Link>.</p>
) : (
<div className="vw-voice-list vw-voice-list-compact" role="radiogroup" aria-label="Narration voice">
{saved.map((v) => {
const on = form.custom_voice_id === v.voice_id && form.voice_gender !== "none";
return (
<div key={v.voice_id} className={`vw-vrow${on ? " on" : ""}`}>
<PlayButton on={playing === v.voice_id} disabled={v.source !== "notestack" && !v.preview_url}
label={v.name} onClick={() => playVoice(v)} />
<button type="button" role="radio" aria-checked={on} className="vw-vpick"
disabled={form.voice_gender === "none"} onClick={() => pickVoice(v)}>
<strong>{v.name}</strong>
<span className="muted">{[cap(v.gender), cap(v.accent)].filter(Boolean).join(" • ")}</span>
</button>
{on && <span className="vw-hero-check"><CheckIcon size={14} /></span>}
</div>
);
})}
</div>
)}
</div>
{error && <ErrorText message={error} />}
{left === 0 && (
<p className="notice">
You have used all your videos{quota?.resets_at ? " this period" : " on your plan"}.{" "}
<button type="button" className="link-btn" onClick={() => openUpgrade()}>See plans</button>
</p>
)}
<div className="vw-nav">
<button className="btn" onClick={() => setStep(2)} disabled={busy}>Back</button>
<button className="btn btn-primary vw-next" onClick={submit} disabled={busy}>
{busy ? "Launching..." : "Generate Video"}
</button>
</div>
</>
)}
</section>
</div>
);
}