blog2video-test / frontend /src /components /BlogUrlForm.tsx
Muhammad-Mehdi's picture
lite plan added
ac490ae
Raw
History Blame Contribute Delete
233 kB
import { useState, useRef, useEffect, useCallback, useMemo, type ReactNode } from "react";
import ReactDOM from "react-dom";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../hooks/useAuth";
import { isPaidPlan } from "../lib/plan";
import { useCraftedTemplates } from "../contexts/CraftedTemplatesContext";
import { useErrorModal } from "../contexts/ErrorModalContext";
import { BulkLinksSection } from "./BulkLinksSection";
import { classifyUrl, classifyUrlScrapability } from "../utils/urlScrapability";
import { getVoicePreviews, getMyVoices, getPrebuiltVoices, previewVoice, getBgmTracks, BACKEND_URL, type TemplateMeta, type CraftedTemplateItem, type VoicePreview, type BulkProjectItem, type CustomTemplateItem, type SavedVoiceFromAPI, type ElevenLabsVoice } from "../api/client";
import {
primeBlogUrlFormStep2Prefetch,
fetchBlogUrlFormBuiltinTemplatesDeduped,
fetchBlogUrlFormAvailabilityDeduped,
} from "../api/blogUrlFormStep2Prefetch";
import { VIDEO_STYLE_OPTIONS, normalizeVideoStyle, type VideoStyleId } from "../constants/videoStyles";
import { SUPPORTED_CONTENT_LANGUAGES, getLanguageOptionLabel } from "../constants/languages";
import UpgradePlanModal from "./UpgradePlanModal";
import { TEMPLATE_PREVIEWS, TEMPLATE_DESCRIPTIONS, NewTemplateBadge, PopularTemplateBadge, CustomTemplateBadge } from "./templatePreviewRegistry";
import CustomPreview from "./templatePreviews/CustomPreview";
import CustomPreviewLandscape from "./templatePreviews/CustomPreviewLandscape";
import CraftedTemplatePreviewSmart from "./templatePreviews/CraftedTemplatePreviewSmart";
import useIsMobileViewport from "../hooks/useIsMobileViewport";
import CraftYourTemplateCard from "./CraftYourTemplateCard";
import GetMoreTemplatesModal from "./GetMoreTemplatesModal";
import DesignerTemplateRequestModal from "./DesignerTemplateRequestModal";
import CraftYourVoiceCard from "./CraftYourVoiceCard";
import VoiceItem, { formatVoiceSubtitle, getMyVoiceDisplayName, subtitleForSavedVoice } from "./VoiceItem";
import AdvancedVoiceOptions from "./AdvancedVoiceOptions";
import {
VOICE_STABILITY_DEFAULT,
VOICE_STABILITY_MIN,
VOICE_STABILITY_MAX,
VOICE_SPEED_DEFAULT,
VOICE_SPEED_MIN,
VOICE_SPEED_MAX,
VOICE_TUNING_STEP,
VOICE_STYLE_DEFAULT,
VOICE_STYLE_MIN,
VOICE_STYLE_MAX,
VOICE_EMOTIONS,
parseVoiceTuning,
serializeVoiceTuning,
} from "./voiceTuning";
export const VIDEO_STYLES = VIDEO_STYLE_OPTIONS;
const DEFAULT_VIDEO_STYLE: VideoStyleId = "auto";
const CRAFTED_TEMPLATE_MENU_THUMBNAIL_FRAME = 128; // ~85% of 5s * 30fps first scene
/** Source-bucket sentinel values for the genre dropdown (not real template genres). */
const GENRE_CUSTOM = "__custom__";
export const GENRE_CRAFTED = "__crafted__";
const GENRE_NEW = "__new__";
const GENRE_POPULAR = "__popular__";
function genreTemplateListCaption(genreFilter: string): string {
if (genreFilter === GENRE_CUSTOM) return "Custom templates";
if (genreFilter === GENRE_CRAFTED) return "Designer templates";
if (genreFilter === GENRE_NEW) return "New templates";
if (genreFilter === GENRE_POPULAR) return "Popular templates";
if (genreFilter) return `Templates for ${genreFilter}`;
return "All templates";
}
function templateBucketsForGenre(
genreFilter: string,
builtinTemplates: TemplateMeta[],
readyCustomTemplates: CustomTemplateItem[],
readyCraftedTemplates: CraftedTemplateItem[]
): {
suggestedTemplates: TemplateMeta[];
customTemplatesForStyle: CustomTemplateItem[];
craftedTemplatesForStyle: CraftedTemplateItem[];
} {
const sourceList = builtinTemplates;
if (genreFilter === GENRE_CUSTOM) {
return {
suggestedTemplates: [],
customTemplatesForStyle: readyCustomTemplates,
craftedTemplatesForStyle: [],
};
}
if (genreFilter === GENRE_CRAFTED) {
return {
suggestedTemplates: [],
customTemplatesForStyle: [],
craftedTemplatesForStyle: readyCraftedTemplates,
};
}
if (genreFilter === GENRE_NEW) {
return {
suggestedTemplates: sourceList.filter((t) => t.new_template === true),
customTemplatesForStyle: [],
craftedTemplatesForStyle: [],
};
}
if (genreFilter === GENRE_POPULAR) {
return {
suggestedTemplates: sourceList.filter((t) => t.popular_template === true),
customTemplatesForStyle: [],
craftedTemplatesForStyle: [],
};
}
if (genreFilter) {
const matchesGenre = (g?: string[]): boolean => (g ?? []).includes(genreFilter);
return {
suggestedTemplates: sourceList.filter((t) => matchesGenre(t.genres)),
customTemplatesForStyle: [],
craftedTemplatesForStyle: [],
};
}
return {
suggestedTemplates: sourceList,
customTemplatesForStyle: readyCustomTemplates,
craftedTemplatesForStyle: readyCraftedTemplates,
};
}
/** First entry in template `genres` from meta.json; "" if missing. */
function defaultGenreForTemplate(meta: TemplateMeta | undefined | null): string {
const raw = meta?.genres?.[0];
return typeof raw === "string" && raw.trim() !== "" ? raw : "";
}
/** Genre aligned with a bulk row template id (built-in meta or custom genres). */
function defaultGenreForBulkTemplateId(
templateId: string,
builtinTemplates: TemplateMeta[],
customTemplatesList: CustomTemplateItem[]
): string {
if (templateId.startsWith("custom_")) {
const cid = parseInt(templateId.replace("custom_", ""), 10);
if (Number.isNaN(cid)) return "";
const ct = customTemplatesList.find((t) => t.id === cid);
return ct?.genres?.[0] ?? "";
}
return defaultGenreForTemplate(builtinTemplates.find((t) => t.id === templateId));
}
/**
* After the style/genre split: video_style no longer changes per template — it's an orthogonal
* user choice (Auto by default). These helpers exist only as no-op shims so existing call sites
* keep compiling; they always return the form-wide default style.
*/
function defaultVideoStyleForTemplate(_meta: TemplateMeta | undefined | null): VideoStyleId {
return DEFAULT_VIDEO_STYLE;
}
function videoStyleForBulkTemplateId(
_templateId: string,
_builtinTemplates: TemplateMeta[],
_customTemplatesList: CustomTemplateItem[]
): VideoStyleId {
return DEFAULT_VIDEO_STYLE;
}
/** Read-only demo mode used by help videos: forces step + seeds state without firing API calls. */
export interface BlogUrlFormDemoMode {
step: 1 | 2 | 3;
url?: string;
name?: string;
videoLength?: "short" | "medium" | "detailed";
template?: string;
videoStyle?: VideoStyleId;
voiceGender?: "female" | "male" | "none";
voiceAccent?: string;
customVoiceId?: string;
templatesData?: TemplateMeta[];
customTemplatesData?: CustomTemplateItem[];
myVoicesData?: SavedVoiceFromAPI[];
voicePreviewsData?: Record<string, VoicePreview>;
/** Replaces every built-in template preview with a static filler (used by help videos). */
templatePreviewOverride?: (opts: { templateId: string; selected: boolean; thumbnail: boolean }) => ReactNode;
}
interface Props {
onSubmit: (
url: string,
name?: string,
voiceGender?: string,
voiceAccent?: string,
accentColor?: string,
bgColor?: string,
textColor?: string,
animationInstructions?: string,
logoFile?: File,
logoPosition?: string,
logoOpacity?: number,
customVoiceId?: string,
aspectRatio?: string,
uploadFiles?: File[],
template?: string,
videoStyle?: VideoStyleId,
videoLength?: "short" | "medium" | "detailed" | "more_detailed",
contentLanguage?: string | null,
voiceEmotion?: string,
bgmTrackId?: string | null,
bgmVolume?: number
) => Promise<void>;
/** Bulk create: one call with array of configs; per-project logo via logoIndices + logoFiles. */
onSubmitBulk?: (items: BulkProjectItem[], logoOptions: { logoIndices: number[]; logoFiles: File[] } | null) => Promise<void>;
/**
* Extra creation options that don't fit `onSubmit`'s positional list (already
* 22 args). Read by the caller when building the create payload; the form
* pushes the current values here whenever they change.
*/
onExtraOptionsChange?: (opts: { stockFootageEnabled: boolean }) => void;
loading?: boolean;
asModal?: boolean;
onClose?: () => void;
/** Invoked before navigating to My Templates to craft a template (e.g. close the new-project modal). */
onDismissFlow?: () => void;
/** When set, the form renders in read-only demo mode for help videos. */
demoMode?: BlogUrlFormDemoMode;
/** Pre-select a genre filter when step 2 opens (e.g. GENRE_CRAFTED to show Designer Templates). */
initialGenre?: string;
}
const MAX_UPLOAD_FILES = 5;
const MAX_UPLOAD_SIZE = 5 * 1024 * 1024;
const viteEnv =
typeof import.meta !== "undefined" ? import.meta.env : undefined;
const processEnv =
typeof globalThis !== "undefined" && "process" in globalThis
? (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env
: undefined;
const MAX_BULK_LINKS = (() => {
const raw = (viteEnv?.VITE_MAX_BULK_LINKS || processEnv?.VITE_MAX_BULK_LINKS) as
| string
| undefined;
const parsed = raw ? parseInt(raw, 10) : NaN;
return Number.isFinite(parsed) && parsed > 0 ? parsed : 10;
})();
/** Estimated wall-clock range per tier (UI only; backend still uses short | medium | detailed). */
const VIDEO_LENGTH_DURATION_LABELS: Record<"short" | "medium" | "detailed" | "more_detailed", string> = {
short: "Short ~ 30 sec – 1 min",
medium: "Medium ~ 1 - 3 mins",
detailed: "Detailed ~ 3 – 8 mins",
more_detailed: "More Detailed ~ 8+ mins",
};
/**
* Minimum source-content word count each tier needs to actually reach that length.
* Mirrors the backend thresholds in pipeline.py (_effective_video_length_for_content):
* below these counts the video is automatically shortened to a smaller tier.
*/
const VIDEO_LENGTH_MIN_WORDS: Partial<Record<"short" | "medium" | "detailed" | "more_detailed", number>> = {
detailed: 1500,
more_detailed: 2000,
};
/**
* Long-form lengths reserved for paid plans; FREE users top out at "medium".
* Mirrors _PAID_ONLY_VIDEO_LENGTHS in backend/app/routers/projects.py, which
* rejects these with 403 `video_length_requires_paid` — this is the UI half of
* the same rule, not the enforcement.
*/
const PAID_ONLY_VIDEO_LENGTHS = new Set<string>(["detailed", "more_detailed"]);
const ALLOWED_EXTENSIONS = [".pdf", ".docx", ".pptx", ".md", ".markdown", ".txt", ".vtt"];
const ALLOWED_TYPES = [
"application/pdf",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"text/plain",
"text/markdown",
"text/x-markdown",
"text/vtt",
];
const VOICE_PREVIEW_KEYS = ["female_american", "female_british", "male_american", "male_british"];
const normalizeVoiceGender = (value?: string | null): "female" | "male" | null => {
const v = (value ?? "").trim().toLowerCase();
if (v === "female" || v === "male") return v;
return null;
};
const normalizeVoiceAccent = (value?: string | null): string | null => {
const v = (value ?? "").trim();
if (!v) return null;
return v.toLowerCase();
};
// Step indicator — order: 1 Content, 2 Template, 3 Voice
function StepIndicator({ current, total }: { current: number; total: number }) {
const stepLabels = ["Project", "Template", "Voice"];
return (
<div className="flex flex-col items-center gap-2 mb-6">
<div className="flex items-center gap-2">
{Array.from({ length: total }, (_, i) => i + 1).map((n) => (
<div key={n} className="flex items-center gap-2">
<div
className={`w-6 h-6 rounded-full text-[10px] font-semibold flex items-center justify-center transition-all ${
n === current
? "bg-purple-600 text-white"
: n < current
? "bg-purple-100 text-purple-600"
: "bg-gray-100 text-gray-400"
}`}
>
{n < current ? (
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
) : (
n
)}
</div>
{n < total && (
<div
className={`h-px w-8 transition-all ${
n < current ? "bg-purple-300" : "bg-gray-200"
}`}
/>
)}
</div>
))}
</div>
<span className="text-[11px] text-gray-400 font-medium">
Step {current} — {stepLabels[current - 1]}
</span>
</div>
);
}
// ─── Template video player lightbox
interface VideoLightboxProps {
templateId: string;
onClose: () => void;
onSelect: () => void;
isSelected: boolean;
customTemplate?: CustomTemplateItem | CraftedTemplateItem | null;
}
function TemplateVideoLightbox({ templateId, onClose, onSelect, isSelected, customTemplate }: VideoLightboxProps) {
const { ensureCraftedTemplateDetail } = useCraftedTemplates();
const PreviewComp = TEMPLATE_PREVIEWS[templateId];
const desc = TEMPLATE_DESCRIPTIONS[templateId];
const title = customTemplate ? customTemplate.name : (desc?.title ?? templateId);
const subtitle = customTemplate
? (templateId.startsWith("crafted_") ? "Designer template" : "Custom template")
: desc?.subtitle;
// Crafted templates ship summary-only by default; the full layout package
// (frontend_files, frontend_entry_rel, public_asset_urls) is fetched here
// on demand so the lightbox can render the real composition. The context
// dedupes concurrent calls and caches the result.
useEffect(() => {
if (templateId.startsWith("crafted_")) {
void ensureCraftedTemplateDetail(templateId);
}
}, [templateId, ensureCraftedTemplateDetail]);
// Close on Escape
useEffect(() => {
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [onClose]);
return ReactDOM.createPortal(
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4">
{/* Backdrop */}
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose} />
{/* Player container */}
<div className="relative w-full max-w-2xl">
{/* Screen bezel */}
<div className="rounded-2xl overflow-hidden shadow-[0_0_0_1px_rgba(255,255,255,0.08),0_32px_80px_rgba(0,0,0,0.7)] bg-[#0f0f0f]">
{/* Top bar */}
<div className="flex items-center justify-between px-4 py-2.5 bg-[#1a1a1a] border-b border-white/[0.06]">
<div className="flex items-center gap-2.5">
{/* macOS-style dots */}
<div className="w-2.5 h-2.5 rounded-full bg-[#FF5F57]" />
<div className="w-2.5 h-2.5 rounded-full bg-[#FEBC2E]" />
<div className="w-2.5 h-2.5 rounded-full bg-[#28C840]" />
</div>
<span className="text-[11px] text-white/40 font-medium tracking-wide">
{title} — Preview
</span>
<button onClick={onClose} className="text-white/30 hover:text-white/70 transition-colors">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Video content */}
<div className="relative aspect-video overflow-hidden bg-black">
{customTemplate && customTemplate.theme ? (
<CustomPreview theme={customTemplate.theme} name={customTemplate.name} previewImageUrl={customTemplate.preview_image_url ?? undefined} introCode={customTemplate.intro_code || undefined} outroCode={customTemplate.outro_code || undefined} contentCodes={customTemplate.content_codes || undefined} contentArchetypeIds={customTemplate.content_archetype_ids || undefined} validLayouts={(customTemplate as any).valid_layouts || undefined} frontendFiles={(customTemplate as any).frontend_files || undefined} frontendEntryRel={(customTemplate as any).frontend_entry_rel || undefined} publicAssetUrls={templateId.startsWith("crafted_") ? ((customTemplate as CraftedTemplateItem).public_asset_urls ?? undefined) : undefined} logoUrls={customTemplate.logo_urls ?? undefined} ogImage={customTemplate.og_image ?? undefined} showLoaderOnEmptyOrError={templateId.startsWith("crafted_")} />
) : PreviewComp ? (
<PreviewComp />
) : (
<div className="w-full aspect-video bg-gray-900 flex items-center justify-center text-gray-500 text-sm">
No preview available
</div>
)}
</div>
{/* Bottom controls bar */}
<div className="flex items-center justify-between px-4 py-3 bg-[#1a1a1a] border-t border-white/[0.06]">
<div className="text-[11px] text-white/40">
{subtitle}
</div>
<button
onClick={() => { onSelect(); onClose(); }}
className={`px-4 py-1.5 rounded-lg text-xs font-semibold transition-all ${
isSelected
? "bg-purple-500/20 text-purple-300 border border-purple-500/30"
: "bg-purple-600 text-white hover:bg-purple-700"
}`}
>
{isSelected ? "Selected" : "Use this template"}
</button>
</div>
</div>
</div>
</div>,
document.body
);
}
type BlogUrlFormDemoStep = 1 | 2 | 3;
/**
* @deprecated Use `<BlogUrlForm demoMode={...} />` directly. Kept temporarily for backwards compatibility.
*/
export function BlogUrlFormDemoModal({
step,
focus = "source",
}: {
step: BlogUrlFormDemoStep;
focus?: "new" | "source" | "template" | "voice" | "generate";
}) {
const selectedTemplateId = "spotlight";
const SelectedPreview = TEMPLATE_PREVIEWS[selectedTemplateId];
const selectedDesc = TEMPLATE_DESCRIPTIONS[selectedTemplateId];
const demoTemplates = ["spotlight", "nightfall", "gridcraft", "default"];
const inputClass =
"w-full px-4 py-2.5 bg-white/80 border border-gray-200/60 rounded-xl text-sm text-gray-900 placeholder-gray-300 focus:outline-none focus:ring-2 focus:ring-purple-500/40 focus:border-transparent transition-all";
const step1 = (
<div className="flex flex-col flex-1 min-h-0">
<div className="flex-1 flex flex-col space-y-5 min-h-0">
<div className="flex gap-1 p-1 bg-gray-100/60 rounded-xl w-fit">
{(["url", "upload", "bulk"] as const).map((mode) => (
<button
key={mode}
type="button"
className={`px-4 py-1.5 rounded-lg text-xs font-medium transition-all ${
mode === "url" ? "bg-white text-purple-600 shadow-sm" : "text-gray-400 hover:text-gray-600"
}`}
>
{mode === "url" ? "Link" : mode === "upload" ? "Upload" : "Multi Link"}
</button>
))}
</div>
<div className="space-y-1.5">
<label className="block text-[11px] font-medium text-gray-400 uppercase tracking-wider">
Blog / Article URL
</label>
<input
type="url"
readOnly
value="https://yourblog.com/how-ai-search-works"
className={`${inputClass} ${focus === "source" ? "ring-2 ring-purple-500/40 border-transparent" : ""}`}
/>
</div>
<div className="space-y-1.5">
<label className="block text-[11px] font-medium text-gray-400 uppercase tracking-wider">
Project name <span className="normal-case text-gray-300">(optional)</span>
</label>
<input type="text" readOnly value="AI Search Explainer" className={inputClass} />
</div>
<div className="space-y-1.5">
<label className="block text-[11px] font-medium text-gray-400 uppercase tracking-wider">
Estimated duration
</label>
<button
type="button"
className="flex w-full items-center justify-between rounded-xl border border-gray-200/60 bg-white/80 px-4 py-2.5 text-left text-sm text-gray-700"
>
<span>Medium ~ 1 - 3 mins</span>
<svg className="h-4 w-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
</div>
<div className="mt-5 flex gap-2 pt-1">
<button
type="button"
className="flex-1 py-3 bg-purple-600 hover:bg-purple-700 text-white text-sm font-medium rounded-xl transition-colors flex items-center justify-center gap-2"
>
Go to step 2
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
);
const step2 = (
<div className="space-y-5">
<div>
<label className="block text-[11px] font-medium text-gray-400 mb-2 uppercase tracking-wider">
Selected Template
</label>
<div className="rounded-xl overflow-hidden border-2 border-purple-500 shadow-[0_0_0_4px_rgba(124,58,237,0.1)]">
<div className="relative aspect-video overflow-hidden">{SelectedPreview ? <SelectedPreview /> : null}</div>
<div className="px-4 py-2.5 bg-purple-50/80 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-gray-800">{selectedDesc?.title ?? "Spotlight"}</span>
</div>
<div className="text-[11px] text-gray-400 mt-0.5">{selectedDesc?.subtitle}</div>
</div>
<div className="w-6 h-6 rounded-full bg-purple-600 flex items-center justify-center flex-shrink-0">
<svg className="w-3.5 h-3.5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
</div>
</div>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-[11px] font-medium text-gray-400 uppercase tracking-wider">
Video Style
</label>
<div className="flex flex-wrap items-center gap-1 p-1 bg-gray-100/60 rounded-xl justify-center">
{VIDEO_STYLES.map((style) => (
<button
key={style.id}
type="button"
className={`px-3 py-1.5 rounded-lg text-[11px] font-medium transition-all ${
style.id === "promotional" ? "bg-white text-purple-600 shadow-sm" : "text-gray-400 hover:text-gray-600"
}`}
>
{style.label}
</button>
))}
</div>
</div>
<p className="text-[10px] text-gray-500 mb-1.5 font-medium">
Suggested templates for the selected video style
</p>
<div className="border border-gray-200/60 rounded-xl p-2.5 max-h-[220px] overflow-y-auto bg-gray-50/40">
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
<CraftYourTemplateCard
variant="default"
isPro
onClick={() => undefined}
onKeyDown={() => undefined}
/>
{demoTemplates.map((templateId) => {
const Preview = TEMPLATE_PREVIEWS[templateId];
const desc = TEMPLATE_DESCRIPTIONS[templateId];
const selected = templateId === selectedTemplateId;
return (
<div
key={templateId}
className={`relative rounded-lg overflow-hidden cursor-pointer transition-all group ${
selected
? "border-2 border-purple-500 shadow-[0_0_0_3px_rgba(124,58,237,0.1)]"
: "border-2 border-gray-200/60 hover:border-purple-300/60"
}`}
>
<div className="relative h-[70px] overflow-hidden">
{Preview ? <Preview thumbnailMode /> : null}
{selected && (
<div className="absolute top-1.5 right-1.5 w-4 h-4 rounded-full bg-purple-600 flex items-center justify-center shadow-sm">
<svg className="w-2.5 h-2.5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
</div>
)}
</div>
<div className={`px-2 py-1 transition-colors ${selected ? "bg-purple-50/80" : "bg-white/80"}`}>
<div className="text-[10px] font-semibold text-gray-800 truncate">{desc?.title ?? templateId}</div>
</div>
</div>
);
})}
</div>
</div>
</div>
<div className="flex gap-2 pt-1">
<button type="button" className="px-5 py-3 text-sm font-medium text-gray-500 bg-gray-50 hover:bg-gray-100 rounded-xl transition-colors border border-gray-200/60">
Back
</button>
<button type="button" className="flex-1 py-3 bg-purple-600 hover:bg-purple-700 text-white text-sm font-medium rounded-xl transition-colors flex items-center justify-center gap-2">
Go to step 3
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
);
const voiceRows = [
{ name: "Rachel", subtitle: "Female - American - clear product narration", selected: true },
{ name: "Alice", subtitle: "Female - British - soft and polished", selected: false },
{ name: "Bill", subtitle: "Male - American - friendly and articulate", selected: false },
];
const step3 = (
<div className="space-y-5">
<div className="flex items-center gap-2 px-3 py-2.5 rounded-xl bg-purple-50/60 border border-purple-200/50">
<svg className="w-4 h-4 text-purple-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129" />
</svg>
<p className="text-[11px] text-purple-600 leading-relaxed">
Choose narration language. Keep <span className="font-semibold">Auto</span> to detect from content.
</p>
</div>
<div className="space-y-1.5">
<label className="block text-[11px] font-medium text-gray-400 uppercase tracking-wider">
Language
</label>
<button type="button" className="flex w-full items-center justify-between rounded-xl border border-gray-200/60 bg-white/80 px-4 py-2.5 text-left text-sm text-gray-700">
<span>Auto</span>
<svg className="h-4 w-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
<p className="text-[11px] text-gray-500">Language of the video content</p>
</div>
<label className="flex items-center gap-2.5 cursor-pointer select-none p-3 rounded-xl bg-gray-50/60 border border-gray-200/60 hover:border-gray-300/60 transition-all">
<input type="checkbox" readOnly checked={false} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500/30 cursor-pointer accent-purple-600" />
<div>
<p className="text-[11px] text-gray-400 mt-0.5">No voiceover</p>
</div>
</label>
<div>
<label className="block text-[11px] font-medium text-gray-400 mb-3 uppercase tracking-wider">
Voice - Select and Play to Preview
</label>
<div className="space-y-2 max-h-[320px] overflow-y-auto">
{voiceRows.map((voice) => (
<VoiceItem
key={voice.name}
name={voice.name}
subtitle={voice.subtitle}
hasPreview
isPlaying={focus === "voice" && voice.selected}
onPlay={() => undefined}
isSelected={voice.selected}
actions={
voice.selected ? (
<div className="w-5 h-5 rounded-full bg-purple-600 flex items-center justify-center flex-shrink-0">
<svg className="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
</div>
) : null
}
/>
))}
</div>
</div>
<div className="flex gap-2 pt-1">
<button type="button" className="px-5 py-3 text-sm font-medium text-gray-500 bg-gray-50 hover:bg-gray-100 rounded-xl transition-colors border border-gray-200/60">
Back
</button>
<button
type="button"
className={`flex-1 py-3 text-white text-sm font-medium rounded-xl transition-colors flex items-center justify-center gap-2 ${
focus === "generate" ? "bg-purple-700 ring-4 ring-purple-500/20" : "bg-purple-600 hover:bg-purple-700"
}`}
>
Generate Video
</button>
</div>
</div>
);
const content = step === 1 ? step1 : step === 2 ? step2 : step3;
return (
<div className="relative w-full max-w-xl bg-white/90 backdrop-blur-xl border border-gray-200/40 rounded-2xl shadow-[0_8px_40px_rgba(0,0,0,0.08)] p-7 max-h-[85vh] overflow-y-auto">
<div className="flex items-center justify-between mb-6">
<h2 className="text-base font-semibold text-gray-900">New Project</h2>
<button className="text-gray-300 hover:text-gray-500 transition-colors">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<StepIndicator current={step} total={3} />
<div className="relative min-h-[420px] flex flex-col">
<div className="min-h-[420px] flex flex-col">{content}</div>
</div>
</div>
);
}
function deriveNameFromUrl(url: string): string {
try {
const parsed = new URL(url);
const segments = parsed.pathname.replace(/\/$/, "").split("/");
const path = segments[segments.length - 1] || parsed.hostname;
return path.replace(/[-_]/g, " ").replace(/\b\w/g, (c: string) => c.toUpperCase()).slice(0, 100) || "Untitled Project";
} catch {
return "Untitled Project";
}
}
/** Returns true if the trimmed string has any whitespace in the middle (not just at start/end). */
function hasSpacesInMiddle(s: string): boolean {
const t = s.trim();
return t.length > 0 && /\s/.test(t);
}
/** Treat as a link only if it contains a dot (e.g. example.com). Rejects single words or plain sentences. */
function containsDot(s: string): boolean {
return s.trim().includes(".");
}
const FILE_EXTENSIONS = [".pdf", ".doc", ".docx", ".ppt", ".pptx", ".xls", ".xlsx", ".odt", ".odp", ".ods", ".txt", ".md", ".markdown", ".rtf", ".csv"];
/** Returns the matched file extension if the URL ends with a document extension, else null. */
function getFileExtension(s: string): string | null {
const trimmed = s.trim().toLowerCase();
// Check the raw input first (covers "hello.pdf", "example.com/file.pdf", etc.)
const directMatch = FILE_EXTENSIONS.find((ext) => trimmed.endsWith(ext));
if (directMatch) return directMatch;
// Also check the pathname of a parsed URL (handles query strings, e.g. site.com/doc.pdf?v=1)
try {
const url = new URL(trimmed.startsWith("http") ? trimmed : `https://${trimmed}`);
const path = url.pathname.toLowerCase();
return FILE_EXTENSIONS.find((ext) => path.endsWith(ext)) ?? null;
} catch {
return null;
}
}
export default function BlogUrlForm({ onSubmit, onSubmitBulk, onExtraOptionsChange, loading, asModal, onClose, onDismissFlow, demoMode, initialGenre }: Props) {
const { user } = useAuth();
const { showError } = useErrorModal();
const navigate = useNavigate();
const isPro = demoMode ? true : isPaidPlan(user?.plan);
const isDemo = !!demoMode;
// On mobile, crafted/custom grid tiles render a static themed placeholder
// instead of a live Remotion Player; only the selected tile plays live. A grid
// of live Players exhausts iOS Safari's per-tab memory and reloads the tab.
const isMobile = useIsMobileViewport();
// Wizard step
const [step, setStep] = useState<1 | 2 | 3>(demoMode?.step ?? 1);
useEffect(() => {
if (demoMode?.step) setStep(demoMode.step);
}, [demoMode?.step]);
// Step 1 — input
const [mode, setMode] = useState<"url" | "upload" | "bulk">("url");
const [urls, setUrls] = useState<string[]>([""]);
const [name, setName] = useState("");
const [docFiles, setDocFiles] = useState<File[]>([]);
const [docError, setDocError] = useState<string | null>(null);
const docInputRef = useRef<HTMLInputElement>(null);
/** URL validation error for single-link mode (shown only after blur). */
const [urlError, setUrlError] = useState<string | null>(null);
// Bulk: rows (url); per-row name, template, voice, format, logo
const [bulkRows, setBulkRows] = useState<{ url: string }[]>([{ url: "" }]);
const [bulkNames, setBulkNames] = useState<string[]>([""]);
const [bulkTemplates, setBulkTemplates] = useState<string[]>(["default"]);
const bulkTemplatesRef = useRef<string[]>(["default"]);
bulkTemplatesRef.current = bulkTemplates;
const [bulkVoiceGender, setBulkVoiceGender] = useState<("female" | "male" | "none")[]>(["female"]);
const [bulkVoiceAccent, setBulkVoiceAccent] = useState<string[]>(["american"]);
const [bulkVoiceStability, setBulkVoiceStability] = useState<number[]>(() => [parseVoiceTuning(user?.preferred_voice_emotion)[0]]);
const [bulkVoiceSpeed, setBulkVoiceSpeed] = useState<number[]>(() => [parseVoiceTuning(user?.preferred_voice_emotion)[1]]);
const [bulkVoiceEmotion, setBulkVoiceEmotion] = useState<string[]>(() => [parseVoiceTuning(user?.preferred_voice_emotion)[2]]);
const [bulkVoiceStyle, setBulkVoiceStyle] = useState<number[]>(() => [parseVoiceTuning(user?.preferred_voice_emotion)[3]]);
const [bulkCustomVoiceId, setBulkCustomVoiceId] = useState<string[]>([]);
const [bulkContentLanguage, setBulkContentLanguage] = useState<string[]>(["auto"]);
const [bulkVideoLength, setBulkVideoLength] = useState<("short" | "medium" | "detailed" | "more_detailed")[]>(["short"]);
const [bulkAspectRatio, setBulkAspectRatio] = useState<("landscape" | "portrait")[]>(["landscape"]);
const [bulkVideoStyles, setBulkVideoStyles] = useState<VideoStyleId[]>([DEFAULT_VIDEO_STYLE]);
const bulkStyleManuallySet = useRef<boolean[]>([false]);
// Empty string = "not yet set from template"; we derive from template.preview_colors on step 2.
const [bulkAccentColors, setBulkAccentColors] = useState<string[]>([""]);
const [bulkBgColors, setBulkBgColors] = useState<string[]>([""]);
const [bulkTextColors, setBulkTextColors] = useState<string[]>([""]);
const [bulkActiveIndex, setBulkActiveIndex] = useState(0);
const [bulkLogoFile, setBulkLogoFile] = useState<(File | null)[]>([null]);
const [bulkLogoPosition, setBulkLogoPosition] = useState<string[]>(["bottom_right"]);
const [bulkLogoOpacity, setBulkLogoOpacity] = useState<number[]>([0.9]);
const [bulkLogoRowIndex, setBulkLogoRowIndex] = useState<number | null>(null);
const bulkLogoInputRef = useRef<HTMLInputElement>(null);
const [bulkApplyLengthAll, setBulkApplyLengthAll] = useState(true);
const [bulkLengthMasterIndex, setBulkLengthMasterIndex] = useState(0);
const [bulkStockFootage, setBulkStockFootage] = useState<boolean[]>([true]);
const [bulkApplyStockAll, setBulkApplyStockAll] = useState(true);
const [bulkStockMasterIndex, setBulkStockMasterIndex] = useState(0);
const [bulkApplyTemplateAll, setBulkApplyTemplateAll] = useState(true);
const [bulkTemplateMasterIndex, setBulkTemplateMasterIndex] = useState(0);
const [bulkApplyVoiceAll, setBulkApplyVoiceAll] = useState(true);
const [bulkVoiceMasterIndex, setBulkVoiceMasterIndex] = useState(0);
// Step 2 — voice
const [voiceGender, setVoiceGender] = useState<"female" | "male" | "none">("female");
const [voiceAccent, setVoiceAccent] = useState<string>("american");
// Voice tuning sliders (paid). Initialized from the remembered per-user preference so a returning
// user sees their last settings pre-selected.
const [voiceStability, setVoiceStability] = useState<number>(() => parseVoiceTuning(user?.preferred_voice_emotion)[0]);
const [voiceSpeed, setVoiceSpeed] = useState<number>(() => parseVoiceTuning(user?.preferred_voice_emotion)[1]);
const [voiceEmotion, setVoiceEmotion] = useState<string>(() => parseVoiceTuning(user?.preferred_voice_emotion)[2]);
const [voiceStyle, setVoiceStyle] = useState<number>(() => parseVoiceTuning(user?.preferred_voice_emotion)[3]);
// Live voice-preview playback state (Advanced Options "Listen").
const [previewState, setPreviewState] = useState<"idle" | "loading" | "playing">("idle");
const previewAudioRef = useRef<HTMLAudioElement | null>(null);
const previewUrlRef = useRef<string | null>(null);
const stopVoicePreview = useCallback(() => {
previewAudioRef.current?.pause();
previewAudioRef.current = null;
if (previewUrlRef.current) {
URL.revokeObjectURL(previewUrlRef.current);
previewUrlRef.current = null;
}
setPreviewState("idle");
}, []);
// Synthesize + play a short sample with the given voice + tuning. Toggling while playing stops it.
const handleVoicePreview = useCallback(
async (args: { gender: string; accent: string; customVoiceId: string; stability: number; speed: number; emotion: string; style: number }) => {
if (previewState === "playing") {
stopVoicePreview();
return;
}
if (previewState === "loading") return;
setPreviewState("loading");
try {
const url = await previewVoice({
voice_gender: args.gender,
voice_accent: args.accent,
custom_voice_id: args.customVoiceId || undefined,
voice_emotion: serializeVoiceTuning(args.stability, args.speed, args.emotion, args.style, true),
});
if (previewUrlRef.current) URL.revokeObjectURL(previewUrlRef.current);
previewUrlRef.current = url;
const audio = new Audio(url);
audio.addEventListener("ended", stopVoicePreview);
previewAudioRef.current = audio;
await audio.play();
setPreviewState("playing");
} catch (err: any) {
setPreviewState("idle");
const status = err?.response?.status;
if (status === 429) showError("Please wait a moment before previewing again.");
else if (status === 403) showError("Voice preview requires a Pro or Standard plan.");
else showError("Couldn't generate a voice preview. Please try again.");
}
},
[previewState, stopVoicePreview, showError]
);
// Stop any preview playback when the component unmounts.
useEffect(() => () => stopVoicePreview(), [stopVoicePreview]);
// Whether the "Advanced Options" tab (voice tuning sliders) is expanded. Paid-only.
const [showAdvancedOptions, setShowAdvancedOptions] = useState<boolean>(false);
// Active tab in the Step 3 voice/music panel: voice | bgm | advanced (paid).
const [voicePanelTab, setVoicePanelTab] = useState<"voice" | "bgm" | "advanced">("voice");
// Master switch for the expressive v3 path. Off → standard v2 voice. Only when ON is the voice
// tuning sent (which is what routes the project through eleven_v3 + [excited] + emotive narration).
// Defaults ON for users who previously enabled it (a saved preference exists) — it stays enabled
// across sessions until they explicitly turn it off on a voiced video.
const [expressiveEnabled, setExpressiveEnabled] = useState<boolean>(() => parseVoiceTuning(user?.preferred_voice_emotion)[4]);
const [contentLanguage, setContentLanguage] = useState<string>("auto");
const [videoLength, setVideoLength] = useState<"short" | "medium" | "detailed" | "more_detailed">("short");
const [customVoiceId, setCustomVoiceId] = useState("");
const [voicePreviews, setVoicePreviews] = useState<Record<string, VoicePreview>>({});
const [myVoicesList, setMyVoicesList] = useState<SavedVoiceFromAPI[]>([]);
const [myVoicesLoading, setMyVoicesLoading] = useState(true);
const [premiumTeaserVoices, setPremiumTeaserVoices] = useState<ElevenLabsVoice[]>([]);
const audioRef = useRef<HTMLAudioElement | null>(null);
const preloadedAudioRef = useRef<Record<string, HTMLAudioElement>>({});
const [playingKey, setPlayingKey] = useState<string | null>(null);
const voiceGenderRef = useRef(voiceGender);
const customVoiceIdRef = useRef(customVoiceId);
voiceGenderRef.current = voiceGender;
customVoiceIdRef.current = customVoiceId;
// Background music
const [bgmTracks, setBgmTracks] = useState<import("../api/client").BgmTrack[]>([]);
const [selectedBgmTrackId, setSelectedBgmTrackId] = useState<string | null>(null);
const [selectedBgmVolume, setSelectedBgmVolume] = useState<number>(0.10);
const [bgmPlayingId, setBgmPlayingId] = useState<string | null>(null);
const bgmAudioRef = useRef<HTMLAudioElement | null>(null);
// Step 2 — video style & template
const [videoStyle, setVideoStyle] = useState<VideoStyleId>(DEFAULT_VIDEO_STYLE);
const styleManuallySet = useRef(false);
/** Genre dropdown filter — populated dynamically from all templates' meta.genres. "" = show all. */
const [genre, setGenre] = useState<string>(initialGenre ?? "");
const [genreDropdownOpen, setGenreDropdownOpen] = useState(false);
const [template, setTemplate] = useState("default");
// Stock footage at generation time: available on every plan and every
// template (builtin, custom, crafted). Free users get a clip on a single
// scene (the backend caps it), paid users on all image-capable scenes.
const [stockFootageEnabled, setStockFootageEnabled] = useState(true);
const stockFootageAvailable = true;
useEffect(() => {
onExtraOptionsChange?.({ stockFootageEnabled: stockFootageAvailable && stockFootageEnabled });
}, [onExtraOptionsChange, stockFootageAvailable, stockFootageEnabled]);
const [templates, setTemplates] = useState<TemplateMeta[]>([]);
const { craftedTemplates, loading: craftedTemplatesCacheLoading, initialized: craftedTemplatesInitialized, ensureCraftedTemplateDetail } = useCraftedTemplates();
// When a crafted template is selected, fetch its full bundle (frontend_files,
// entry, public assets) on demand so its picker card can render the REAL
// composition via the module graph (falls back to the marquee until ready).
useEffect(() => {
if (template.startsWith("crafted_")) {
void ensureCraftedTemplateDetail(template);
}
}, [template, ensureCraftedTemplateDetail]);
/** Built-in template list fetch — drives step 2 loading overlay (often warmed by Dashboard prefetch). */
const [builtinTemplatesLoading, setBuiltinTemplatesLoading] = useState(true);
/** After built-ins load: session random pick (or skip) has finished — step 2 can interact. */
const [sessionBuiltinInitDone, setSessionBuiltinInitDone] = useState(false);
/** Random built-in default for new rows / initial pick; stable until templates reload. */
const [pickerDefaultTemplateId, setPickerDefaultTemplateId] = useState<string>("default");
const templateManuallySelectedRef = useRef(false);
const [aspectRatio, setAspectRatio] = useState<"landscape" | "portrait">("landscape");
const [accentColor, setAccentColor] = useState("#7C3AED");
const [bgColor, setBgColor] = useState("#FFFFFF");
const [textColor, setTextColor] = useState("#000000");
const [logoFile, setLogoFile] = useState<File | null>(null);
const [logoPosition, setLogoPosition] = useState("bottom_right");
const [logoOpacity, setLogoOpacity] = useState(0.9);
const logoInputRef = useRef<HTMLInputElement>(null);
const [showUpgrade, setShowUpgrade] = useState(false);
const [videoPreviewId, setVideoPreviewId] = useState<string | null>(null);
const submitButtonRef = useRef<HTMLButtonElement>(null);
/** When we navigated to step 3 (ms). Used to ignore submit from replayed click after "Go to step 3". */
const step3EnteredAtRef = useRef<number | null>(null);
// Load all templates once (filtering by style is done in UI)
const [customTemplates, setCustomTemplates] = useState<CustomTemplateItem[]>([]);
const templatesRef = useRef<TemplateMeta[]>(templates);
const customTemplatesRef = useRef<CustomTemplateItem[]>(customTemplates);
const pickerDefaultTemplateIdRef = useRef(pickerDefaultTemplateId);
templatesRef.current = templates;
customTemplatesRef.current = customTemplates;
pickerDefaultTemplateIdRef.current = pickerDefaultTemplateId;
// Only show templates that have finished generating (intro_code exists)
const readyCustomTemplates = customTemplates.filter((ct) => !!ct.intro_code);
const readyCraftedTemplates = craftedTemplates.filter((ct) => !!ct.theme);
const [showCustomTemplateUpgrade, setShowCustomTemplateUpgrade] = useState(false);
const [showGetMoreTemplates, setShowGetMoreTemplates] = useState(false);
const [showDesignerRequest, setShowDesignerRequest] = useState(false);
const [customTemplatesLoading, setCustomTemplatesLoading] = useState(false);
const [hasCraftedTemplatesEligible, setHasCraftedTemplatesEligible] = useState(false);
// Show the crafted-template loader until the first R2 roundtrip resolves
// (not just during the non-silent fetch window), so eligible users never
// see the "no templates" state flash while we silently revalidate the
// localStorage cache against R2.
const craftedTemplatesLoading =
hasCraftedTemplatesEligible && (craftedTemplatesCacheLoading || !craftedTemplatesInitialized);
const [templateAvailabilityLoading, setTemplateAvailabilityLoading] = useState(true);
const allTemplates = useMemo<TemplateMeta[]>(() => {
const byId = new Map<string, TemplateMeta>();
for (const t of templates) byId.set(t.id, t);
for (const ct of craftedTemplates) {
if (!byId.has(ct.id)) byId.set(ct.id, ct as unknown as TemplateMeta);
}
return Array.from(byId.values());
}, [templates, craftedTemplates]);
const renderLanguageDropdown = (
value: string,
onSelect: (next: string) => void
) => (
<details className="relative group">
<summary className="list-none w-full px-3 py-2.5 rounded-xl bg-white border border-gray-200 text-sm text-gray-700 cursor-pointer focus:outline-none focus:ring-2 focus:ring-purple-500/30 focus:border-purple-400 flex items-center justify-between">
<span>{getLanguageOptionLabel(value)}</span>
<svg
className="w-4 h-4 text-gray-400 transition-transform group-open:rotate-180"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</summary>
<div className="absolute z-20 mt-1 w-full rounded-xl border border-gray-200 bg-white shadow-lg overflow-hidden">
{/* ~12 visible rows; rest scrollable */}
<div className="max-h-[18.5rem] overflow-y-auto py-1">
<button
type="button"
onClick={(e) => {
onSelect("auto");
const details = (e.currentTarget.closest("details") as HTMLDetailsElement | null);
details?.removeAttribute("open");
}}
className={`w-full text-left px-3 py-2 text-sm hover:bg-purple-50 ${
value === "auto" ? "bg-purple-50 text-purple-700" : "text-gray-700"
}`}
>
Auto
</button>
{SUPPORTED_CONTENT_LANGUAGES.map((lang) => (
<button
key={lang.code}
type="button"
onClick={(e) => {
onSelect(lang.code);
const details = (e.currentTarget.closest("details") as HTMLDetailsElement | null);
details?.removeAttribute("open");
}}
className={`w-full text-left px-3 py-2 text-sm hover:bg-purple-50 ${
value === lang.code ? "bg-purple-50 text-purple-700" : "text-gray-700"
}`}
>
{lang.code} - {lang.name}
</button>
))}
</div>
</div>
</details>
);
const renderVideoLengthDropdown = (
value: "short" | "medium" | "detailed" | "more_detailed",
onSelect: (next: "short" | "medium" | "detailed" | "more_detailed") => void
) => {
const minWords = VIDEO_LENGTH_MIN_WORDS[value];
return (
<>
<details className="relative group">
<summary className="list-none w-full px-3 py-2.5 rounded-xl bg-white border border-gray-200 text-sm text-gray-700 cursor-pointer focus:outline-none focus:ring-2 focus:ring-purple-500/30 focus:border-purple-400 flex items-center justify-between">
<span>{VIDEO_LENGTH_DURATION_LABELS[value]}</span>
<svg
className="w-4 h-4 text-gray-400 transition-transform group-open:rotate-180"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</summary>
<div className="absolute z-20 mt-1 w-full rounded-xl border border-gray-200 bg-white shadow-lg overflow-hidden">
<div className="max-h-[18.5rem] overflow-y-auto py-1">
{(["short", "medium", "detailed", "more_detailed"] as const).map((opt) => {
// Long-form lengths require a paid plan — the backend rejects them
// with 403 video_length_requires_paid, so gate them here too rather
// than letting a free user pick an option that will fail on submit.
const locked = !isPro && PAID_ONLY_VIDEO_LENGTHS.has(opt);
return (
<button
key={opt}
type="button"
disabled={locked}
title={locked ? "Requires a paid plan (Lite, Standard, or Pro)" : undefined}
onClick={(e) => {
if (locked) return;
onSelect(opt);
const details = (e.currentTarget.closest("details") as HTMLDetailsElement | null);
details?.removeAttribute("open");
}}
className={`w-full text-left px-3 py-2 text-sm flex items-center justify-between gap-2 ${
locked
? "text-gray-400 cursor-not-allowed"
: `hover:bg-purple-50 ${value === opt ? "bg-purple-50 text-purple-700" : "text-gray-700"}`
}`}
>
<span>{VIDEO_LENGTH_DURATION_LABELS[opt]}</span>
{locked && (
<span className="shrink-0 px-1.5 py-0.5 rounded bg-purple-50 text-purple-600 text-[10px] font-medium">
Paid only
</span>
)}
</button>
);
})}
</div>
</div>
</details>
{minWords && (
<p className="mt-1.5 flex items-start gap-1 text-[11px] text-red-600 leading-relaxed">
<svg className="w-3.5 h-3.5 flex-shrink-0 mt-px" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
</svg>
<span>
Make sure your content is at least ~{minWords.toLocaleString()} words for this length,
otherwise the video will be automatically shortened.
</span>
</p>
)}
</>
);
};
// Load templates, voice previews, and user's saved voices once
useEffect(() => {
let mounted = true;
if (isDemo) {
if (demoMode?.templatesData) setTemplates(demoMode.templatesData);
if (demoMode?.customTemplatesData) setCustomTemplates(demoMode.customTemplatesData);
if (demoMode?.myVoicesData) setMyVoicesList(demoMode.myVoicesData);
if (demoMode?.voicePreviewsData) setVoicePreviews(demoMode.voicePreviewsData);
if (demoMode?.url) setUrls([demoMode.url]);
if (demoMode?.name) setName(demoMode.name);
if (demoMode?.videoLength) setVideoLength(demoMode.videoLength);
if (demoMode?.template) {
setTemplate(demoMode.template);
templateManuallySelectedRef.current = true;
}
if (demoMode?.videoStyle) {
setVideoStyle(demoMode.videoStyle);
styleManuallySet.current = true;
}
if (demoMode?.voiceGender) setVoiceGender(demoMode.voiceGender);
if (demoMode?.voiceAccent) setVoiceAccent(demoMode.voiceAccent);
if (demoMode?.customVoiceId) setCustomVoiceId(demoMode.customVoiceId);
setBuiltinTemplatesLoading(false);
setSessionBuiltinInitDone(true);
setMyVoicesLoading(false);
setCustomTemplatesLoading(false);
setHasCraftedTemplatesEligible(false);
setTemplateAvailabilityLoading(false);
sessionRandomAppliedRef.current = true;
return () => {
mounted = false;
};
}
primeBlogUrlFormStep2Prefetch();
fetchBlogUrlFormBuiltinTemplatesDeduped()
.then((data) => {
if (mounted) {
setTemplates(data);
setBuiltinTemplatesLoading(false);
}
})
.catch(() => {
if (mounted) setBuiltinTemplatesLoading(false);
});
fetchBlogUrlFormAvailabilityDeduped()
.then(({ hasCraftedTemplatesEligible: eligible, customTemplates }) => {
if (!mounted) return;
setHasCraftedTemplatesEligible(eligible);
setCustomTemplates(customTemplates);
setCustomTemplatesLoading(false);
setTemplateAvailabilityLoading(false);
})
.catch(() => {
if (!mounted) return;
setHasCraftedTemplatesEligible(true);
setCustomTemplates([]);
setCustomTemplatesLoading(false);
setTemplateAvailabilityLoading(false);
});
getVoicePreviews()
.then((r) => {
if (mounted) setVoicePreviews(r.data);
})
.catch(() => {});
getBgmTracks()
.then((r) => {
if (mounted) setBgmTracks(r.data);
})
.catch(() => {});
setMyVoicesLoading(true);
getMyVoices()
.then((r) => {
if (!mounted) return;
const list = r.data ?? [];
setMyVoicesList(list);
if (list.length > 0) {
const first = list[0];
const firstId = first.voice_id;
// Single/link flow: default-select the first voice in Step 3 (read latest prefs via refs)
if (!customVoiceIdRef.current && voiceGenderRef.current !== "none") {
setCustomVoiceId(firstId);
const g = normalizeVoiceGender(first.gender);
const a = normalizeVoiceAccent(first.accent);
if (g) setVoiceGender(g);
if (a) setVoiceAccent(a);
}
}
})
.catch(() => {
if (mounted) setMyVoicesList([]);
})
.finally(() => {
if (mounted) setMyVoicesLoading(false);
});
getPrebuiltVoices()
.then((r: { data?: { voices?: ElevenLabsVoice[] } }) => {
if (!mounted) return;
const voices = r.data?.voices ?? [];
const paid = voices.filter((v) => v.plan === "paid");
setPremiumTeaserVoices(paid.slice(0, 2));
})
.catch(() => {});
return () => {
mounted = false;
};
}, []);
// Keep bulk rows in sync: whenever we have saved voices and bulk URLs,
// ensure each populated row gets a default custom voice if it doesn't have one.
useEffect(() => {
if (!myVoicesList.length) return;
const firstId = myVoicesList[0].voice_id;
setBulkCustomVoiceId((prev) => {
const next = [...prev];
let changed = false;
bulkRows.forEach((row, idx) => {
if (row.url.trim() && !next[idx]) {
next[idx] = firstId;
changed = true;
}
});
return changed ? next : prev;
});
}, [myVoicesList, bulkRows]);
// Sync colors to the selected template when templates load or selection changes.
// Runs before session random pick on the same commit so random pick can override starter "default" colors.
useEffect(() => {
if (allTemplates.length === 0) return;
// Check custom templates first
if (template.startsWith("custom_")) {
const customId = parseInt(template.replace("custom_", ""));
const ct = customTemplates.find((t) => t.id === customId);
if (ct) {
setAccentColor(ct.preview_colors.accent);
setBgColor(ct.preview_colors.bg);
setTextColor(ct.preview_colors.text);
}
return;
}
const meta = allTemplates.find((t) => t.id === template);
if (meta?.preview_colors) {
setAccentColor(meta.preview_colors.accent);
setBgColor(meta.preview_colors.bg);
setTextColor(meta.preview_colors.text);
}
}, [allTemplates, customTemplates, template]);
// Built-ins loaded but empty (error / no data): unblock step 2 without random pick.
useEffect(() => {
if (builtinTemplatesLoading) return;
if (templates.length === 0) {
setSessionBuiltinInitDone(true);
}
}, [builtinTemplatesLoading, templates.length]);
// Once per form mount: pick a random built-in template for this session (single + all bulk rows).
const sessionRandomAppliedRef = useRef(false);
useEffect(() => {
if (templates.length === 0) return;
if (sessionRandomAppliedRef.current) {
setSessionBuiltinInitDone(true);
return;
}
const idx = Math.floor(Math.random() * templates.length);
const picked = templates[idx];
if (!picked?.id) {
setSessionBuiltinInitDone(true);
return;
}
sessionRandomAppliedRef.current = true;
setPickerDefaultTemplateId(picked.id);
if (templateManuallySelectedRef.current) {
setSessionBuiltinInitDone(true);
return;
}
// Genre filter stays at "All genres" by default; users narrow the list explicitly.
if (picked.preview_colors) {
setAccentColor(picked.preview_colors.accent);
setBgColor(picked.preview_colors.bg);
setTextColor(picked.preview_colors.text);
}
setTemplate((prev) =>
prev === "default" ? picked.id : prev
);
setBulkTemplates((prev) =>
prev.length > 0
? prev.map((tpl) => (tpl === "default" ? picked.id : tpl))
: [picked.id]
);
setSessionBuiltinInitDone(true);
}, [templates]);
// Preload voice preview audio on mount so it's ready by step 3
useEffect(() => {
if (isDemo) return;
const base = BACKEND_URL ? `${BACKEND_URL}/api` : "/api";
for (const key of VOICE_PREVIEW_KEYS) {
if (preloadedAudioRef.current[key]) continue;
const url = `${base}/voices/preview-audio?key=${encodeURIComponent(key)}`;
const a = new Audio();
a.preload = "auto";
a.src = url;
preloadedAudioRef.current[key] = a;
}
}, [isDemo]);
// Cleanup audio only on unmount; do not clear preloadedAudioRef so step 3 can use it
useEffect(() => {
return () => {
audioRef.current?.pause();
for (const key of VOICE_PREVIEW_KEYS) {
const a = preloadedAudioRef.current[key];
if (a) {
a.pause();
a.removeAttribute("src");
a.load();
}
}
preloadedAudioRef.current = {};
};
}, []);
// ─── Audio preview ───────────────────────────────────────────
const playVoice = (key: string, url: string | null) => {
if (!url) return;
if (playingKey === key) {
audioRef.current?.pause();
setPlayingKey(null);
return;
}
audioRef.current?.pause();
// Always use preloaded instance when present so we don't refetch at step 3
let audio = preloadedAudioRef.current[key];
if (!audio) {
audio = new Audio(url);
audio.preload = "auto";
preloadedAudioRef.current[key] = audio;
}
audio.currentTime = 0;
audio.onended = () => setPlayingKey(null);
audio.onerror = () => setPlayingKey(null);
audio.play().catch(() => setPlayingKey(null));
audioRef.current = audio;
setPlayingKey(key);
};
const playMyVoice = (saved: { voice_id: string; preview_url?: string | null }) => {
const key = `my_${saved.voice_id}`;
if (playingKey === key) {
audioRef.current?.pause();
setPlayingKey(null);
return;
}
const src = saved.preview_url;
if (!src) return;
audioRef.current?.pause();
const audio = new Audio(src);
audio.onended = () => setPlayingKey(null);
audio.onerror = () => setPlayingKey(null);
audio.play().catch(() => setPlayingKey(null));
audioRef.current = audio;
setPlayingKey(key);
};
const playPremiumTeaser = (voice: ElevenLabsVoice) => {
const key = `premium_${voice.voice_id}`;
if (playingKey === key) {
audioRef.current?.pause();
setPlayingKey(null);
return;
}
if (!voice.preview_url) return;
audioRef.current?.pause();
const audio = new Audio(voice.preview_url);
audio.onended = () => setPlayingKey(null);
audio.onerror = () => setPlayingKey(null);
audio.play().catch(() => setPlayingKey(null));
audioRef.current = audio;
setPlayingKey(key);
};
// ─── File helpers ────────────────────────────────────────────
const isAllowedFile = (file: File) => {
// 1) Extension check first — file.type can be empty / unreliable for .vtt
// on many browsers (Chrome reports "" or "application/octet-stream").
const lowerName = (file.name || "").toLowerCase();
const dotIdx = lowerName.lastIndexOf(".");
if (dotIdx !== -1) {
const ext = lowerName.slice(dotIdx); // includes the leading "."
if (ALLOWED_EXTENSIONS.includes(ext)) return true;
}
// 2) Fallback to MIME type.
return ALLOWED_TYPES.includes(file.type);
};
/** Add one or more files using functional state so paste / drop handlers never see stale `docFiles`. */
const addDocFileArray = useCallback((incoming: File[]) => {
if (incoming.length === 0) return;
setDocError(null);
for (const f of incoming) {
if (!isAllowedFile(f)) {
setDocError(`"${f.name}" is not supported. Use PDF, DOCX, PPTX, Markdown, TXT, or VTT.`);
return;
}
if (f.size > MAX_UPLOAD_SIZE) {
setDocError(`"${f.name}" exceeds the 5 MB size limit.`);
return;
}
}
setDocFiles((prev) => {
if (prev.length + incoming.length > MAX_UPLOAD_FILES) {
setTimeout(() => setDocError(`Maximum ${MAX_UPLOAD_FILES} files allowed.`), 0);
return prev;
}
return [...prev, ...incoming];
});
}, []);
const addDocFiles = useCallback((newFiles: FileList | null) => {
if (!newFiles || newFiles.length === 0) return;
addDocFileArray(Array.from(newFiles));
}, [addDocFileArray]);
/** Plain text from clipboard → single .txt document (same limits as uploads). */
const addPastedTextAsTxtFile = useCallback((text: string) => {
const blob = new Blob([text], { type: "text/plain;charset=utf-8" });
const file = new File([blob], "pasted-content.txt", { type: "text/plain" });
addDocFileArray([file]);
}, [addDocFileArray]);
// Step 1 + Upload: Ctrl+V pastes plain text as a .txt doc, or pasted files as uploads.
// Skips when focus is in an input/textarea so project name & other fields work normally.
useEffect(() => {
if (step !== 1 || mode !== "upload") return;
const onPaste = (e: ClipboardEvent) => {
const el = e.target as HTMLElement | null;
if (el?.closest?.("input, textarea, select, [contenteditable='true']")) return;
const cd = e.clipboardData;
if (!cd) return;
if (cd.files && cd.files.length > 0) {
e.preventDefault();
addDocFiles(cd.files);
return;
}
const text = cd.getData("text/plain");
const trimmed = text?.trim() ?? "";
if (!trimmed) return;
e.preventDefault();
addPastedTextAsTxtFile(trimmed);
};
window.addEventListener("paste", onPaste);
return () => window.removeEventListener("paste", onPaste);
}, [step, mode, addDocFiles, addPastedTextAsTxtFile]);
const removeDocFile = (index: number) => {
setDocFiles((prev) => prev.filter((_, i) => i !== index));
setDocError(null);
};
const formatFileSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
// ─── File-extension URL detection ────────────────────────────
const urlFileExt = mode === "url" ? getFileExtension(urls[0] ?? "") : null;
const bulkFileExtRows = mode === "bulk"
? bulkRows.map((r) => getFileExtension(r.url))
: [];
const hasBulkFileExt = bulkFileExtRows.some(Boolean);
// ─── Non-scrapable link detection ────────────────────────────
const urlClassification = mode === "url" ? classifyUrl(urls[0] ?? "") : { kind: "ok" as const };
const urlScrape = urlClassification.kind;
const bulkScrapeRows = mode === "bulk"
? bulkRows.map((r) => classifyUrlScrapability(r.url))
: [];
const hasBulkBlocked = bulkScrapeRows.some((s) => s === "blocked");
// ─── Navigation ──────────────────────────────────────────────
// Step order: 1 = Project (URL/Upload/Bulk), 2 = Template, 3 = Voice
const canGoNext1 =
mode === "url"
? !!urls[0]?.trim() && !hasSpacesInMiddle(urls[0]) && containsDot(urls[0]) && !urlFileExt && urlScrape !== "blocked"
: mode === "upload"
? docFiles.length > 0
: bulkRows.some((r) => r.url.trim()) &&
bulkRows.every(
(r) =>
!r.url.trim() || (!hasSpacesInMiddle(r.url) && containsDot(r.url))
) && !hasBulkFileExt && !hasBulkBlocked;
const goNext = () => {
if (step === 1 && canGoNext1) {
if (mode === "bulk") {
const n = bulkRows.length;
setBulkNames((prev) => resizeTo(prev, n, ""));
setBulkTemplates((prev) => resizeTo(prev, n, pickerDefaultTemplateId));
setBulkVoiceGender((prev) => resizeTo(prev, n, "female"));
setBulkVoiceAccent((prev) => resizeTo(prev, n, "american"));
setBulkCustomVoiceId((prev) => resizeTo(prev, n, ""));
setBulkContentLanguage((prev) => resizeTo(prev, n, "auto"));
setBulkVideoLength((prev) => resizeTo(prev, n, "short"));
setBulkStockFootage((prev) => resizeTo(prev, n, true));
setBulkAspectRatio((prev) => resizeTo(prev, n, "landscape"));
setBulkVideoStyles((prev) =>
resizeTo(
prev,
n,
videoStyleForBulkTemplateId(
pickerDefaultTemplateIdRef.current,
templatesRef.current,
customTemplatesRef.current
)
)
);
setBulkAccentColors((prev) => resizeTo(prev, n, ""));
setBulkBgColors((prev) => resizeTo(prev, n, ""));
setBulkTextColors((prev) => resizeTo(prev, n, ""));
setBulkLogoFile((prev) => resizeTo(prev, n, null));
setBulkLogoPosition((prev) => resizeTo(prev, n, "bottom_right"));
setBulkLogoOpacity((prev) => resizeTo(prev, n, 0.9));
setBulkActiveIndex(0);
}
setStep(2);
} else if (step === 2) {
step3EnteredAtRef.current = Date.now();
setBulkActiveIndex(0);
setStep(3);
}
};
function resizeTo<T>(arr: T[], len: number, fill: T): T[] {
if (arr.length >= len) return arr.slice(0, len);
return [...arr, ...Array(len - arr.length).fill(fill)];
}
const addBulkRow = () => {
if (bulkRows.length >= MAX_BULK_LINKS) return;
setBulkRows((prev) => [...prev, { url: "" }]);
setBulkNames((prev) => [...prev, ""]);
setBulkTemplates((prev) => [...prev, pickerDefaultTemplateId]);
setBulkVoiceGender((prev) => [...prev, "female"]);
setBulkVoiceAccent((prev) => [...prev, "american"]);
setBulkCustomVoiceId((prev) => [...prev, ""]);
setBulkContentLanguage((prev) => [...prev, "auto"]);
setBulkVideoLength((prev) => [...prev, "short"]);
setBulkStockFootage((prev) => {
const next = [...prev, bulkApplyStockAll ? (prev[bulkStockMasterIndex] ?? true) : true];
return next;
});
setBulkAspectRatio((prev) => [...prev, "landscape"]);
setBulkVideoStyles((prev) => [
...prev,
videoStyleForBulkTemplateId(
pickerDefaultTemplateIdRef.current,
templatesRef.current,
customTemplatesRef.current
),
]);
setBulkAccentColors((prev) => [...prev, ""]);
setBulkBgColors((prev) => [...prev, ""]);
setBulkTextColors((prev) => [...prev, ""]);
setBulkLogoFile((prev) => [...prev, null]);
setBulkLogoPosition((prev) => [...prev, "bottom_right"]);
setBulkLogoOpacity((prev) => [...prev, 0.9]);
bulkStyleManuallySet.current = [...bulkStyleManuallySet.current, false];
};
const removeBulkRow = (index: number) => {
if (bulkRows.length <= 1) return;
bulkStyleManuallySet.current = bulkStyleManuallySet.current.filter((_, i) => i !== index);
setBulkRows((prev) => prev.filter((_, i) => i !== index));
setBulkNames((prev) => prev.filter((_, i) => i !== index));
setBulkTemplates((prev) => prev.filter((_, i) => i !== index));
setBulkVoiceGender((prev) => prev.filter((_, i) => i !== index));
setBulkVoiceAccent((prev) => prev.filter((_, i) => i !== index));
setBulkCustomVoiceId((prev) => prev.filter((_, i) => i !== index));
setBulkContentLanguage((prev) => prev.filter((_, i) => i !== index));
setBulkVideoLength((prev) => prev.filter((_, i) => i !== index));
setBulkStockFootage((prev) => prev.filter((_, i) => i !== index));
setBulkAspectRatio((prev) => prev.filter((_, i) => i !== index));
setBulkVideoStyles((prev) => prev.filter((_, i) => i !== index));
setBulkAccentColors((prev) => prev.filter((_, i) => i !== index));
setBulkBgColors((prev) => prev.filter((_, i) => i !== index));
setBulkTextColors((prev) => prev.filter((_, i) => i !== index));
setBulkLogoFile((prev) => prev.filter((_, i) => i !== index));
setBulkLogoPosition((prev) => prev.filter((_, i) => i !== index));
setBulkLogoOpacity((prev) => prev.filter((_, i) => i !== index));
setBulkActiveIndex((prev) => {
if (prev >= bulkRows.length - 1) return Math.max(0, prev - 1);
return prev;
});
};
const goBack = () => {
if (step === 2) {
setStep(1);
setBulkActiveIndex(0);
}
else if (step === 3) {
setStep(2);
setBulkActiveIndex(0);
}
};
// ─── Submit ──────────────────────────────────────────────────
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (isDemo) return;
if (step !== 3) return;
const enteredAt = step3EnteredAtRef.current;
if (enteredAt != null && Date.now() - enteredAt < 400) return;
step3EnteredAtRef.current = null;
audioRef.current?.pause();
bgmAudioRef.current?.pause();
if (mode === "bulk" && onSubmitBulk) {
const valid = bulkRows
.map((r, i) => ({ url: r.url, name: bulkNames[i] ?? "", i }))
.filter((r) => r.url.trim());
if (valid.length === 0) return;
// Detect duplicate URLs and auto-suffix names
const urlCounts: Record<string, number> = {};
const urlSeenSoFar: Record<string, number> = {};
for (const { url } of valid) {
const normalized = url.trim();
urlCounts[normalized] = (urlCounts[normalized] ?? 0) + 1;
}
const firstSavedVoiceId = myVoicesList[0]?.voice_id;
const items: BulkProjectItem[] = valid.map(({ url, name: n, i }) => {
const normalized = url.trim();
urlSeenSoFar[normalized] = (urlSeenSoFar[normalized] ?? 0) + 1;
const isDuplicate = urlCounts[normalized] > 1;
let resolvedName = n.trim() || undefined;
if (!resolvedName && isDuplicate) {
const occurrence = urlSeenSoFar[normalized];
const derived = deriveNameFromUrl(normalized);
resolvedName = occurrence === 1 ? derived : `${derived} (${occurrence})`;
}
const rowSelectedVoiceId = bulkCustomVoiceId[i]?.trim();
const selectedVoice = myVoicesList.find((v) => v.voice_id === rowSelectedVoiceId);
const rowGender = bulkVoiceGender[i] ?? "female";
const inferredGender =
rowGender === "none"
? "none"
: (normalizeVoiceGender(selectedVoice?.gender) ?? rowGender);
const inferredAccent = normalizeVoiceAccent(selectedVoice?.accent) ?? bulkVoiceAccent[i];
const effectiveCustomVoiceId = rowSelectedVoiceId || firstSavedVoiceId;
return {
blog_url: normalized,
name: resolvedName,
template: bulkTemplates[i] !== "default" ? bulkTemplates[i] : undefined,
video_style:
bulkVideoStyles[i] ??
videoStyleForBulkTemplateId(
bulkTemplates[i] ?? "default",
templatesRef.current,
customTemplatesRef.current
),
video_length: bulkVideoLength[i] ?? "short",
voice_gender: inferredGender,
voice_accent: inferredAccent,
voice_emotion: (() => {
const s = bulkVoiceStability[i] ?? VOICE_STABILITY_DEFAULT;
const sp = bulkVoiceSpeed[i] ?? VOICE_SPEED_DEFAULT;
const em = bulkVoiceEmotion[i] ?? "";
const sty = bulkVoiceStyle[i] ?? VOICE_STYLE_DEFAULT;
// Always send for Pro+voiced so the enabled/disabled flag + last values are remembered;
// the backend only applies tuning to the project when the flag is on.
return isPro && inferredGender !== "none" ? serializeVoiceTuning(s, sp, em, sty, expressiveEnabled) : undefined;
})(),
accent_color:
bulkAccentColors[i] && bulkAccentColors[i].trim()
? bulkAccentColors[i]
: accentColor,
bg_color:
bulkBgColors[i] && bulkBgColors[i].trim()
? bulkBgColors[i]
: bgColor,
text_color:
bulkTextColors[i] && bulkTextColors[i].trim()
? bulkTextColors[i]
: textColor,
logo_position: bulkLogoPosition[i] ?? "bottom_right",
logo_opacity: bulkLogoOpacity[i] ?? 0.9,
custom_voice_id:
inferredGender === "none"
? undefined
: (effectiveCustomVoiceId || undefined),
aspect_ratio: bulkAspectRatio[i] ?? "landscape",
content_language:
(bulkContentLanguage[i] ?? "auto") === "auto"
? null
: (bulkContentLanguage[i] ?? "auto"),
// Per-row now (with "apply to all" in step 1); the backend still decides
// per project whether its template can render a clip and clears otherwise.
stock_footage_enabled: bulkStockFootage[i] ?? false,
};
});
const logoIndices: number[] = [];
const logoFiles: File[] = [];
valid.forEach((v, j) => {
const f = bulkLogoFile[v.i];
if (f) {
logoIndices.push(j);
logoFiles.push(f);
}
});
await onSubmitBulk(items, logoIndices.length > 0 ? { logoIndices, logoFiles } : null);
setBulkRows([{ url: "" }]);
setBulkNames([""]);
setBulkTemplates([pickerDefaultTemplateId]);
setBulkVoiceGender(["female"]);
setBulkVoiceAccent(["american"]);
setBulkVoiceStability([VOICE_STABILITY_DEFAULT]);
setBulkVoiceSpeed([VOICE_SPEED_DEFAULT]);
setBulkVoiceEmotion([""]);
setBulkVoiceStyle([VOICE_STYLE_DEFAULT]);
setBulkCustomVoiceId([]);
setBulkContentLanguage(["auto"]);
setBulkVideoLength(["short"]);
setBulkStockFootage([true]);
setBulkAspectRatio(["landscape"]);
setBulkVideoStyles([DEFAULT_VIDEO_STYLE]);
setBulkAccentColors([""]);
setBulkBgColors([""]);
setBulkTextColors([""]);
setBulkLogoFile([null]);
setBulkLogoPosition(["bottom_right"]);
setBulkLogoOpacity([0.9]);
setBulkActiveIndex(0);
setBulkApplyLengthAll(true);
setBulkLengthMasterIndex(0);
setBulkApplyTemplateAll(true);
setBulkTemplateMasterIndex(0);
setBulkApplyVoiceAll(true);
setBulkVoiceMasterIndex(0);
setVideoLength("short");
setContentLanguage("auto");
return;
}
if (mode === "upload") {
if (docFiles.length === 0) return;
const selectedVoice = myVoicesList.find((v) => v.voice_id === customVoiceId.trim());
const inferredGender =
voiceGender === "none"
? "none"
: (normalizeVoiceGender(selectedVoice?.gender) ?? voiceGender);
const inferredAccent = normalizeVoiceAccent(selectedVoice?.accent) ?? voiceAccent;
const effectiveCustomVoiceId = customVoiceId.trim() || myVoicesList[0]?.voice_id || "";
await onSubmit(
"",
name.trim() || undefined,
inferredGender,
inferredAccent,
accentColor,
bgColor,
textColor,
undefined,
logoFile || undefined,
logoPosition,
logoOpacity,
inferredGender === "none" ? undefined : (effectiveCustomVoiceId || undefined),
aspectRatio,
docFiles,
template !== "default" ? template : undefined,
videoStyle,
videoLength,
contentLanguage === "auto" ? null : contentLanguage,
isPro && inferredGender !== "none"
? serializeVoiceTuning(voiceStability, voiceSpeed, voiceEmotion, voiceStyle, expressiveEnabled)
: undefined,
selectedBgmTrackId,
selectedBgmVolume
);
setDocFiles([]);
setName("");
} else {
const validUrls = urls.filter((u) => u.trim());
if (validUrls.length === 0) return;
const selectedVoice = myVoicesList.find((v) => v.voice_id === customVoiceId.trim());
const inferredGender =
voiceGender === "none"
? "none"
: (normalizeVoiceGender(selectedVoice?.gender) ?? voiceGender);
const inferredAccent = normalizeVoiceAccent(selectedVoice?.accent) ?? voiceAccent;
const effectiveCustomVoiceId = customVoiceId.trim() || myVoicesList[0]?.voice_id || "";
for (const url of validUrls) {
await onSubmit(
url.trim(),
name.trim() || undefined,
inferredGender,
inferredAccent,
accentColor,
bgColor,
textColor,
undefined,
logoFile || undefined,
logoPosition,
logoOpacity,
inferredGender === "none" ? undefined : (effectiveCustomVoiceId || undefined),
aspectRatio,
undefined,
template !== "default" ? template : undefined,
videoStyle,
videoLength,
contentLanguage === "auto" ? null : contentLanguage,
isPro && inferredGender !== "none"
? serializeVoiceTuning(voiceStability, voiceSpeed, voiceEmotion, voiceStyle, expressiveEnabled)
: undefined,
selectedBgmTrackId,
selectedBgmVolume
);
}
setUrls([""]);
setName("");
}
};
// ─── Template apply colors ───────────────────────────────────
const applyTemplate = (id: string) => {
templateManuallySelectedRef.current = true;
setTemplate(id);
// Custom template
if (id.startsWith("custom_")) {
const customId = parseInt(id.replace("custom_", ""));
const ct = customTemplates.find((t) => t.id === customId);
if (ct) {
setAccentColor(ct.preview_colors.accent);
setBgColor(ct.preview_colors.bg);
setTextColor(ct.preview_colors.text);
}
return;
}
const meta = allTemplates.find((t) => t.id === id);
if (meta?.preview_colors) {
setAccentColor(meta.preview_colors.accent);
setBgColor(meta.preview_colors.bg);
setTextColor(meta.preview_colors.text);
}
};
const openStep2CustomTemplateCreator = (style: VideoStyleId, _bulkRow: number | null) => {
// Creation is open to all plans; the dashboard creator enforces the per-plan
// template-creation cap (1 free + purchased slots) via can_create_custom_template.
onDismissFlow?.();
const params = new URLSearchParams();
params.set("tab", "templates");
params.set("openCustomCreator", "1");
params.set("videoStyle", style);
navigate(`/dashboard?${params.toString()}`);
};
const openStep3CustomVoiceCreator = () => {
if (!isPro) {
setShowCustomTemplateUpgrade(true);
return;
}
onDismissFlow?.();
const params = new URLSearchParams();
params.set("tab", "voices");
params.set("openCustomVoiceCreator", "1");
navigate(`/dashboard?${params.toString()}`);
};
// ─── Step 1: Project (URL or Upload) ─────────────────────────
const bulkStep1ActiveIndex = Math.min(bulkActiveIndex, Math.max(0, bulkRows.length - 1));
const bulkStep1MasterIndex = Math.min(bulkLengthMasterIndex, Math.max(0, bulkRows.length - 1));
const bulkStep1RowVideoLength = bulkVideoLength[bulkStep1ActiveIndex] ?? "short";
const applyStep1LengthToAll = () => {
setBulkVideoLength((prev) => {
const next = resizeTo(prev, bulkRows.length, "short");
const value = next[bulkStep1ActiveIndex] ?? "short";
return next.map(() => value);
});
};
const bulkStep1RowStockFootage = bulkStockFootage[bulkStep1ActiveIndex] ?? false;
const applyStep1StockToAll = () => {
setBulkStockFootage((prev) => {
const next = resizeTo(prev, bulkRows.length, false);
const value = next[bulkStep1ActiveIndex] ?? false;
return next.map(() => value);
});
};
const setBulkStockFootageAt = (value: boolean) => {
// Mirrors the length dropdown: editing a non-master row while "apply to all"
// is on breaks the sync; editing the master (or with sync off) sets one/all.
if (bulkApplyStockAll && bulkStep1ActiveIndex !== bulkStockMasterIndex) {
setBulkApplyStockAll(false);
setBulkStockFootage((prev) => {
const next = resizeTo(prev, bulkRows.length, false);
next[bulkStep1ActiveIndex] = value;
return next;
});
return;
}
if (bulkApplyStockAll && bulkStep1ActiveIndex === bulkStockMasterIndex) {
setBulkStockFootage((prev) => resizeTo(prev, bulkRows.length, false).map(() => value));
return;
}
setBulkStockFootage((prev) => {
const next = resizeTo(prev, bulkRows.length, false);
next[bulkStep1ActiveIndex] = value;
return next;
});
};
const step1 = (
<div className="flex flex-col flex-1 min-h-0">
<div className="flex-1 flex flex-col space-y-5 min-h-0">
{/* Mode tabs — selected tab purple */}
<div className="flex gap-1 p-1 bg-gray-100/60 rounded-xl w-fit">
{(["url", "upload", ...(onSubmitBulk ? (["bulk"] as const) : [])] as const).map((m) => (
<button
key={m}
type="button"
onClick={() => setMode(m)}
className={`px-4 py-1.5 rounded-lg text-xs font-medium transition-all ${
mode === m
? "bg-white text-purple-600 shadow-sm"
: "text-gray-400 hover:text-gray-600"
}`}
>
{m === "url" ? "Link" : m === "upload" ? "Upload" : "Multi Link"}
</button>
))}
</div>
{/* Bulk: multiple links (url + name per row) */}
{mode === "bulk" && (<>
<BulkLinksSection
rows={bulkRows}
maxBulkLinks={MAX_BULK_LINKS}
aspectRatios={bulkAspectRatio}
onChangeUrl={(index, value) =>
setBulkRows((prev) => prev.map((r, i) => (i === index ? { ...r, url: value } : r)))
}
onChangeAspectRatio={(index, value) =>
setBulkAspectRatio((prev) => {
const next = [...prev];
next[index] = value;
return next;
})
}
onAddRow={addBulkRow}
onRemoveRow={removeBulkRow}
/>
{(() => {
const seen = new Set<string>();
const hasDupes = bulkRows.some((r) => {
const t = r.url.trim();
if (!t) return false;
if (seen.has(t)) return true;
seen.add(t);
return false;
});
return hasDupes ? (
<p className="text-xs text-amber-600 bg-amber-50 rounded-lg px-3 py-2 mt-2">
Duplicate URLs detected — suffixes will be added to project names to differentiate them.
</p>
) : null;
})()}
{hasBulkFileExt && (
<div className="mt-2 flex items-start gap-2 px-3 py-2.5 rounded-xl bg-purple-50 border border-purple-200/60">
<svg className="w-4 h-4 text-purple-500 shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13 16h-1v-4h-1m1-4h.01M12 2a10 10 0 110 20A10 10 0 0112 2z" />
</svg>
<p className="text-[11px] text-purple-700 leading-relaxed">
{bulkFileExtRows.map((ext, i) => ext ? (
<span key={i}>Row {i + 1} has a <span className="font-semibold">{ext.toUpperCase()}</span> file link. </span>
) : null)}
Please use the <span className="font-semibold">Upload</span> tab to upload files directly instead of linking to them.
</p>
</div>
)}
<div className="flex flex-wrap gap-1">
<div className="flex flex-wrap gap-1 p-1 bg-gray-100/60 rounded-xl">
{bulkRows.map((_, i) => (
<button
key={i}
type="button"
onClick={() => setBulkActiveIndex(i)}
className={`px-3 py-1.5 rounded-lg text-[11px] font-medium transition-all ${
i === bulkStep1ActiveIndex
? "bg-white text-purple-600 shadow-sm"
: "text-gray-400 hover:text-gray-600"
}`}
>
Video #{i + 1}
</button>
))}
</div>
</div>
{/* One "apply to all" toggle drives both duration and footage sync. */}
<div className="flex items-center justify-start">
<label className="flex items-center gap-2 text-[11px] text-gray-500 cursor-pointer select-none">
<input
type="checkbox"
checked={bulkApplyLengthAll && (!stockFootageAvailable || bulkApplyStockAll)}
onChange={(e) => {
const checked = e.target.checked;
setBulkApplyLengthAll(checked);
setBulkApplyStockAll(checked);
if (checked) {
setBulkLengthMasterIndex(bulkStep1ActiveIndex);
setBulkStockMasterIndex(bulkStep1ActiveIndex);
applyStep1LengthToAll();
applyStep1StockToAll();
}
}}
className="h-3.5 w-3.5 rounded border-gray-300 accent-purple-600 focus:ring-purple-500"
/>
Apply to all
</label>
</div>
{/* Estimated duration + stock footage side by side. */}
<div className={`mt-1 grid gap-3 items-start ${stockFootageAvailable ? "grid-cols-2" : "grid-cols-1"}`}>
<div className="space-y-1.5">
<label className="block text-[11px] font-medium text-gray-400 uppercase tracking-wider">
Estimated duration
</label>
{renderVideoLengthDropdown(bulkStep1RowVideoLength, (value) => {
if (bulkApplyLengthAll && bulkStep1ActiveIndex !== bulkStep1MasterIndex) {
setBulkApplyLengthAll(false);
setBulkVideoLength((prev) => {
const next = resizeTo(prev, bulkRows.length, "short");
next[bulkStep1ActiveIndex] = value;
return next;
});
return;
}
if (bulkApplyLengthAll && bulkStep1ActiveIndex === bulkStep1MasterIndex) {
setBulkVideoLength((prev) => resizeTo(prev, bulkRows.length, "short").map(() => value));
return;
}
setBulkVideoLength((prev) => {
const next = resizeTo(prev, bulkRows.length, "short");
next[bulkStep1ActiveIndex] = value;
return next;
});
})}
</div>
{stockFootageAvailable && (
<div className="space-y-1.5">
<label className="block text-[11px] font-medium text-gray-400 uppercase tracking-wider">
Use stock footage
</label>
<label className="flex items-center gap-2.5 cursor-pointer select-none p-3 rounded-xl bg-gray-50/60 border border-gray-200/60 hover:border-purple-300/60 transition-all">
<input
type="checkbox"
checked={bulkStep1RowStockFootage}
onChange={(e) => setBulkStockFootageAt(e.target.checked)}
className="w-4 h-4 shrink-0 rounded border-gray-300 text-purple-600 focus:ring-purple-500/30 cursor-pointer accent-purple-600"
/>
<span className="text-xs font-small text-gray-700 min-w-0">
{isPro ? "Insert stock footage automatically" : "Insert stock footage automatically"}
</span>
</label>
</div>
)}
</div>
<p className="mt-1 text-[10px] text-gray-400 pb-10 leading-relaxed">
Actual length may vary depending on content size and video style. If the scraped or uploaded content is
very short, video might get shorten automatically.
</p>
</>)}
{/* URL input */}
{mode === "url" && (
<div>
<label className="block text-[11px] font-medium text-gray-400 mb-1.5 uppercase tracking-wider">
Blog URL
</label>
{urls.map((url, i) => (
<input
key={i}
type="url"
required={i === 0}
value={url}
onChange={(e) => {
const next = e.target.value;
setUrls((prev) => prev.map((u, idx) => (idx === i ? next : u)));
if (i === 0) {
setUrlError(null);
}
}}
onBlur={(e) => {
if (i !== 0) return;
const value = e.target.value;
const trimmed = value.trim();
if (!trimmed) {
setUrlError(null);
return;
}
if (hasSpacesInMiddle(value)) {
setUrlError("Enter a valid link (e.g. example.com, https://example.com).");
} else if (!containsDot(value)) {
setUrlError("Enter a valid link (e.g. example.com, https://example.com).");
} else {
setUrlError(null);
}
}}
placeholder={
i === 0
? "https://yourblog.com/your-article..."
: `URL ${i + 1} (optional)`
}
className={`w-full px-4 py-2.5 bg-white/80 border rounded-xl text-sm text-gray-900 placeholder-gray-300 focus:outline-none focus:ring-2 focus:ring-purple-500/40 focus:border-transparent transition-all mb-2 ${
i === 0 && urls[0]?.trim() && (urlError || urlScrape === "blocked")
? "border-red-400"
: "border-gray-200/60"
}`}
autoFocus={i === 0}
/>
))}
{urlError && urls[0]?.trim() && (
<p className="text-xs text-red-500 mt-1">
{urlError}
</p>
)}
{urlFileExt && urls[0]?.trim() && (
<div className="mt-2 flex items-start gap-2 px-3 py-2.5 rounded-xl bg-purple-50 border border-purple-200/60">
<svg className="w-4 h-4 text-purple-500 shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13 16h-1v-4h-1m1-4h.01M12 2a10 10 0 110 20A10 10 0 0112 2z" />
</svg>
<p className="text-[11px] text-purple-700 leading-relaxed">
<span className="font-semibold">{urlFileExt.toUpperCase()} files</span> can't be processed as a URL. Please use the{" "}
<span className="font-semibold">Upload</span> tab above to upload this file directly.
</p>
</div>
)}
{urlScrape === "blocked" && urls[0]?.trim() && (
<p className="text-xs text-red-500 mt-1">
This site can't be scraped, please use a different link.
</p>
)}
{urlScrape === "warn" && urls[0]?.trim() && (
<p className="text-[11px] text-amber-600 leading-relaxed mt-1">
{urlClassification.message}
</p>
)}
<p className="mt-0.5 text-[11px] text-gray-400 leading-relaxed">
Use a paywall-free link for best results.{" "}
<button
type="button"
onClick={() => {
const demoUrls = [
"https://blog2video.app/"
];
const picked = demoUrls[Math.floor(Math.random() * demoUrls.length)];
setUrls((prev) => prev.map((u, idx) => (idx === 0 ? picked : u)));
setUrlError(null);
}}
className="text-purple-500 hover:text-purple-700 underline underline-offset-2 transition-colors"
>
Try a demo link
</button>
</p>
</div>
)}
{/* Document upload */}
{mode === "upload" && (
<div>
<label className="block text-[11px] font-medium text-gray-400 mb-1.5 uppercase tracking-wider">
Documents{" "}
<span className="text-gray-300 font-normal">(max 5 files, 5 MB each)</span>
</label>
<div
role="button"
tabIndex={0}
aria-label="Upload documents or paste text"
className="relative border-2 border-dashed border-gray-200/80 rounded-xl p-6 text-center hover:border-purple-400/60 transition-colors cursor-pointer focus:outline-none focus:ring-2 focus:ring-purple-500/40"
onClick={() => docInputRef.current?.click()}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
docInputRef.current?.click();
}
}}
onPaste={(e) => {
e.stopPropagation();
const cd = e.clipboardData;
if (cd.files && cd.files.length > 0) {
e.preventDefault();
addDocFiles(cd.files);
return;
}
const text = cd.getData("text/plain");
const trimmed = text?.trim() ?? "";
if (trimmed) {
e.preventDefault();
addPastedTextAsTxtFile(trimmed);
}
}}
onDragOver={(e) => {
e.preventDefault();
e.currentTarget.classList.add("border-purple-400/60", "bg-purple-50/30");
}}
onDragLeave={(e) => {
e.preventDefault();
e.currentTarget.classList.remove("border-purple-400/60", "bg-purple-50/30");
}}
onDrop={(e) => {
e.preventDefault();
e.currentTarget.classList.remove("border-purple-400/60", "bg-purple-50/30");
addDocFiles(e.dataTransfer.files);
}}
>
<svg className="w-8 h-8 mx-auto mb-2 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<p className="text-sm text-gray-500">
Drop files here or <span className="text-purple-600 font-medium">paste text (Ctrl+V)</span>
</p>
<p className="text-[10px] text-gray-300 mt-1">PDF, Word, PowerPoint, Markdown, Text, VTT — or paste plain text as a .txt file</p>
<input
ref={docInputRef}
type="file"
accept=".pdf,.docx,.pptx,.md,.markdown,.txt,.vtt,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.openxmlformats-officedocument.presentationml.presentation,text/plain,text/markdown,text/x-markdown,text/vtt"
multiple
className="hidden"
onChange={(e) => { addDocFiles(e.target.files); e.target.value = ""; }}
/>
</div>
{docError && <p className="mt-2 text-[11px] text-red-500">{docError}</p>}
{docFiles.length > 0 && (
<div className="mt-3 space-y-2">
{docFiles.map((file, i) => (
<div key={`${file.name}-${i}`} className="flex items-center gap-3 px-4 py-3 bg-gray-50/80 rounded-xl border border-gray-200/60">
<svg
className={`w-6 h-6 flex-shrink-0 ${file.name.endsWith(".pdf") ? "text-red-400" : file.name.endsWith(".docx") ? "text-blue-400" : "text-orange-400"}`}
fill="currentColor"
viewBox="0 0 20 20"
>
<path fillRule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clipRule="evenodd" />
</svg>
<span className="flex-1 text-sm text-gray-700 truncate font-medium">{file.name}</span>
<span className="text-xs text-gray-400 flex-shrink-0">{formatFileSize(file.size)}</span>
<button
type="button"
onClick={(e) => { e.stopPropagation(); removeDocFile(i); }}
className="flex-shrink-0 px-2.5 py-1.5 rounded-lg text-xs font-medium text-red-600 bg-red-50 hover:bg-red-100 border border-red-200/60 transition-colors"
>
Remove
</button>
</div>
))}
</div>
)}
</div>
)}
{/* Project name (single-link / upload only; bulk has per-row name) */}
{mode !== "bulk" && (
<div>
<label className="block text-[11px] font-medium text-gray-400 mb-1.5 uppercase tracking-wider">
Project Name <span className="text-gray-300 font-normal">(optional)</span>
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={mode === "url" ? "Auto-generated from URL" : "Auto-generated from file name"}
className="w-full px-4 py-2.5 bg-white/80 border border-gray-200/60 rounded-xl text-sm text-gray-900 placeholder-gray-300 focus:outline-none focus:ring-2 focus:ring-purple-500/40 focus:border-transparent transition-all"
/>
</div>
)}
{/* Format, duration + Logo (single-link / upload only; bulk has per-row in step 3) */}
{mode !== "bulk" && (
<>
<div>
<label className="block text-[11px] font-medium text-gray-400 mb-2 uppercase tracking-wider">
Video Format
</label>
<div className="flex gap-2">
{([
{ value: "landscape", label: "Landscape", sub: "YouTube" },
{ value: "portrait", label: "Portrait", sub: "TikTok / Reels" },
] as const).map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setAspectRatio(opt.value)}
className={`flex-1 py-2.5 rounded-lg text-xs font-medium transition-all flex flex-col items-center gap-0.5 ${
aspectRatio === opt.value
? "bg-purple-600 text-white shadow-sm"
: "bg-gray-50 text-gray-500 hover:bg-gray-100 border border-gray-200/60"
}`}
>
<span>{opt.label}</span>
<span className={`text-[9px] ${aspectRatio === opt.value ? "text-purple-200" : "text-gray-300"}`}>
{opt.sub}
</span>
</button>
))}
</div>
</div>
<div>
{/* Estimated duration + stock footage side by side in one row. */}
{/* items-stretch + h-full on each column: the duration label can wrap
to two lines while the checkbox stays one, so sizing each column
to its own content leaves the two pills visibly different heights. */}
<div className={`grid gap-2 items-stretch ${stockFootageAvailable ? "grid-cols-2" : "grid-cols-1"}`}>
<div className="flex flex-col h-full">
<label className="block text-[11px] font-medium text-gray-400 mb-1.5 uppercase tracking-wider">
Estimated duration
</label>
<div className="flex flex-1 flex-col [&>details]:flex-1 [&>details>summary]:h-full">
{renderVideoLengthDropdown(videoLength, setVideoLength)}
</div>
</div>
{stockFootageAvailable && (
<div className="flex flex-col h-full">
<label className="block text-[11px] font-medium text-gray-400 mb-1.5 uppercase tracking-wider">
Use stock footage
</label>
<label className="flex flex-1 items-center gap-2.5 cursor-pointer select-none p-3 rounded-xl bg-white border border-gray-200 hover:border-purple-300/60 transition-all">
<input
type="checkbox"
checked={stockFootageEnabled}
onChange={(e) => setStockFootageEnabled(e.target.checked)}
className="w-4 h-4 shrink-0 rounded border-gray-300 text-purple-600 focus:ring-purple-500/30 cursor-pointer accent-purple-600"
/>
<span className="text-xs font-small text-gray-700 min-w-0">
{isPro ? "Insert stock footage automatically" : "Insert stock footage automatically"}
</span>
</label>
</div>
)}
</div>
<p className="mt-1 text-[10px] text-gray-400 leading-relaxed">
Actual length may vary depending on content size and video style. If the scraped or uploaded content is
very short, we may shorten the video.
</p>
</div>
<div>
<label className="block text-[11px] font-medium text-gray-400 mb-2 uppercase tracking-wider">
Logo <span className="text-gray-300 font-normal">(optional · max 2 MB)</span>
</label>
<div className="flex items-center gap-3">
<div className="relative mb-4 inline-block">
<button
type="button"
onClick={() => logoInputRef.current?.click()}
className="px-3 py-2 rounded-lg text-xs font-medium bg-gray-50 text-gray-500 hover:bg-gray-100 border border-gray-200/60 transition-all pr-8"
>
{logoFile ? logoFile.name : "Choose file"}
</button>
{logoFile && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setLogoFile(null);
if (logoInputRef.current) logoInputRef.current.value = "";
}}
className="absolute top-0.5 right-0.5 w-6 h-6 rounded-full border border-purple-500/80 text-purple-600 hover:bg-purple-600 hover:text-white hover:border-purple-600 flex items-center justify-center transition-colors"
title="Remove logo"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" strokeWidth={2.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
<input
ref={logoInputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/svg+xml"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0] || null;
if (f && f.size > 2 * 1024 * 1024) {
showError("Logo must be under 2 MB.");
e.target.value = "";
return;
}
setLogoFile(f);
}}
/>
</div>
{logoFile && (
<div className="mt-2">
<label className="block text-[10px] text-gray-400 mb-1">Position</label>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-1.5">
{([
{ value: "top_left", label: "Top Left" },
{ value: "top_right", label: "Top Right" },
{ value: "bottom_left", label: "Bottom Left" },
{ value: "bottom_right", label: "Bottom Right" },
] as const).map((pos) => (
<button
key={pos.value}
type="button"
onClick={() => setLogoPosition(pos.value)}
className={`px-2 py-1 rounded text-[10px] font-medium transition-all ${
logoPosition === pos.value
? "bg-purple-600 text-white"
: "bg-gray-50 text-gray-400 hover:bg-gray-100 border border-gray-200/60"
}`}
>
{pos.label}
</button>
))}
</div>
<div className="mt-2.5 mb-3.5">
<label className="block text-[10px] text-gray-500 mb-1">
Opacity <span className="text-gray-500">{Math.round(logoOpacity * 100)}%</span>
</label>
<input
type="range"
min={10}
max={100}
step={5}
value={Math.round(logoOpacity * 100)}
onChange={(e) => setLogoOpacity(parseInt(e.target.value, 10) / 100)}
className="w-full h-1 bg-gray-300 rounded-full appearance-none cursor-pointer accent-purple-600"
/>
</div>
</div>
)}
</div>
</>
)}
</div>
{/* Next — always visible; gray when disabled (invalid/empty URL) */}
<button
type="button"
onClick={goNext}
disabled={!canGoNext1}
className={`w-full mb-3 py-3 mt-auto text-sm font-medium rounded-xl transition-colors flex items-center justify-center gap-2 flex-shrink-0 ${
canGoNext1
? "bg-purple-600 hover:bg-purple-700 text-white"
: "bg-gray-200 text-gray-500 cursor-not-allowed"
}`}
>
Go to step 2
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
);
// ─── Step 2: Genre/source filter + Template list ──────────────────────────
// Genre dropdown has two source-bucket options at the top — "Custom Templates"
// and "Designer Templates" — encoded with sentinel values. Below them are
// the actual genres, compiled from built-in templates' meta.json.
const allGenres = useMemo(() => {
const set = new Set<string>();
for (const t of templates) (t.genres ?? []).forEach((g) => g && set.add(g));
return Array.from(set).sort();
}, [templates]);
const {
suggestedTemplates,
customTemplatesForStyle,
craftedTemplatesForStyle,
} = templateBucketsForGenre(genre, templates, readyCustomTemplates, readyCraftedTemplates);
const sortedSuggestedTemplates = [...suggestedTemplates].sort((a, b) => {
const rank = (t: TemplateMeta) => (t.new_template ? 0 : t.popular_template ? 1 : 2);
return rank(a) - rank(b);
});
const styleTemplateItems: Array<
| { type: "builtin"; id: string; data: TemplateMeta }
| { type: "custom"; id: string; data: CustomTemplateItem }
| { type: "crafted"; id: string; data: CraftedTemplateItem }
> = [
...sortedSuggestedTemplates.map((t) => ({ type: "builtin" as const, id: t.id, data: t })),
...customTemplatesForStyle.map((ct) => ({ type: "custom" as const, id: `custom_${ct.id}`, data: ct })),
...craftedTemplatesForStyle.map((ct) => ({ type: "crafted" as const, id: ct.id, data: ct })),
];
const SelectedPreviewComp = TEMPLATE_PREVIEWS[template];
const selectedDesc = TEMPLATE_DESCRIPTIONS[template];
const selectedCustom = template.startsWith("custom_")
? customTemplates.find((ct) => ct.id === parseInt(template.replace("custom_", "")))
: null;
const selectedCrafted = template.startsWith("crafted_")
? craftedTemplates.find((ct) => ct.id === template)
: null;
const selectedBuiltinNew =
!template.startsWith("custom_") && allTemplates.some((t) => t.id === template && t.new_template === true);
const step2Template = (
<div className="space-y-5">
{/* Selected template — full-width preview */}
<div>
<label className="block text-[11px] font-medium text-gray-400 mb-2 uppercase tracking-wider">
Selected Template
</label>
<div className="rounded-xl overflow-hidden border-2 border-purple-500 shadow-[0_0_0_4px_rgba(124,58,237,0.1)]">
<div className="relative aspect-video overflow-hidden">
{demoMode?.templatePreviewOverride && !selectedCustom && !selectedCrafted ? (
demoMode.templatePreviewOverride({ templateId: template, selected: true, thumbnail: false })
) : selectedCustom ? (
<CustomPreview theme={selectedCustom.theme} name={selectedCustom.name} previewImageUrl={selectedCustom.preview_image_url} introCode={selectedCustom.intro_code || undefined} outroCode={selectedCustom.outro_code || undefined} contentCodes={selectedCustom.content_codes || undefined} contentArchetypeIds={selectedCustom.content_archetype_ids || undefined} logoUrls={selectedCustom.logo_urls} ogImage={selectedCustom.og_image} thumbnailMode={isMobile} staticThumb={isMobile} key={`selected-custom-${selectedCustom.id}-${step}`} />
) : selectedCrafted && ((selectedCrafted as any).frontend_files || selectedCrafted.preview_file || selectedCrafted.preview_image_url || selectedCrafted.theme) ? (
<CraftedTemplatePreviewSmart
item={selectedCrafted}
compileCacheScope={user?.id != null ? String(user.id) : undefined}
showLoaderOnEmptyOrError
thumbnailMode={isMobile}
staticThumb={isMobile}
key={`selected-crafted-${selectedCrafted.id}-${step}`}
/>
) : template.startsWith("crafted_") && craftedTemplatesLoading ? (
<div className="w-full aspect-video bg-[#1a1a2e] flex items-center justify-center">
<span className="w-7 h-7 border-2 border-purple-200/50 border-t-purple-500 rounded-full animate-spin" />
</div>
) : SelectedPreviewComp ? (
<SelectedPreviewComp thumbnailMode={isMobile} key={`selected-${template}-${step}`} />
) : (
<div className="w-full aspect-video bg-gray-100 flex items-center justify-center text-gray-300 text-sm">
{selectedDesc?.title ?? template}
</div>
)}
</div>
<div className="px-4 py-2.5 bg-purple-50/80 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-gray-800">
{selectedCustom ? selectedCustom.name : selectedCrafted ? selectedCrafted.name : (selectedDesc?.title ?? template)}
</span>
{selectedBuiltinNew && <NewTemplateBadge className="shrink-0" />}
{selectedCustom && (
<span className="px-1.5 py-0.5 rounded text-[9px] font-bold text-white" style={{ backgroundColor: selectedCustom.preview_colors.accent }}>
Custom
</span>
)}
{selectedCrafted && (
<span className="px-1.5 py-0.5 rounded text-[9px] font-bold text-white bg-amber-500">
Designer
</span>
)}
</div>
{selectedCustom ? (
<div className="text-[11px] text-gray-400 mt-0.5">Custom template</div>
) : selectedCrafted ? (
<div className="text-[11px] text-gray-400 mt-0.5">Designer template</div>
) : selectedDesc?.subtitle ? (
<div className="text-[11px] text-gray-400 mt-0.5">{selectedDesc.subtitle}</div>
) : null}
</div>
<div className="w-6 h-6 rounded-full bg-purple-600 flex items-center justify-center flex-shrink-0">
<svg className="w-3.5 h-3.5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
</div>
</div>
</div>
</div>
{/* Genre dropdown + Templates list (filtered by genre) */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-[11px] font-medium text-gray-400 uppercase tracking-wider">
Genre
</label>
<div className="relative">
<button
type="button"
onClick={() => setGenreDropdownOpen((v) => !v)}
className="flex items-center gap-2 px-3 py-1.5 rounded-xl bg-gray-100/60 hover:bg-gray-200/70 text-[11px] font-medium text-gray-700 transition-all min-w-[140px] justify-between"
>
<span>
{genre === GENRE_CUSTOM
? "Custom Templates"
: genre === GENRE_CRAFTED
? "Designer"
: genre === GENRE_NEW
? "New"
: genre === GENRE_POPULAR
? "Popular"
: genre || "All genres"}
</span>
<svg
className={`w-3 h-3 text-gray-500 transition-transform ${genreDropdownOpen ? "rotate-180" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{genreDropdownOpen && (
<>
<div className="fixed inset-0 z-10" onClick={() => setGenreDropdownOpen(false)} />
<div className="absolute right-0 top-full mt-1 z-20 bg-white rounded-xl shadow-lg border border-gray-200 py-1 min-w-[200px] max-h-[300px] overflow-y-auto">
<button
type="button"
onClick={() => {
setGenre("");
setGenreDropdownOpen(false);
}}
className={`w-full text-left px-3 py-1.5 text-[11px] font-medium transition-colors ${
genre === "" ? "bg-purple-50 text-purple-600" : "text-gray-700 hover:bg-gray-50"
}`}
>
All genres
</button>
{/* Source filters — independent of genre */}
<div className="my-1 border-t border-gray-100" />
<button
type="button"
onClick={() => {
setGenre(GENRE_NEW);
setGenreDropdownOpen(false);
}}
className={`w-full text-left px-3 py-1.5 text-[11px] font-medium transition-colors ${
genre === GENRE_NEW ? "bg-purple-50 text-purple-600" : "text-gray-700 hover:bg-gray-50"
}`}
>
✦ New
</button>
<button
type="button"
onClick={() => {
setGenre(GENRE_POPULAR);
setGenreDropdownOpen(false);
}}
className={`w-full text-left px-3 py-1.5 text-[11px] font-medium transition-colors ${
genre === GENRE_POPULAR ? "bg-amber-50 text-amber-600" : "text-gray-700 hover:bg-gray-50"
}`}
>
Popular
</button>
<button
type="button"
onClick={() => {
setGenre(GENRE_CUSTOM);
setGenreDropdownOpen(false);
}}
className={`w-full text-left px-3 py-1.5 text-[11px] font-medium transition-colors ${
genre === GENRE_CUSTOM ? "bg-purple-50 text-purple-600" : "text-gray-700 hover:bg-gray-50"
}`}
>
Custom Templates
</button>
<button
type="button"
onClick={() => {
setGenre(GENRE_CRAFTED);
setGenreDropdownOpen(false);
}}
className={`w-full text-left px-3 py-1.5 text-[11px] font-medium transition-colors ${
genre === GENRE_CRAFTED ? "bg-purple-50 text-purple-600" : "text-gray-700 hover:bg-gray-50"
}`}
>
Designer Templates
</button>
{allGenres.length > 0 && <div className="my-1 border-t border-gray-100" />}
{allGenres.map((g) => (
<button
key={g}
type="button"
onClick={() => {
setGenre(g);
setGenreDropdownOpen(false);
}}
className={`w-full text-left px-3 py-1.5 text-[11px] font-medium transition-colors ${
genre === g ? "bg-purple-50 text-purple-600" : "text-gray-700 hover:bg-gray-50"
}`}
>
{g}
</button>
))}
</div>
</>
)}
</div>
</div>
<p className="text-[10px] text-gray-500 mb-1.5 font-medium">
{genreTemplateListCaption(genre)}
</p>
<div className="border border-gray-200/60 rounded-xl p-2.5 max-h-[260px] sm:max-h-[220px] overflow-y-auto bg-gray-50/40">
<>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
<CraftYourTemplateCard
variant="default"
isPro={isPro}
onClick={() => {
setShowGetMoreTemplates(true);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setShowGetMoreTemplates(true);
}
}}
/>
{styleTemplateItems.map((item) => {
if (item.type === "custom" || item.type === "crafted") {
const ct = item.data;
const customId = item.id;
const isSelected = template === customId;
// Mobile: every grid tile renders a static themed placeholder
// and never mounts a Player — the selected template plays only
// in the full-width "Selected Template" preview above. A grid of
// live Players exhausts iOS Safari's memory and reloads the tab.
const staticThumb = isMobile;
return (
<div
key={customId}
onClick={() => applyTemplate(customId)}
className={`relative rounded-lg border-2 overflow-hidden cursor-pointer transition-all group ${
isSelected
? "border-purple-500 shadow-[0_0_0_3px_rgba(124,58,237,0.1)]"
: "border-gray-200/60 hover:border-purple-300/60"
}`}
>
<div className="relative isolate h-[70px] overflow-hidden">
<div className="relative z-0 h-full">
{item.type === "crafted" ? (
<CraftedTemplatePreviewSmart
item={ct as any}
compileCacheScope={user?.id != null ? String(user.id) : undefined}
thumbnailMode
showLoaderOnEmptyOrError
staticThumb={staticThumb}
key={`${customId}-${step}`}
/>
) : (
<CustomPreviewLandscape {...({ theme: ct.theme, name: ct.name, introCode: ct.intro_code || undefined, outroCode: ct.outro_code || undefined, contentCodes: ct.content_codes || undefined, contentArchetypeIds: ct.content_archetype_ids || undefined, validLayouts: (ct as any).valid_layouts, frontendFiles: (ct as any).frontend_files, frontendEntryRel: (ct as any).frontend_entry_rel, publicAssetUrls: (ct as any).public_asset_urls, previewImageUrl: ct.preview_image_url, logoUrls: (ct as any).logo_urls, ogImage: (ct as any).og_image, showLoaderOnEmptyOrError: false, thumbnailFrame: 135, thumbnailMode: true, staticThumb } as any)} key={`${customId}-${step}`} />
)}
</div>
<div className="absolute top-0 left-0.5 z-[5]">
{item.type === "custom" ? <CustomTemplateBadge /> : (
<span className="pointer-events-none px-1.5 py-0.5 rounded text-[8px] font-bold uppercase tracking-wide text-white bg-amber-500 ring-1 ring-amber-300">
Designer
</span>
)}
</div>
{isSelected && (
<div className="absolute top-1.5 right-1.5 z-20 w-4 h-4 rounded-full bg-purple-600 flex items-center justify-center shadow-md ring-2 ring-white">
<svg className="w-2.5 h-2.5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
</div>
)}
</div>
<div className={`px-2 py-1 transition-colors ${isSelected ? "bg-purple-50/80" : "bg-white/80"}`}>
<div className="text-[10px] font-semibold text-gray-800 truncate">
{ct.name}
</div>
</div>
</div>
);
}
const t = item.data;
const PreviewComp = TEMPLATE_PREVIEWS[t.id];
const desc = TEMPLATE_DESCRIPTIONS[t.id];
const isSelected = template === t.id;
const isNewTemplate = t.new_template === true;
const isPopularTemplate = t.popular_template === true;
return (
<div
key={t.id}
onClick={() => applyTemplate(t.id)}
className={`relative rounded-lg overflow-hidden cursor-pointer transition-all group ${
isSelected
? "border-2 border-purple-500 shadow-[0_0_0_3px_rgba(124,58,237,0.1)]"
: isNewTemplate
? "border border-purple-500 shadow-[0_0_0_2px_rgba(124,58,237,0.2)] hover:border-purple-600"
: isPopularTemplate
? "border border-amber-400/60 shadow-[0_0_0_2px_rgba(245,158,11,0.15)] hover:border-amber-500"
: "border-2 border-gray-200/60 hover:border-purple-300/60"
}`}
>
<div className="relative h-[70px] overflow-hidden">
{demoMode?.templatePreviewOverride ? (
demoMode.templatePreviewOverride({ templateId: t.id, selected: isSelected, thumbnail: true })
) : PreviewComp ? (
<PreviewComp key={`${t.id}-${step}`} thumbnailMode />
) : (
<div className="w-full h-full min-h-[56px] bg-gray-100 flex items-center justify-center text-gray-300 text-[10px]">
{desc?.title ?? t.name}
</div>
)}
{isSelected && (
<div className="absolute top-1.5 right-1.5 w-4 h-4 rounded-full bg-purple-600 flex items-center justify-center shadow-sm">
<svg className="w-2.5 h-2.5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
</div>
)}
{isNewTemplate && (
<div className="absolute top-0.5 left-0.5 z-[1]">
<NewTemplateBadge />
</div>
)}
{!isNewTemplate && isPopularTemplate && (
<div className="absolute top-0.5 left-0.5 z-[1]">
<PopularTemplateBadge />
</div>
)}
</div>
<div className={`px-2 py-1 transition-colors ${isSelected ? "bg-purple-50/80" : "bg-white/80"}`}>
<div className="text-[10px] font-semibold text-gray-800 truncate">
{desc?.title ?? t.name}
</div>
</div>
</div>
);
})}
{customTemplatesLoading && (
<div
className="rounded-lg border border-dashed border-gray-200/80 bg-white/70 flex flex-col items-center justify-center gap-2 min-h-[88px] px-2 py-3 text-center"
role="status"
aria-live="polite"
>
<span className="w-4 h-4 border-2 border-purple-200 border-t-purple-600 rounded-full animate-spin shrink-0" aria-hidden />
<p className="text-[10px] text-gray-500 leading-snug">
Loading custom templates, please wait.
</p>
</div>
)}
{craftedTemplatesLoading && (
<div
className="rounded-lg border border-dashed border-gray-200/80 bg-white/70 flex flex-col items-center justify-center gap-2 min-h-[88px] px-2 py-3 text-center"
role="status"
aria-live="polite"
>
<span className="w-4 h-4 border-2 border-amber-200 border-t-amber-500 rounded-full animate-spin shrink-0" aria-hidden />
<p className="text-[10px] text-gray-500 leading-snug">
Loading designer templates, please wait.
</p>
</div>
)}
</div>
{styleTemplateItems.length === 0 && (
<p className="text-xs text-gray-500 py-3 text-center">
No templates for this genre. Pick another genre above or add a custom template.
</p>
)}
</>
</div>
{/* Video Style picker — orthogonal to genre. "Auto" lets the AI choose after scraping. */}
<div className="mt-4">
<div className="flex items-center justify-between mb-2">
<label className="block text-[11px] font-medium text-gray-400 uppercase tracking-wider">
Video Style
</label>
<div className="flex flex-wrap items-center gap-1 p-1 bg-gray-100/60 rounded-xl justify-center">
{VIDEO_STYLES.map((s) => {
const isSelected = videoStyle === s.id;
return (
<button
key={s.id}
type="button"
onClick={() => {
styleManuallySet.current = true;
setVideoStyle(s.id);
}}
className={`px-3 py-1.5 rounded-lg text-[11px] font-medium transition-all ${
isSelected ? "bg-white text-purple-600 shadow-sm" : "text-gray-400 hover:text-gray-600"
}`}
>
{s.label}
</button>
);
})}
</div>
</div>
</div>
</div>
{/* Video colors */}
<div>
<label className="block text-[11px] font-medium text-gray-400 mb-2 uppercase tracking-wider">
Video Colors
</label>
<div className="flex items-center gap-3 sm:gap-5">
{[
{ label: "Accent", value: accentColor, setter: setAccentColor },
{ label: "Background", value: bgColor, setter: setBgColor },
{ label: "Text", value: textColor, setter: setTextColor },
].map(({ label, value, setter }) => (
<label key={label} className="flex items-center gap-2 cursor-pointer group">
<span
className="w-8 h-8 rounded-full border-2 border-gray-200 group-hover:border-gray-400 transition-all shadow-sm relative overflow-hidden"
style={{ backgroundColor: value }}
>
<input
type="color"
value={value}
onChange={(e) => setter(e.target.value)}
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full"
/>
</span>
<span className="text-[10px] text-gray-400 font-medium">{label}</span>
</label>
))}
</div>
</div>
<div className="flex gap-2 pt-1">
<button
type="button"
onClick={goBack}
className="px-5 py-3 text-sm font-medium text-gray-500 bg-gray-50 hover:bg-gray-100 rounded-xl transition-colors border border-gray-200/60"
>
Back
</button>
<button
type="button"
onClick={goNext}
className="flex-1 py-3 bg-purple-600 hover:bg-purple-700 text-white text-sm font-medium rounded-xl transition-colors flex items-center justify-center gap-2"
>
Go to step 3
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
);
// ─── Step 2 bulk: template per project (tabbed; same UI as single) ─────────
const step2BulkTemplate = (() => {
const indexed = bulkRows
.map((row, i) => ({ row, i }))
.filter(({ row }) => row.url.trim());
if (indexed.length === 0) {
return (
<div className="space-y-5">
<p className="text-sm text-gray-500">Please go back to step 1 to continue again.</p>
<div className="flex gap-2 pt-1">
<button
type="button"
onClick={goBack}
className="px-5 py-3 text-sm font-medium text-gray-500 bg-gray-50 hover:bg-gray-100 rounded-xl transition-colors border border-gray-200/60"
>
Back
</button>
</div>
</div>
);
}
const active = Math.min(bulkActiveIndex, indexed.length - 1);
const activeIndex = indexed[active].i;
const activeRow = indexed[active].row;
const masterIndex =
indexed.find(({ i }) => i === bulkTemplateMasterIndex)?.i ?? indexed[0].i;
const tpl = bulkTemplates[activeIndex] ?? "default";
const templateMeta = allTemplates.find((t) => t.id === tpl);
const selectedCustomBulk = tpl.startsWith("custom_")
? customTemplates.find((ct) => ct.id === parseInt(tpl.replace("custom_", "")))
: null;
const selectedCraftedBulk = tpl.startsWith("crafted_")
? craftedTemplates.find((ct) => ct.id === tpl)
: null;
const selectedBuiltinNewBulk = !tpl.startsWith("custom_") && templateMeta?.new_template === true;
const defaultAccent =
selectedCustomBulk?.preview_colors?.accent
?? selectedCraftedBulk?.preview_colors?.accent
?? templateMeta?.preview_colors?.accent
?? accentColor;
const defaultBg =
selectedCustomBulk?.preview_colors?.bg
?? selectedCraftedBulk?.preview_colors?.bg
?? templateMeta?.preview_colors?.bg
?? bgColor;
const defaultText =
selectedCustomBulk?.preview_colors?.text
?? selectedCraftedBulk?.preview_colors?.text
?? templateMeta?.preview_colors?.text
?? textColor;
const accent =
bulkAccentColors[activeIndex] && bulkAccentColors[activeIndex].trim()
? bulkAccentColors[activeIndex]
: defaultAccent;
const bg =
bulkBgColors[activeIndex] && bulkBgColors[activeIndex].trim()
? bulkBgColors[activeIndex]
: defaultBg;
const text =
bulkTextColors[activeIndex] && bulkTextColors[activeIndex].trim()
? bulkTextColors[activeIndex]
: defaultText;
const activeVideoStyle = bulkVideoStyles[activeIndex] ?? DEFAULT_VIDEO_STYLE;
const rowVideoLength = bulkVideoLength[activeIndex] ?? "short";
const applyTemplateToAll = () => {
const targetIndices = indexed.map(({ i }) => i);
// Template + video style + colors
setBulkTemplates((prev) => {
const next = [...prev];
targetIndices.forEach((idx) => {
next[idx] = tpl;
});
return next;
});
setBulkVideoStyles((prev) => {
const next = [...prev];
targetIndices.forEach((idx) => {
next[idx] = activeVideoStyle || DEFAULT_VIDEO_STYLE;
});
return next;
});
setBulkAccentColors((prev) => {
const next = [...prev];
targetIndices.forEach((idx) => {
next[idx] = accent;
});
return next;
});
setBulkBgColors((prev) => {
const next = [...prev];
targetIndices.forEach((idx) => {
next[idx] = bg;
});
return next;
});
setBulkTextColors((prev) => {
const next = [...prev];
targetIndices.forEach((idx) => {
next[idx] = text;
});
return next;
});
setBulkVideoLength((prev) => {
const next = [...prev];
targetIndices.forEach((idx) => {
next[idx] = rowVideoLength;
});
return next;
});
// Logo (file, position, opacity)
setBulkLogoFile((prev) => {
const next = [...prev];
targetIndices.forEach((idx) => {
next[idx] = bulkLogoFile[activeIndex] ?? null;
});
return next;
});
setBulkLogoPosition((prev) => {
const next = [...prev];
targetIndices.forEach((idx) => {
next[idx] = bulkLogoPosition[activeIndex] ?? "bottom_right";
});
return next;
});
setBulkLogoOpacity((prev) => {
const next = [...prev];
targetIndices.forEach((idx) => {
next[idx] = bulkLogoOpacity[activeIndex] ?? 0.9;
});
return next;
});
};
const applyBulkTemplate = (id: string) => {
templateManuallySelectedRef.current = true;
const colors = id.startsWith("custom_")
? customTemplates.find((t) => t.id === parseInt(id.replace("custom_", "")))?.preview_colors
: id.startsWith("crafted_")
? craftedTemplates.find((t) => t.id === id)?.preview_colors
: allTemplates.find((t) => t.id === id)?.preview_colors;
const targetIndices = indexed.map(({ i }) => i);
if (bulkApplyTemplateAll && activeIndex !== masterIndex) {
setBulkApplyTemplateAll(false);
setBulkTemplates((prev) => {
const next = [...prev];
next[activeIndex] = id;
return next;
});
if (colors) {
setBulkAccentColors((prev) => { const next = [...prev]; next[activeIndex] = colors.accent; return next; });
setBulkBgColors((prev) => { const next = [...prev]; next[activeIndex] = colors.bg; return next; });
setBulkTextColors((prev) => { const next = [...prev]; next[activeIndex] = colors.text; return next; });
}
return;
}
if (bulkApplyTemplateAll && activeIndex === masterIndex) {
setBulkTemplates((prev) => {
const next = [...prev];
targetIndices.forEach((idx) => { next[idx] = id; });
return next;
});
if (colors) {
setBulkAccentColors((prev) => {
const next = [...prev];
targetIndices.forEach((idx) => { next[idx] = colors.accent; });
return next;
});
setBulkBgColors((prev) => {
const next = [...prev];
targetIndices.forEach((idx) => { next[idx] = colors.bg; });
return next;
});
setBulkTextColors((prev) => {
const next = [...prev];
targetIndices.forEach((idx) => { next[idx] = colors.text; });
return next;
});
}
return;
}
setBulkTemplates((prev) => {
const next = [...prev];
next[activeIndex] = id;
return next;
});
if (colors) {
setBulkAccentColors((prev) => { const next = [...prev]; next[activeIndex] = colors.accent; return next; });
setBulkBgColors((prev) => { const next = [...prev]; next[activeIndex] = colors.bg; return next; });
setBulkTextColors((prev) => { const next = [...prev]; next[activeIndex] = colors.text; return next; });
}
};
const targetIndicesForTemplate = indexed.map(({ i }) => i);
const setBulkColor = (kind: "accent" | "bg" | "text", v: string) => {
if (bulkApplyTemplateAll && activeIndex !== masterIndex) {
setBulkApplyTemplateAll(false);
if (kind === "accent") setBulkAccentColors((prev) => { const n = [...prev]; n[activeIndex] = v; return n; });
if (kind === "bg") setBulkBgColors((prev) => { const n = [...prev]; n[activeIndex] = v; return n; });
if (kind === "text") setBulkTextColors((prev) => { const n = [...prev]; n[activeIndex] = v; return n; });
return;
}
if (bulkApplyTemplateAll && activeIndex === masterIndex) {
if (kind === "accent") setBulkAccentColors((prev) => { const n = [...prev]; targetIndicesForTemplate.forEach((idx) => { n[idx] = v; }); return n; });
if (kind === "bg") setBulkBgColors((prev) => { const n = [...prev]; targetIndicesForTemplate.forEach((idx) => { n[idx] = v; }); return n; });
if (kind === "text") setBulkTextColors((prev) => { const n = [...prev]; targetIndicesForTemplate.forEach((idx) => { n[idx] = v; }); return n; });
return;
}
if (kind === "accent") setBulkAccentColors((prev) => { const n = [...prev]; n[activeIndex] = v; return n; });
if (kind === "bg") setBulkBgColors((prev) => { const n = [...prev]; n[activeIndex] = v; return n; });
if (kind === "text") setBulkTextColors((prev) => { const n = [...prev]; n[activeIndex] = v; return n; });
};
const SelectedPreviewComp = TEMPLATE_PREVIEWS[tpl];
const selectedDesc = TEMPLATE_DESCRIPTIONS[tpl];
const {
suggestedTemplates,
customTemplatesForStyle,
craftedTemplatesForStyle,
} = templateBucketsForGenre(genre, templates, readyCustomTemplates, readyCraftedTemplates);
const sortedBulkSuggestedTemplates = [...suggestedTemplates].sort((a, b) => {
const rank = (t: TemplateMeta) => (t.new_template ? 0 : t.popular_template ? 1 : 2);
return rank(a) - rank(b);
});
const styleTemplateItems: Array<
| { type: "builtin"; id: string; data: TemplateMeta }
| { type: "custom"; id: string; data: CustomTemplateItem }
| { type: "crafted"; id: string; data: CraftedTemplateItem }
> = [
...sortedBulkSuggestedTemplates.map((t) => ({ type: "builtin" as const, id: t.id, data: t })),
...customTemplatesForStyle.map((ct) => ({ type: "custom" as const, id: `custom_${ct.id}`, data: ct })),
...craftedTemplatesForStyle.map((ct) => ({ type: "crafted" as const, id: ct.id, data: ct })),
];
return (
<div className="space-y-5">
{/* Bulk logo picker (step 2) */}
<input
ref={bulkLogoInputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/svg+xml"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0] || null;
e.target.value = "";
if (bulkLogoRowIndex === null) return;
if (f && f.size > 2 * 1024 * 1024) {
showError("Logo must be under 2 MB.");
return;
}
const targetIndices = indexed.map(({ i }) => i);
if (bulkApplyTemplateAll && bulkLogoRowIndex !== masterIndex) {
setBulkApplyTemplateAll(false);
setBulkLogoFile((prev) => {
const n = [...prev];
n[bulkLogoRowIndex] = f;
return n;
});
setBulkLogoRowIndex(null);
return;
}
if (bulkApplyTemplateAll && bulkLogoRowIndex === masterIndex) {
setBulkLogoFile((prev) => {
const n = [...prev];
targetIndices.forEach((idx) => { n[idx] = f; });
return n;
});
setBulkLogoRowIndex(null);
return;
}
setBulkLogoFile((prev) => {
const n = [...prev];
n[bulkLogoRowIndex] = f;
return n;
});
setBulkLogoRowIndex(null);
}}
/>
{/* Tabs for each bulk project — match Link/Upload/Multi Link tabs */}
<div className="flex flex-wrap gap-1 mb-2">
<div className="flex flex-wrap gap-1 p-1 bg-gray-100/60 rounded-xl">
{indexed.map(({ row, i }, tabIdx) => (
<button
key={i}
type="button"
onClick={() => setBulkActiveIndex(tabIdx)}
className={`px-3 py-1.5 rounded-lg text-[11px] font-medium transition-all ${
tabIdx === active
? "bg-white text-purple-600 shadow-sm"
: "text-gray-400 hover:text-gray-600"
}`}
title={row.url.trim() || undefined}
>
Video #{tabIdx + 1}
</button>
))}
</div>
</div>
{/* Apply template, colors & logo to all */}
<div className="flex items-center justify-start ml-1 mb-1">
<label className="flex items-center gap-2 text-[11px] text-gray-500 cursor-pointer select-none">
<input
type="checkbox"
checked={bulkApplyTemplateAll}
onChange={(e) => {
const checked = e.target.checked;
setBulkApplyTemplateAll(checked);
if (checked) {
setBulkTemplateMasterIndex(activeIndex);
applyTemplateToAll();
}
}}
className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500/30 cursor-pointer accent-purple-600"
/>
<span className="font-medium">Apply template, colors & logo to all videos</span>
</label>
</div>
{/* Selected template — full-width preview (same UI as single) */}
<div>
<label className="block text-[11px] font-medium text-gray-400 mb-2 uppercase tracking-wider">
Selected Template
</label>
<div className="rounded-xl overflow-hidden border-2 border-purple-500 shadow-[0_0_0_4px_rgba(124,58,237,0.1)]">
<div className="relative aspect-video overflow-hidden">
{selectedCustomBulk ? (
<CustomPreview theme={selectedCustomBulk.theme} name={selectedCustomBulk.name} previewImageUrl={selectedCustomBulk.preview_image_url} introCode={selectedCustomBulk.intro_code || undefined} outroCode={selectedCustomBulk.outro_code || undefined} contentCodes={selectedCustomBulk.content_codes || undefined} contentArchetypeIds={selectedCustomBulk.content_archetype_ids || undefined} logoUrls={selectedCustomBulk.logo_urls} ogImage={selectedCustomBulk.og_image} thumbnailMode={isMobile} staticThumb={isMobile} key={`selected-bulk-custom-${tpl}-${activeIndex}-${step}`} />
) : selectedCraftedBulk && ((selectedCraftedBulk as any).frontend_files || selectedCraftedBulk.preview_file || selectedCraftedBulk.preview_image_url || selectedCraftedBulk.theme) ? (
<CraftedTemplatePreviewSmart
item={selectedCraftedBulk}
compileCacheScope={user?.id != null ? String(user.id) : undefined}
showLoaderOnEmptyOrError
thumbnailMode={isMobile}
staticThumb={isMobile}
key={`selected-bulk-crafted-${tpl}-${activeIndex}-${step}`}
/>
) : tpl.startsWith("crafted_") && craftedTemplatesLoading ? (
<div className="w-full aspect-video bg-[#1a1a2e] flex items-center justify-center">
<span className="w-7 h-7 border-2 border-purple-200/50 border-t-purple-500 rounded-full animate-spin" />
</div>
) : SelectedPreviewComp ? (
<SelectedPreviewComp thumbnailMode={isMobile} key={`selected-bulk-${tpl}-${activeIndex}-${step}`} />
) : (
<div className="w-full aspect-video bg-gray-100 flex items-center justify-center text-gray-300 text-sm">
{selectedDesc?.title ?? tpl}
</div>
)}
</div>
<div className="px-4 py-2.5 bg-purple-50/80 flex items-center justify-between">
<div>
<div className="flex items-center gap-2">
<div className="text-sm font-semibold text-gray-800">{selectedCustomBulk ? selectedCustomBulk.name : selectedCraftedBulk ? selectedCraftedBulk.name : (selectedDesc?.title ?? tpl)}</div>
{selectedBuiltinNewBulk && <NewTemplateBadge className="shrink-0" />}
{selectedCustomBulk && (
<span className="px-1.5 py-0.5 rounded text-[9px] font-bold text-white" style={{ backgroundColor: selectedCustomBulk.preview_colors.accent }}>
Custom
</span>
)}
{selectedCraftedBulk && (
<span className="px-1.5 py-0.5 rounded text-[9px] font-bold text-white bg-amber-500">
Designer
</span>
)}
</div>
{selectedCustomBulk ? (
<div className="text-[11px] text-gray-400 mt-0.5">Custom template</div>
) : selectedCraftedBulk ? (
<div className="text-[11px] text-gray-400 mt-0.5">Designer template</div>
) : selectedDesc?.subtitle ? (
<div className="text-[11px] text-gray-400 mt-0.5">{selectedDesc.subtitle}</div>
) : null}
</div>
<div className="w-6 h-6 rounded-full bg-purple-600 flex items-center justify-center flex-shrink-0">
<svg className="w-3.5 h-3.5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
</div>
</div>
</div>
</div>
{/* Genre filter — same `genre` state as single-link step 2 */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-[11px] font-medium text-gray-400 uppercase tracking-wider">
Genre
</label>
<div className="relative">
<button
type="button"
onClick={() => setGenreDropdownOpen((v) => !v)}
className="flex items-center gap-2 px-3 py-1.5 rounded-xl bg-gray-100/60 hover:bg-gray-200/70 text-[11px] font-medium text-gray-700 transition-all min-w-[140px] justify-between"
>
<span>
{genre === GENRE_CUSTOM
? "Custom Templates"
: genre === GENRE_CRAFTED
? "Designer"
: genre === GENRE_NEW
? "New"
: genre === GENRE_POPULAR
? "Popular"
: genre || "All genres"}
</span>
<svg
className={`w-3 h-3 text-gray-500 transition-transform ${genreDropdownOpen ? "rotate-180" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{genreDropdownOpen && (
<>
<div className="fixed inset-0 z-10" onClick={() => setGenreDropdownOpen(false)} />
<div className="absolute right-0 top-full mt-1 z-20 bg-white rounded-xl shadow-lg border border-gray-200 py-1 min-w-[200px] max-h-[300px] overflow-y-auto">
<button
type="button"
onClick={() => {
setGenre("");
setGenreDropdownOpen(false);
}}
className={`w-full text-left px-3 py-1.5 text-[11px] font-medium transition-colors ${
genre === "" ? "bg-purple-50 text-purple-600" : "text-gray-700 hover:bg-gray-50"
}`}
>
All genres
</button>
<div className="my-1 border-t border-gray-100" />
<button
type="button"
onClick={() => {
setGenre(GENRE_NEW);
setGenreDropdownOpen(false);
}}
className={`w-full text-left px-3 py-1.5 text-[11px] font-medium transition-colors ${
genre === GENRE_NEW ? "bg-purple-50 text-purple-600" : "text-gray-700 hover:bg-gray-50"
}`}
>
✦ New
</button>
<button
type="button"
onClick={() => {
setGenre(GENRE_POPULAR);
setGenreDropdownOpen(false);
}}
className={`w-full text-left px-3 py-1.5 text-[11px] font-medium transition-colors ${
genre === GENRE_POPULAR ? "bg-amber-50 text-amber-600" : "text-gray-700 hover:bg-gray-50"
}`}
>
Popular
</button>
<button
type="button"
onClick={() => {
setGenre(GENRE_CUSTOM);
setGenreDropdownOpen(false);
}}
className={`w-full text-left px-3 py-1.5 text-[11px] font-medium transition-colors ${
genre === GENRE_CUSTOM ? "bg-purple-50 text-purple-600" : "text-gray-700 hover:bg-gray-50"
}`}
>
Custom Templates
</button>
<button
type="button"
onClick={() => {
setGenre(GENRE_CRAFTED);
setGenreDropdownOpen(false);
}}
className={`w-full text-left px-3 py-1.5 text-[11px] font-medium transition-colors ${
genre === GENRE_CRAFTED ? "bg-purple-50 text-purple-600" : "text-gray-700 hover:bg-gray-50"
}`}
>
Designer Templates
</button>
{allGenres.length > 0 && <div className="my-1 border-t border-gray-100" />}
{allGenres.map((g) => (
<button
key={g}
type="button"
onClick={() => {
setGenre(g);
setGenreDropdownOpen(false);
}}
className={`w-full text-left px-3 py-1.5 text-[11px] font-medium transition-colors ${
genre === g ? "bg-purple-50 text-purple-600" : "text-gray-700 hover:bg-gray-50"
}`}
>
{g}
</button>
))}
</div>
</>
)}
</div>
</div>
</div>
<p className="text-[10px] text-gray-500 mb-1.5 font-medium">
{genreTemplateListCaption(genre)}
</p>
{/* Template thumbnails — filtered by genre; video style below is orthogonal.
Height matches the single-link grid: 2 columns make the cards taller,
so mobile needs the extra room. */}
<div className="border border-gray-200/60 rounded-xl p-2.5 max-h-[260px] sm:max-h-[220px] overflow-y-auto bg-gray-50/40">
<>
{/* Same responsive columns as the single-link template grid. */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
<CraftYourTemplateCard
variant="compact"
isPro={isPro}
onClick={() => {
setShowGetMoreTemplates(true);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setShowGetMoreTemplates(true);
}
}}
/>
{styleTemplateItems.map((item) => {
if (item.type === "custom" || item.type === "crafted") {
const ct = item.data;
const customId = item.id;
const isSelected = tpl === customId;
// Mobile: every grid tile renders a static themed placeholder
// and never mounts a Player — the selected template plays only
// in the full-width "Selected Template" preview above. A grid of
// live Players exhausts iOS Safari's memory and reloads the tab.
const staticThumb = isMobile;
return (
<div
key={customId}
onClick={() => applyBulkTemplate(customId)}
className={`relative rounded-lg border-2 overflow-hidden cursor-pointer transition-all group ${
isSelected
? "border-purple-500 shadow-[0_0_0_3px_rgba(124,58,237,0.1)]"
: "border-gray-200/60 hover:border-purple-300/60"
}`}
>
<div className="relative isolate h-[70px] overflow-hidden">
<div className="relative z-0 h-full">
{item.type === "crafted" ? (
<CraftedTemplatePreviewSmart
item={ct as any}
compileCacheScope={user?.id != null ? String(user.id) : undefined}
thumbnailMode
showLoaderOnEmptyOrError
staticThumb={staticThumb}
key={`${customId}-bulk-${activeIndex}`}
/>
) : (
<CustomPreviewLandscape {...({ theme: ct.theme, name: ct.name, introCode: ct.intro_code || undefined, outroCode: ct.outro_code || undefined, contentCodes: ct.content_codes || undefined, contentArchetypeIds: ct.content_archetype_ids || undefined, validLayouts: (ct as any).valid_layouts, frontendFiles: (ct as any).frontend_files, frontendEntryRel: (ct as any).frontend_entry_rel, publicAssetUrls: (ct as any).public_asset_urls, previewImageUrl: ct.preview_image_url, logoUrls: (ct as any).logo_urls, ogImage: (ct as any).og_image, showLoaderOnEmptyOrError: false, thumbnailFrame: 135, thumbnailMode: true, staticThumb } as any)} key={`${customId}-bulk-${activeIndex}`} />
)}
</div>
<div className="absolute top-0.5 left-0.5 z-[5]">
{item.type === "custom" ? <CustomTemplateBadge /> : (
<span className="pointer-events-none px-1.5 py-0.5 rounded text-[8px] font-bold uppercase tracking-wide text-white bg-amber-500 ring-1 ring-amber-300">
Designer
</span>
)}
</div>
{isSelected && (
<div className="absolute top-1.5 right-1.5 z-20 w-4 h-4 rounded-full bg-purple-600 flex items-center justify-center shadow-md ring-2 ring-white">
<svg className="w-2.5 h-2.5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
</div>
)}
</div>
<div className={`px-2 py-1 transition-colors ${isSelected ? "bg-purple-50/80" : "bg-white/80"}`}>
<div className="text-[10px] font-semibold text-gray-800 truncate">
{ct.name}
</div>
</div>
</div>
);
}
const t = item.data;
const PreviewComp = TEMPLATE_PREVIEWS[t.id];
const desc = TEMPLATE_DESCRIPTIONS[t.id];
const isSelected = tpl === t.id;
const isNewTemplate = t.new_template === true;
const isPopularTemplate = t.popular_template === true;
return (
<div
key={t.id}
onClick={() => applyBulkTemplate(t.id)}
className={`relative rounded-lg overflow-hidden cursor-pointer transition-all group ${
isSelected
? "border-2 border-purple-500 shadow-[0_0_0_3px_rgba(124,58,237,0.1)]"
: isNewTemplate
? "border border-purple-500 shadow-[0_0_0_2px_rgba(124,58,237,0.2)] hover:border-purple-600"
: isPopularTemplate
? "border border-amber-400/60 shadow-[0_0_0_2px_rgba(245,158,11,0.15)] hover:border-amber-500"
: "border-2 border-gray-200/60 hover:border-purple-300/60"
}`}
>
<div className="relative h-[70px] overflow-hidden">
{PreviewComp ? (
<PreviewComp key={`${t.id}-bulk-${activeIndex}`} thumbnailMode />
) : (
<div className="w-full h-full min-h-[56px] bg-gray-100 flex items-center justify-center text-gray-300 text-[10px]">
{desc?.title ?? t.name}
</div>
)}
{isSelected && (
<div className="absolute top-1.5 right-1.5 w-4 h-4 rounded-full bg-purple-600 flex items-center justify-center shadow-sm">
<svg className="w-2.5 h-2.5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
</div>
)}
{isNewTemplate && (
<div className="absolute top-0.5 left-0.5 z-[1]">
<NewTemplateBadge />
</div>
)}
{!isNewTemplate && isPopularTemplate && (
<div className="absolute top-0.5 left-0.5 z-[1]">
<PopularTemplateBadge />
</div>
)}
</div>
<div className={`px-2 py-1 transition-colors ${isSelected ? "bg-purple-50/80" : "bg-white/80"}`}>
<div className="text-[10px] font-semibold text-gray-800 truncate">
{desc?.title ?? t.name}
</div>
</div>
</div>
);
})}
{customTemplatesLoading && (
<div
className="rounded-lg border border-dashed border-gray-200/80 bg-white/70 flex flex-col items-center justify-center gap-2 min-h-[88px] px-2 py-3 text-center"
role="status"
aria-live="polite"
>
<span className="w-4 h-4 border-2 border-purple-200 border-t-purple-600 rounded-full animate-spin shrink-0" aria-hidden />
<p className="text-[10px] text-gray-500 leading-snug">
Loading the custom template, please wait.
</p>
</div>
)}
{craftedTemplatesLoading && (
<div
className="rounded-lg border border-dashed border-gray-200/80 bg-white/70 flex flex-col items-center justify-center gap-2 min-h-[88px] px-2 py-3 text-center"
role="status"
aria-live="polite"
>
<span className="w-4 h-4 border-2 border-amber-200 border-t-amber-500 rounded-full animate-spin shrink-0" aria-hidden />
<p className="text-[10px] text-gray-500 leading-snug">
Loading designer templates, please wait.
</p>
</div>
)}
</div>
{styleTemplateItems.length === 0 && (
<p className="text-xs text-gray-500 py-3 text-center">
No templates for this genre. Pick another genre above or add a custom template.
</p>
)}
</>
</div>
{/* Video Style — orthogonal to genre (same behavior as single-link step 2) */}
<div className="mt-4">
<div className="flex items-center justify-between mb-2">
<label className="block text-[11px] font-medium text-gray-400 uppercase tracking-wider">
Video Style
</label>
{/* Same wrapping/alignment as the single-link Video Style row. */}
<div className="flex flex-wrap items-center gap-1 p-1 bg-gray-100/60 rounded-xl justify-center">
{VIDEO_STYLES.map((s) => {
const isSelected = activeVideoStyle === s.id;
return (
<button
key={s.id}
type="button"
onClick={() => {
const targetIndices = indexed.map(({ i }) => i);
if (bulkApplyTemplateAll && activeIndex !== masterIndex) {
setBulkApplyTemplateAll(false);
bulkStyleManuallySet.current[activeIndex] = true;
setBulkVideoStyles((prev) => {
const next = [...prev];
next[activeIndex] = s.id;
return next;
});
return;
}
if (bulkApplyTemplateAll && activeIndex === masterIndex) {
targetIndices.forEach((idx) => { bulkStyleManuallySet.current[idx] = true; });
setBulkVideoStyles((prev) => {
const next = [...prev];
targetIndices.forEach((idx) => { next[idx] = s.id; });
return next;
});
return;
}
bulkStyleManuallySet.current[activeIndex] = true;
setBulkVideoStyles((prev) => {
const next = [...prev];
next[activeIndex] = s.id;
return next;
});
}}
className={`px-3 py-1.5 rounded-lg text-[11px] font-medium transition-all ${
isSelected ? "bg-white text-purple-600 shadow-sm" : "text-gray-400 hover:text-gray-600"
}`}
>
{s.label}
</button>
);
})}
</div>
</div>
</div>
{/* Video colors (same UI as single) + Logo (bulk-only extra, placed to the right) */}
<div className="flex flex-col gap-5">
<div className="min-w-0">
{/* Same label + swatch alignment as the single-link Video Colors row. */}
<label className="block text-[11px] font-medium text-gray-400 mb-2 uppercase tracking-wider">
Video Colors
</label>
<div className="flex items-center gap-3 sm:gap-5">
{[
{ label: "Accent", value: accent, setter: (v: string) => setBulkColor("accent", v) },
{ label: "Background", value: bg, setter: (v: string) => setBulkColor("bg", v) },
{ label: "Text", value: text, setter: (v: string) => setBulkColor("text", v) },
].map(({ label, value, setter }) => (
<label key={label} className="flex items-center gap-2 cursor-pointer group">
<span
className="w-8 h-8 rounded-full border-2 border-gray-200 group-hover:border-gray-400 transition-all shadow-sm relative overflow-hidden"
style={{ backgroundColor: value }}
>
<input
type="color"
value={value}
onChange={(e) => setter(e.target.value)}
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full"
/>
</span>
<span className="text-[10px] text-gray-400 font-medium">{label}</span>
</label>
))}
</div>
</div>
<div className="w-full">
<label className="block text-[11px] font-medium text-gray-400 mb-2 uppercase tracking-wider">
Logo <span className="text-gray-300 font-normal">(optional · max 2 MB)</span>
</label>
<div className="flex items-center gap-3">
<div className="relative mb-4 inline-block">
<button
type="button"
onClick={() => { setBulkLogoRowIndex(activeIndex); bulkLogoInputRef.current?.click(); }}
className="px-3 py-2 rounded-lg text-xs font-medium bg-gray-50 text-gray-500 hover:bg-gray-100 border border-gray-200/60 transition-all pr-8"
>
{bulkLogoFile[activeIndex] ? bulkLogoFile[activeIndex]!.name : "Choose file"}
</button>
{bulkLogoFile[activeIndex] && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
const targetIndices = indexed.map(({ i }) => i);
if (bulkApplyTemplateAll && activeIndex !== masterIndex) {
setBulkApplyTemplateAll(false);
setBulkLogoFile((prev) => { const n = [...prev]; n[activeIndex] = null; return n; });
return;
}
if (bulkApplyTemplateAll && activeIndex === masterIndex) {
setBulkLogoFile((prev) => {
const n = [...prev];
targetIndices.forEach((idx) => { n[idx] = null; });
return n;
});
return;
}
setBulkLogoFile((prev) => { const n = [...prev]; n[activeIndex] = null; return n; });
}}
className="absolute top-0.5 right-0.5 w-6 h-6 rounded-full border border-purple-500/80 text-purple-600 hover:bg-purple-600 hover:text-white hover:border-purple-600 flex items-center justify-center transition-colors"
title="Remove logo"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" strokeWidth={2.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
</div>
{bulkLogoFile[activeIndex] && (
<div className="mt-2">
<label className="block text-[10px] text-gray-400 mb-1">Position</label>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-1.5">
{([
{ value: "top_left", label: "Top Left" },
{ value: "top_right", label: "Top Right" },
{ value: "bottom_left", label: "Bottom Left" },
{ value: "bottom_right", label: "Bottom Right" },
] as const).map((pos) => (
<button
key={pos.value}
type="button"
onClick={() => {
const targetIndices = indexed.map(({ i }) => i);
if (bulkApplyTemplateAll && activeIndex !== masterIndex) {
setBulkApplyTemplateAll(false);
setBulkLogoPosition((prev) => { const n = [...prev]; n[activeIndex] = pos.value; return n; });
return;
}
if (bulkApplyTemplateAll && activeIndex === masterIndex) {
setBulkLogoPosition((prev) => {
const n = [...prev];
targetIndices.forEach((idx) => { n[idx] = pos.value; });
return n;
});
return;
}
setBulkLogoPosition((prev) => { const n = [...prev]; n[activeIndex] = pos.value; return n; });
}}
className={`px-2 py-1 rounded text-[10px] font-medium transition-all ${
(bulkLogoPosition[activeIndex] ?? "bottom_right") === pos.value
? "bg-purple-600 text-white"
: "bg-gray-50 text-gray-400 hover:bg-gray-100 border border-gray-200/60"
}`}
>
{pos.label}
</button>
))}
</div>
<div className="mt-2.5 mb-3.5">
<label className="block text-[10px] text-gray-500 mb-1">
Opacity <span className="text-gray-500">{Math.round((bulkLogoOpacity[activeIndex] ?? 0.9) * 100)}%</span>
</label>
<input
type="range"
min={10}
max={100}
step={5}
value={Math.round((bulkLogoOpacity[activeIndex] ?? 0.9) * 100)}
onChange={(e) => {
const val = parseInt(e.target.value, 10) / 100;
const targetIndices = indexed.map(({ i }) => i);
if (bulkApplyTemplateAll && activeIndex !== masterIndex) {
setBulkApplyTemplateAll(false);
setBulkLogoOpacity((prev) => { const n = [...prev]; n[activeIndex] = val; return n; });
return;
}
if (bulkApplyTemplateAll && activeIndex === masterIndex) {
setBulkLogoOpacity((prev) => {
const n = [...prev];
targetIndices.forEach((idx) => { n[idx] = val; });
return n;
});
return;
}
setBulkLogoOpacity((prev) => { const n = [...prev]; n[activeIndex] = val; return n; });
}}
className="w-full h-1 bg-gray-300 rounded-full appearance-none cursor-pointer accent-purple-600"
/>
</div>
</div>
)}
</div>
</div>
<div className="flex flex-col sm:flex-row gap-2 pt-1">
<button
type="button"
onClick={goBack}
className="w-full sm:w-auto px-5 py-3 text-sm font-medium text-gray-500 bg-gray-50 hover:bg-gray-100 rounded-xl transition-colors border border-gray-200/60"
>
Back
</button>
<button
type="button"
onClick={goNext}
className="w-full sm:flex-1 py-3 bg-purple-600 hover:bg-purple-700 text-white text-sm font-medium rounded-xl transition-colors flex items-center justify-center gap-2"
>
Go to step 3
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
);
})();
// ─── Step 3: Voice (last step) — audio playlist style ─────────
const voiceOptions = [
{ gender: "female" as const, accent: "american" as const, key: "female_american" },
{ gender: "female" as const, accent: "british" as const, key: "female_british" },
{ gender: "male" as const, accent: "american" as const, key: "male_american" },
{ gender: "male" as const, accent: "british" as const, key: "male_british" },
];
const getPlaybackUrl = (key: string): string | null => {
if (!VOICE_PREVIEW_KEYS.includes(key)) return null;
const base = BACKEND_URL ? `${BACKEND_URL}/api` : "/api";
return `${base}/voices/preview-audio?key=${encodeURIComponent(key)}`;
};
const FALLBACK_VOICE_NAMES: Record<string, string> = {
female_american: "Rachel",
female_british: "Alice",
male_american: "Bill",
male_british: "Daniel",
};
const FALLBACK_VOICE_DESCS: Record<string, string> = {
female_american: "Warm & confident, clear narration",
female_british: "Soft & polished, refined tone",
male_american: "Friendly & articulate, conversational",
male_british: "Calm & authoritative, smooth delivery",
};
const step3Voice = (
<div className="space-y-5">
<div className="flex items-center gap-2 px-3 py-2.5 rounded-xl bg-purple-50/60 border border-purple-200/50">
<svg className="w-4 h-4 text-purple-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129" />
</svg>
<p className="text-[11px] text-purple-600 leading-relaxed">
Choose narration language. Keep <span className="font-semibold">Auto</span> to detect from content.
</p>
</div>
<div className="space-y-1.5">
<label className="block text-[11px] font-medium text-gray-400 uppercase tracking-wider">
Language
</label>
{renderLanguageDropdown(contentLanguage, setContentLanguage)}
<p className="text-[11px] text-gray-500">Language of the video content</p>
</div>
{/* No voiceover: label above the pill, description + checkbox inside it
(mirrors the stock-footage pill in step 1). Stock footage moved to
step 1 so the visual choice sits with format/length. */}
<div>
<label className="flex items-center gap-2.5 cursor-pointer select-none p-3 rounded-xl bg-gray-50/60 border border-gray-200/60 hover:border-gray-300/60 transition-all">
<input
type="checkbox"
checked={voiceGender === "none"}
onChange={(e) => {
const noVoice = e.target.checked;
if (noVoice) {
setVoiceGender("none");
return;
}
const id = customVoiceId.trim();
const saved = id ? myVoicesList.find((v) => v.voice_id === id) : undefined;
setVoiceGender(normalizeVoiceGender(saved?.gender) ?? "female");
const a = normalizeVoiceAccent(saved?.accent);
if (a) setVoiceAccent(a);
}}
className="w-4 h-4 shrink-0 rounded border-gray-300 text-purple-600 focus:ring-purple-500/30 cursor-pointer accent-purple-600"
/>
<span className="text-sm font-medium text-gray-700 min-w-0">No voiceover</span>
</label>
</div>
{/* Voices from user's saved list + premium teasers for free users */}
<div>
<div className="flex items-center justify-between mb-3">
<label className="block text-[11px] font-medium text-gray-400 uppercase tracking-wider">
{voicePanelTab === "advanced" && isPro
? "Advanced Options"
: voicePanelTab === "bgm"
? "Music — Play to preview"
: "Voice — Play to preview"}
</label>
<div className="flex gap-1 p-1 bg-gray-100/60 rounded-xl shrink-0">
<button
type="button"
onClick={() => setVoicePanelTab("voice")}
className={`whitespace-nowrap px-3 py-1 rounded-lg text-[11px] font-medium transition-all ${
voicePanelTab === "voice"
? "bg-white text-purple-600 shadow-sm"
: "text-gray-400 hover:text-gray-600"
}`}
>
Voice
</button>
<button
type="button"
onClick={() => {
setVoicePanelTab("bgm");
}}
className={`flex items-center gap-1.5 whitespace-nowrap px-3 py-1 rounded-lg text-[11px] font-medium transition-all ${
voicePanelTab === "bgm"
? "bg-white text-purple-600 shadow-sm"
: "text-gray-400 hover:text-gray-600"
}`}
>
Music
</button>
<button
type="button"
onClick={() => {
if (!isPro) {
setShowUpgrade(true);
return;
}
setVoicePanelTab("advanced");
}}
className={`flex items-center gap-1.5 whitespace-nowrap px-3 py-1 rounded-lg text-[11px] font-medium transition-all ${
voicePanelTab === "advanced" && isPro
? "bg-white text-purple-600 shadow-sm"
: "text-gray-400 hover:text-gray-600"
}`}
>
Advanced Options
<span className="inline-flex h-4 items-center justify-center rounded-full bg-purple-600 px-1.5 text-[9px] font-semibold text-white">
Premium
</span>
</button>
</div>
</div>
{voicePanelTab === "voice" && (
<div className={`space-y-2 max-h-[320px] overflow-y-auto ${voiceGender === "none" ? "opacity-60 pointer-events-none" : ""}`}>
<CraftYourVoiceCard
isPro={isPro}
onClick={openStep3CustomVoiceCreator}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
openStep3CustomVoiceCreator();
}
}}
/>
{myVoicesLoading ? (
<div className="flex items-center gap-2 py-3 px-3 rounded-xl bg-gray-50/60 border border-gray-200/60">
<span className="w-4 h-4 border-2 border-purple-200 border-t-purple-600 rounded-full animate-spin shrink-0" />
<p className="text-[11px] text-gray-500">Loading your voices…</p>
</div>
) : (
<>
{myVoicesList.map((saved) => {
const isSelected = customVoiceId === saved.voice_id;
const canSelect = isPro || (saved.plan !== "paid" && !saved.custom_voice_id);
const hasPreview = !!saved.preview_url;
const myKey = `my_${saved.voice_id}`;
const isPlaying = playingKey === myKey;
const { displayName } = getMyVoiceDisplayName(saved.name);
const isCustom = !!saved.custom_voice_id;
return (
<VoiceItem
key={`saved_${saved.id}`}
name={displayName}
subtitle={subtitleForSavedVoice(saved)}
hasPreview={hasPreview}
isPlaying={isPlaying}
onPlay={() => playMyVoice(saved)}
disabled={false}
isSelected={isSelected}
onClick={() => {
if (!canSelect) {
setShowUpgrade(true);
return;
}
const nextId = isSelected ? "" : saved.voice_id;
setCustomVoiceId(nextId);
if (!isSelected) {
const g = normalizeVoiceGender(saved.gender);
const a = normalizeVoiceAccent(saved.accent);
if (g) setVoiceGender(g);
if (a) setVoiceAccent(a);
}
}}
badge={
isCustom ? (
<span className="inline-flex h-5 min-w-[4.5rem] items-center justify-center rounded-full bg-purple-600 px-2.5 py-0.5 text-[10px] font-semibold text-white">Custom</span>
) : saved.plan === "paid" ? (
<span className="inline-flex h-5 min-w-[4.5rem] items-center justify-center rounded-full bg-purple-600 px-2.5 py-0.5 text-[10px] font-semibold text-white">Premium</span>
) : null
}
actions={
isSelected ? (
<div className="w-5 h-5 rounded-full bg-purple-600 flex items-center justify-center flex-shrink-0">
<svg className="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
</div>
) : null
}
/>
);
})}
{!isPro && premiumTeaserVoices.map((voice) => {
const key = `premium_${voice.voice_id}`;
const isPlaying = playingKey === key;
const labels = voice.labels ?? {};
const subtitle = formatVoiceSubtitle(labels.gender, labels.accent, voice.description ?? "Premium voice");
return (
<VoiceItem
key={key}
name={voice.name}
subtitle={subtitle}
hasPreview={!!voice.preview_url}
isPlaying={isPlaying}
onPlay={() => playPremiumTeaser(voice)}
disabled={false}
isSelected={false}
onClick={() => setShowUpgrade(true)}
badge={
<span className="inline-flex h-5 min-w-[4.5rem] items-center justify-center rounded-full bg-purple-600 px-2.5 py-0.5 text-[10px] font-semibold text-white">Premium</span>
}
/>
);
})}
</>
)}
</div>
)}
{voicePanelTab === "voice" && !myVoicesLoading && myVoicesList.length === 0 && (
<p className="text-[11px] text-gray-500 mt-2">
No voices saved. Add voices in the Voices tab to use them here.{" "}
{!isPro && (
<button type="button" onClick={() => setShowUpgrade(true)} className="text-purple-600 hover:underline">
Upgrade to add video
</button>
)}
</p>
)}
{/* Advanced Options tab content — voice tuning sliders (paid) */}
{voicePanelTab === "advanced" && isPro && voiceGender !== "none" && (
<AdvancedVoiceOptions
value={{ enabled: expressiveEnabled, stability: voiceStability, speed: voiceSpeed, emotion: voiceEmotion, style: voiceStyle }}
onChange={(t) => { setExpressiveEnabled(t.enabled); setVoiceStability(t.stability); setVoiceSpeed(t.speed); setVoiceEmotion(t.emotion); setVoiceStyle(t.style); }}
voiceGender={voiceGender}
voiceAccent={voiceAccent}
customVoiceId={customVoiceId}
/>
)}
</div>
{/* ─── Music tab content (Premium; optional) ─────── */}
{voicePanelTab === "bgm" && bgmTracks.length > 0 && (
<div className="space-y-2">
<p className="text-[11px] text-gray-400 mb-1">Ambient music behind your narration — optional.</p>
{/* Volume — shown once a track is picked; applies to the whole project */}
{selectedBgmTrackId && (
<div className="mb-3">
<div className="flex items-center justify-between mb-1">
<span className="text-[11px] font-medium text-gray-600">Music volume</span>
<span className="text-[11px] text-gray-400 tabular-nums">{Math.round(selectedBgmVolume * 100)}%</span>
</div>
<input
type="range"
min={0}
max={100}
step={1}
value={Math.round(selectedBgmVolume * 100)}
onChange={(e) => {
const v = Number(e.target.value) / 100;
setSelectedBgmVolume(v);
if (bgmAudioRef.current) bgmAudioRef.current.volume = Math.max(0, Math.min(1, v));
}}
className="w-full accent-purple-600 cursor-pointer"
/>
<p className="text-[10px] text-gray-400 mt-0.5">Sets the volume for the whole video. After it's generated, you can fine-tune the music volume per scene in the <span className="font-medium text-gray-500">Audio</span> tab.</p>
</div>
)}
<div className="space-y-1.5 max-h-[320px] overflow-y-auto">
{/* None option */}
<button
type="button"
onClick={() => {
setSelectedBgmTrackId(null);
bgmAudioRef.current?.pause();
setBgmPlayingId(null);
}}
className={`w-full flex items-center gap-3 p-3 rounded-xl border transition-all text-left ${
!selectedBgmTrackId
? "border-purple-400 bg-purple-50/60"
: "border-gray-200/60 bg-gray-50/40 hover:border-gray-300/60"
}`}
>
<div className="w-8 h-8 rounded-full bg-gray-200 flex items-center justify-center shrink-0">
<svg className="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
</div>
<div className="min-w-0 flex-1">
<span className="text-sm font-medium text-gray-700">None</span>
<p className="text-[11px] text-gray-400">No background music</p>
</div>
{!selectedBgmTrackId && (
<div className="w-5 h-5 rounded-full bg-purple-600 flex items-center justify-center shrink-0">
<svg className="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
</div>
)}
</button>
{bgmTracks.map((track) => {
const isSelected = selectedBgmTrackId === track.track_id;
const isPlaying = bgmPlayingId === track.track_id;
return (
<div
key={track.track_id}
onClick={() => setSelectedBgmTrackId(isSelected ? null : track.track_id)}
className={`flex items-center gap-3 p-3 rounded-xl border transition-all cursor-pointer ${
isSelected
? "border-purple-400 bg-purple-50/60"
: "border-gray-200/60 bg-gray-50/40 hover:border-gray-300/60"
}`}
>
{/* Play/pause button */}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
if (isPlaying) {
bgmAudioRef.current?.pause();
setBgmPlayingId(null);
} else {
if (bgmAudioRef.current) {
bgmAudioRef.current.pause();
}
const audio = new Audio(track.r2_url);
audio.volume = Math.max(0, Math.min(1, selectedBgmVolume));
audio.onended = () => setBgmPlayingId(null);
audio.play().catch(() => {});
bgmAudioRef.current = audio;
setBgmPlayingId(track.track_id);
}
}}
className={`w-8 h-8 rounded-full flex items-center justify-center shrink-0 transition-colors ${
isPlaying
? "bg-purple-600 text-white"
: "bg-gray-200 text-gray-500 hover:bg-gray-300"
}`}
>
{isPlaying ? (
<svg className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<rect x="6" y="4" width="4" height="16" rx="1" />
<rect x="14" y="4" width="4" height="16" rx="1" />
</svg>
) : (
<svg className="w-3.5 h-3.5 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
)}
</button>
<div className="min-w-0 flex-1">
<span className="text-sm font-medium text-gray-700">{track.display_name}</span>
<p className="text-[11px] text-gray-400">{track.mood}</p>
</div>
{isSelected && (
<div className="w-5 h-5 rounded-full bg-purple-600 flex items-center justify-center shrink-0">
<svg className="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
</div>
)}
</div>
);
})}
</div>
</div>
)}
<div className="flex gap-2 pt-1">
<button
type="button"
onClick={goBack}
className="px-5 py-3 text-sm font-medium text-gray-500 bg-gray-50 hover:bg-gray-100 rounded-xl transition-colors border border-gray-200/60"
>
Back
</button>
<button
ref={submitButtonRef}
type="submit"
data-action="new-project"
disabled={loading}
className="flex-1 py-3 bg-purple-600 hover:bg-purple-700 disabled:bg-purple-400 disabled:text-white text-white text-sm font-medium rounded-xl transition-colors flex items-center justify-center gap-2"
>
{loading ? (
<>
<span className="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
Generating
</>
) : (
"Generate Video"
)}
</button>
</div>
</div>
);
// ─── Step 3 bulk: voice per project (tabbed) ───────────────────
const step3BulkVoice = (() => {
const indexed = bulkRows
.map((row, i) => ({ row, i }))
.filter(({ row }) => row.url.trim());
if (indexed.length === 0) {
return (
<div className="space-y-5">
<p className="text-sm text-gray-500">Please go back to step 1 to continue again.</p>
<div className="flex gap-2 pt-1">
<button
type="button"
onClick={goBack}
className="px-5 py-3 text-sm font-medium text-gray-500 bg-gray-50 hover:bg-gray-100 rounded-xl transition-colors border border-gray-200/60"
>
Back
</button>
</div>
</div>
);
}
const active = Math.min(bulkActiveIndex, indexed.length - 1);
const activeIndex = indexed[active].i;
const masterIndex =
indexed.find(({ i }) => i === bulkVoiceMasterIndex)?.i ??
indexed[0].i;
const rowVoiceGender = bulkVoiceGender[activeIndex] ?? "female";
const rowVoiceAccent = bulkVoiceAccent[activeIndex] ?? "american";
const rowVoiceStability = bulkVoiceStability[activeIndex] ?? VOICE_STABILITY_DEFAULT;
const rowVoiceSpeed = bulkVoiceSpeed[activeIndex] ?? VOICE_SPEED_DEFAULT;
const rowVoiceEmotion = bulkVoiceEmotion[activeIndex] ?? "";
const rowVoiceStyle = bulkVoiceStyle[activeIndex] ?? VOICE_STYLE_DEFAULT;
const rowCustomVoiceId = bulkCustomVoiceId[activeIndex] ?? "";
const rowContentLanguage = bulkContentLanguage[activeIndex] ?? "auto";
const rowVideoLength = bulkVideoLength[activeIndex] ?? "short";
const applyVoiceToAll = () => {
const targetIndices = indexed.map(({ i }) => i);
setBulkVoiceGender((prev) => {
const next = [...prev];
targetIndices.forEach((idx) => {
next[idx] = rowVoiceGender;
});
return next;
});
setBulkVoiceAccent((prev) => {
const next = [...prev];
targetIndices.forEach((idx) => {
next[idx] = rowVoiceAccent;
});
return next;
});
setBulkVoiceStability((prev) => {
const next = [...prev];
targetIndices.forEach((idx) => {
next[idx] = rowVoiceStability;
});
return next;
});
setBulkVoiceSpeed((prev) => {
const next = [...prev];
targetIndices.forEach((idx) => {
next[idx] = rowVoiceSpeed;
});
return next;
});
setBulkVoiceEmotion((prev) => {
const next = [...prev];
targetIndices.forEach((idx) => {
next[idx] = rowVoiceEmotion;
});
return next;
});
setBulkVoiceStyle((prev) => {
const next = [...prev];
targetIndices.forEach((idx) => {
next[idx] = rowVoiceStyle;
});
return next;
});
setBulkCustomVoiceId((prev) => {
const next = [...prev];
targetIndices.forEach((idx) => {
next[idx] = rowCustomVoiceId;
});
return next;
});
setBulkContentLanguage((prev) => {
const next = [...prev];
targetIndices.forEach((idx) => {
next[idx] = rowContentLanguage;
});
return next;
});
setBulkVideoLength((prev) => {
const next = [...prev];
targetIndices.forEach((idx) => {
next[idx] = rowVideoLength;
});
return next;
});
};
return (
<div className="space-y-5">
{/* Tabs for each bulk project — match Link/Upload/Multi Link tabs */}
<div className="flex flex-wrap gap-1 pb-2 mb-2">
<div className="flex flex-wrap gap-1 p-1 bg-gray-100/60 rounded-xl">
{indexed.map(({ row, i }, tabIdx) => (
<button
key={i}
type="button"
onClick={() => setBulkActiveIndex(tabIdx)}
className={`px-3 py-1.5 rounded-lg text-[11px] font-medium transition-all ${
tabIdx === active
? "bg-white text-purple-600 shadow-sm"
: "text-gray-400 hover:text-gray-600"
}`}
title={row.url.trim() || undefined}
>
Video #{tabIdx + 1}
</button>
))}
</div>
</div>
{/* Apply voice selection to all */}
<div className="flex items-center justify-start mb-2 ml-1">
<label className="flex items-center gap-2 text-[11px] text-gray-500 cursor-pointer select-none">
<input
type="checkbox"
checked={bulkApplyVoiceAll}
onChange={(e) => {
const checked = e.target.checked;
setBulkApplyVoiceAll(checked);
if (checked) {
// Use the currently active video as the master when (re)enabling apply-to-all.
setBulkVoiceMasterIndex(activeIndex);
applyVoiceToAll();
}
}}
className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500/30 cursor-pointer accent-purple-600"
/>
<span className="font-medium">Apply settings to all videos</span>
</label>
</div>
<div className="space-y-1.5">
<label className="block text-[11px] font-medium text-gray-400 uppercase tracking-wider">
Language
</label>
{renderLanguageDropdown(rowContentLanguage, (value) => {
const targetIndices = indexed.map(({ i }) => i);
if (bulkApplyVoiceAll && activeIndex === masterIndex) {
setBulkContentLanguage((prev) => {
const next = [...prev];
targetIndices.forEach((idx) => {
next[idx] = value;
});
return next;
});
} else {
setBulkApplyVoiceAll(false);
setBulkContentLanguage((prev) => {
const next = [...prev];
next[activeIndex] = value;
return next;
});
}
})}
<p className="text-[11px] text-gray-500">Language of the video content</p>
</div>
{/* No voiceover: label above the pill, description + checkbox inside it
(mirrors step 1). Edits this row, or all rows when the apply-to-all
voice sync is on. */}
<div>
<label className="flex items-center gap-2.5 cursor-pointer select-none p-3 rounded-xl bg-gray-50/60 border border-gray-200/60 hover:border-gray-300/60 transition-all">
<input
type="checkbox"
checked={rowVoiceGender === "none"}
onChange={(e) => {
const noVoice = e.target.checked;
const targetIndices = indexed.map(({ i }) => i);
const applyNoVoiceoverToggle = (indices: number[]) => {
if (noVoice) {
setBulkVoiceGender((prev) => {
const next = [...prev];
indices.forEach((idx) => {
next[idx] = "none";
});
return next;
});
return;
}
setBulkVoiceGender((prev) => {
const next = [...prev];
indices.forEach((idx) => {
const vid = (bulkCustomVoiceId[idx] ?? "").trim();
const saved = vid ? myVoicesList.find((v) => v.voice_id === vid) : undefined;
next[idx] = normalizeVoiceGender(saved?.gender) ?? "female";
});
return next;
});
setBulkVoiceAccent((prev) => {
const next = [...prev];
indices.forEach((idx) => {
const vid = (bulkCustomVoiceId[idx] ?? "").trim();
const saved = vid ? myVoicesList.find((v) => v.voice_id === vid) : undefined;
const a = normalizeVoiceAccent(saved?.accent);
if (a) next[idx] = a;
});
return next;
});
};
if (bulkApplyVoiceAll && activeIndex !== masterIndex) {
// Editing a non-master video breaks the global sync.
setBulkApplyVoiceAll(false);
applyNoVoiceoverToggle([activeIndex]);
return;
}
if (bulkApplyVoiceAll && activeIndex === masterIndex) {
// Master row change: keep all videos in sync.
applyNoVoiceoverToggle(targetIndices);
return;
}
// No global sync: only update this row.
applyNoVoiceoverToggle([activeIndex]);
}}
className="w-4 h-4 shrink-0 rounded border-gray-300 text-purple-600 focus:ring-purple-500/30 cursor-pointer accent-purple-600"
/>
<span className="text-sm font-medium text-gray-700 min-w-0">No voiceover</span>
</label>
</div>
<div className={rowVoiceGender === "none" ? "opacity-60 pointer-events-none" : ""}>
<div className="flex items-center justify-between mb-3">
<label className="block text-[11px] font-medium text-gray-400 uppercase tracking-wider">
{showAdvancedOptions && isPro ? "Advanced Options" : "Voice — select and play to preview"}
</label>
<div className="flex gap-1 p-1 bg-gray-100/60 rounded-xl">
<button
type="button"
onClick={() => setShowAdvancedOptions(false)}
className={`whitespace-nowrap px-3 py-1 rounded-lg text-[11px] font-medium transition-all ${
!(showAdvancedOptions && isPro)
? "bg-white text-purple-600 shadow-sm"
: "text-gray-400 hover:text-gray-600"
}`}
>
Voice
</button>
<button
type="button"
onClick={() => {
if (!isPro) {
setShowUpgrade(true);
return;
}
setShowAdvancedOptions(true);
}}
className={`flex items-center gap-1.5 px-3 py-1 rounded-lg text-[11px] font-medium transition-all ${
showAdvancedOptions && isPro
? "bg-white text-purple-600 shadow-sm"
: "text-gray-400 hover:text-gray-600"
}`}
>
Advanced Options
<span className="inline-flex h-4 items-center justify-center rounded-full bg-purple-600 px-1.5 text-[9px] font-semibold text-white">
Premium
</span>
</button>
</div>
</div>
{!(showAdvancedOptions && isPro) && (
<div className="space-y-2 max-h-[320px] overflow-y-auto">
{myVoicesLoading ? (
<div className="flex items-center gap-2 py-3 px-3 rounded-xl bg-gray-50/60 border border-gray-200/60">
<span className="w-4 h-4 border-2 border-purple-200 border-t-purple-600 rounded-full animate-spin shrink-0" />
<p className="text-[11px] text-gray-500">Loading your voices…</p>
</div>
) : (
<>
{myVoicesList.map((saved) => {
const isSelectedBulk = rowCustomVoiceId === saved.voice_id;
const canSelectBulk = isPro || (saved.plan !== "paid" && !saved.custom_voice_id);
const hasPreview = !!saved.preview_url;
const myKey = `my_${saved.voice_id}`;
const isPlaying = playingKey === myKey;
const { displayName } = getMyVoiceDisplayName(saved.name);
const isCustom = !!saved.custom_voice_id;
return (
<VoiceItem
key={`saved_${saved.id}`}
name={displayName}
subtitle={subtitleForSavedVoice(saved)}
hasPreview={hasPreview}
isPlaying={isPlaying}
onPlay={() => playMyVoice(saved)}
disabled={false}
isSelected={isSelectedBulk}
onClick={() => {
if (!canSelectBulk) {
setShowUpgrade(true);
return;
}
const value = isSelectedBulk ? "" : saved.voice_id;
const g = normalizeVoiceGender(saved.gender);
const a = normalizeVoiceAccent(saved.accent);
const targetIndices = indexed.map(({ i }) => i);
if (bulkApplyVoiceAll && activeIndex === masterIndex) {
if (g) {
setBulkVoiceGender((prev) => {
const next = [...prev];
targetIndices.forEach((idx) => { next[idx] = g; });
return next;
});
}
if (a) {
setBulkVoiceAccent((prev) => {
const next = [...prev];
targetIndices.forEach((idx) => { next[idx] = a; });
return next;
});
}
setBulkCustomVoiceId((prev) => {
const next = [...prev];
targetIndices.forEach((idx) => { next[idx] = value; });
return next;
});
} else {
setBulkApplyVoiceAll(false);
if (g) {
setBulkVoiceGender((prev) => {
const next = [...prev];
next[activeIndex] = g;
return next;
});
}
if (a) {
setBulkVoiceAccent((prev) => {
const next = [...prev];
next[activeIndex] = a;
return next;
});
}
setBulkCustomVoiceId((prev) => {
const next = [...prev];
next[activeIndex] = value;
return next;
});
}
}}
badge={
isCustom ? (
<span className="inline-flex h-5 min-w-[4.5rem] items-center justify-center rounded-full bg-purple-600 px-2.5 py-0.5 text-[10px] font-semibold text-white">Custom</span>
) : saved.plan === "paid" ? (
<span className="inline-flex h-5 min-w-[4.5rem] items-center justify-center rounded-full bg-purple-600 px-2.5 py-0.5 text-[10px] font-semibold text-white">Premium</span>
) : null
}
actions={
isSelectedBulk ? (
<div className="w-5 h-5 rounded-full bg-purple-600 flex items-center justify-center flex-shrink-0">
<svg className="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
</div>
) : null
}
/>
);
})}
{!isPro && premiumTeaserVoices.map((voice) => {
const key = `premium_${voice.voice_id}`;
const isPlaying = playingKey === key;
const labels = voice.labels ?? {};
const subtitle = formatVoiceSubtitle(labels.gender, labels.accent, voice.description ?? "Premium voice");
return (
<VoiceItem
key={key}
name={voice.name}
subtitle={subtitle}
hasPreview={!!voice.preview_url}
isPlaying={isPlaying}
onPlay={() => playPremiumTeaser(voice)}
disabled={false}
isSelected={false}
onClick={() => setShowUpgrade(true)}
badge={
<span className="inline-flex h-5 min-w-[4.5rem] items-center justify-center rounded-full bg-purple-600 px-2.5 py-0.5 text-[10px] font-semibold text-white">Premium</span>
}
/>
);
})}
</>
)}
</div>
)}
{!(showAdvancedOptions && isPro) && !myVoicesLoading && myVoicesList.length === 0 && (
<p className="text-[11px] text-gray-500 mt-2">
No voices saved. Add voices in the Voices tab to use them here.{" "}
{!isPro && (
<button type="button" onClick={() => setShowUpgrade(true)} className="text-purple-600 hover:underline">
Upgrade to add video
</button>
)}
</p>
)}
</div>
{/* Voice tuning — Stability + Speed sliders (paid); apply-to-all sync mirrors other voice controls */}
{(() => {
const applyBulkTuning = <T,>(
setter: React.Dispatch<React.SetStateAction<T[]>>,
value: T,
) => {
const targetIndices = indexed.map(({ i }) => i);
if (bulkApplyVoiceAll && activeIndex === masterIndex) {
setter((prev) => {
const next = [...prev];
targetIndices.forEach((idx) => { next[idx] = value; });
return next;
});
} else {
setBulkApplyVoiceAll(false);
setter((prev) => {
const next = [...prev];
next[activeIndex] = value;
return next;
});
}
};
return (
<div className={rowVoiceGender === "none" ? "opacity-60 pointer-events-none" : ""}>
{showAdvancedOptions && isPro && (
<div className="space-y-4">
<div className="flex items-center justify-between gap-3 p-3 rounded-xl bg-gray-50/60 border border-gray-200/60">
<div>
<p className="text-sm font-medium text-gray-700">Enable</p>
</div>
<button
type="button"
role="switch"
aria-checked={expressiveEnabled}
onClick={() => setExpressiveEnabled((v) => !v)}
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors ${expressiveEnabled ? "bg-purple-600" : "bg-gray-300"}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${expressiveEnabled ? "translate-x-4" : "translate-x-0.5"}`} />
</button>
</div>
<div
className={`space-y-4 transition-opacity ${expressiveEnabled ? "" : "opacity-50 pointer-events-none select-none"}`}
aria-disabled={!expressiveEnabled}
>
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-[11px] font-medium text-gray-600">
Emotion <span className="font-normal text-gray-400">(optional)</span>
</span>
</div>
<div className="flex flex-wrap gap-1.5">
{VOICE_EMOTIONS.map((em) => {
const selected = rowVoiceEmotion === em.value;
return (
<button
key={em.value}
type="button"
aria-pressed={selected}
onClick={() => applyBulkTuning(setBulkVoiceEmotion, selected ? "" : em.value)}
className={`px-3 py-1 rounded-full text-[11px] font-medium border transition-colors ${
selected
? "bg-purple-600 border-purple-600 text-white"
: "bg-white border-gray-200 text-gray-600 hover:border-purple-300"
}`}
>
{em.label}
</button>
);
})}
</div>
</div>
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-[11px] font-medium text-gray-600">Expressiveness</span>
<span className="text-[11px] text-gray-400 tabular-nums">{rowVoiceStability.toFixed(2)}</span>
</div>
<input
type="range"
min={VOICE_STABILITY_MIN}
max={VOICE_STABILITY_MAX}
step={VOICE_TUNING_STEP}
value={rowVoiceStability}
onChange={(e) => applyBulkTuning(setBulkVoiceStability, parseFloat(e.target.value))}
className="w-full accent-purple-600 cursor-pointer"
/>
<div className="flex justify-between text-[10px] text-gray-400 mt-0.5">
<span>Steady</span>
<span>Expressive</span>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-[11px] font-medium text-gray-600">Character</span>
<span className="text-[11px] text-gray-400 tabular-nums">{rowVoiceStyle.toFixed(2)}</span>
</div>
<input
type="range"
min={VOICE_STYLE_MIN}
max={VOICE_STYLE_MAX}
step={VOICE_TUNING_STEP}
value={rowVoiceStyle}
onChange={(e) => applyBulkTuning(setBulkVoiceStyle, parseFloat(e.target.value))}
className="w-full accent-purple-600 cursor-pointer"
/>
<div className="flex justify-between text-[10px] text-gray-400 mt-0.5">
<span>Natural</span>
<span>Dramatic</span>
</div>
{rowVoiceStyle >= 0.4 && rowVoiceStability >= 0.7 && (
<p className="text-[10px] text-amber-600 mt-0.5">High Character + high Expressiveness can sound distorted.</p>
)}
</div>
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-[11px] font-medium text-gray-600">Speed</span>
<span className="text-[11px] text-gray-400 tabular-nums">{rowVoiceSpeed.toFixed(2)}x</span>
</div>
<input
type="range"
min={VOICE_SPEED_MIN}
max={VOICE_SPEED_MAX}
step={VOICE_TUNING_STEP}
value={rowVoiceSpeed}
onChange={(e) => applyBulkTuning(setBulkVoiceSpeed, parseFloat(e.target.value))}
className="w-full accent-purple-600 cursor-pointer"
/>
</div>
<button
type="button"
onClick={() =>
handleVoicePreview({
gender: rowVoiceGender,
accent: rowVoiceAccent,
customVoiceId: rowCustomVoiceId,
stability: rowVoiceStability,
speed: rowVoiceSpeed,
emotion: rowVoiceEmotion,
style: rowVoiceStyle,
})
}
disabled={previewState === "loading"}
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-medium border border-gray-200 text-gray-700 hover:border-purple-300 hover:text-purple-700 transition-colors disabled:opacity-50"
>
{previewState === "loading" ? (
<>
<span className="w-3.5 h-3.5 rounded-full border-2 border-purple-200 border-t-purple-600 animate-spin" />
Generating…
</>
) : previewState === "playing" ? (
<>
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="currentColor" aria-hidden><rect x="6" y="5" width="4" height="14" rx="1" /><rect x="14" y="5" width="4" height="14" rx="1" /></svg>
Stop
</>
) : (
<>
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="currentColor" aria-hidden><path d="M8 5v14l11-7z" /></svg>
Listen to a sample
</>
)}
</button>
</div>
</div>
)}
</div>
);
})()}
<div className="flex gap-2 pt-1">
<button
type="button"
onClick={goBack}
className="px-5 py-3 text-sm font-medium text-gray-500 bg-gray-50 hover:bg-gray-100 rounded-xl transition-colors border border-gray-200/60"
>
Back
</button>
<button
ref={submitButtonRef}
type="submit"
disabled={loading || !onSubmitBulk}
className="flex-1 py-3 bg-purple-600 hover:bg-purple-700 disabled:bg-purple-400 disabled:text-white text-white text-sm font-medium rounded-xl transition-colors flex items-center justify-center gap-2"
>
{loading ? (
<>
<span className="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
Generating
</>
) : (
"Create all & generate"
)}
</button>
</div>
</div>
);
})();
// ─── Render ──────────────────────────────────────────────────
// Step order: 1 Project, 2 Template, 3 Voice
const stepContent =
step === 1
? step1
: step === 2
? mode === "bulk"
? step2BulkTemplate
: step2Template
: mode === "bulk"
? step3BulkVoice
: step3Voice;
const modalWidth = "max-w-xl";
const isStep2TemplatesPending =
step === 2 && (builtinTemplatesLoading || templateAvailabilityLoading || !sessionBuiltinInitDone);
// Constant form size: min-height so layout doesn’t jump between steps
const stepContentWrapper = (
<div className="relative min-h-[420px] flex flex-col">
<div className="min-h-[420px] flex flex-col">{stepContent}</div>
{isStep2TemplatesPending && (
<div
className="absolute inset-0 z-20 flex items-center justify-center rounded-xl bg-white/60 backdrop-blur-md ring-1 ring-gray-200/50 shadow-[inset_0_0_24px_rgba(124,58,237,0.06)]"
aria-busy="true"
>
<span
className="h-4 w-4 shrink-0 animate-spin rounded-full border-2 border-purple-200 border-t-purple-600"
aria-hidden
/>
</div>
)}
</div>
);
const formContent = (
<>
<form
onSubmit={handleSubmit}
onKeyDown={(e) => {
// Prevent Enter from submitting unless the submit button is focused (avoids auto-submit when landing on step 3)
if (e.key === "Enter" && document.activeElement !== submitButtonRef.current) {
e.preventDefault();
}
}}
>
<div className="relative">
<StepIndicator current={step} total={3} />
{stepContentWrapper}
</div>
<UpgradePlanModal
open={showUpgrade}
onClose={() => setShowUpgrade(false)}
title="Upgrade to unlock"
subtitle="Unlock premium voices and more. Choose a plan below."
/>
<UpgradePlanModal
open={showCustomTemplateUpgrade}
onClose={() => setShowCustomTemplateUpgrade(false)}
subscriptionsOnly
title="Upgrade to use custom template"
subtitle="Custom templates are a Standard & Pro feature. Upgrade your plan to use this template in your videos."
/>
<GetMoreTemplatesModal
open={showGetMoreTemplates}
onClose={() => setShowGetMoreTemplates(false)}
onChooseLink={() => {
setShowGetMoreTemplates(false);
openStep2CustomTemplateCreator(videoStyle, null);
}}
onChooseDesigner={() => {
setShowGetMoreTemplates(false);
setShowDesignerRequest(true);
}}
/>
<DesignerTemplateRequestModal
open={showDesignerRequest}
onClose={() => setShowDesignerRequest(false)}
/>
</form>
</>
);
if (!asModal) {
return (
<div className="relative">
<StepIndicator current={step} total={3} />
<form
onSubmit={handleSubmit}
onKeyDown={(e) => {
if (e.key === "Enter" && document.activeElement !== submitButtonRef.current) {
e.preventDefault();
}
}}
>
{stepContentWrapper}
</form>
<UpgradePlanModal
open={showUpgrade}
onClose={() => setShowUpgrade(false)}
title="Upgrade to unlock"
subtitle="Unlock premium voices and more. Choose a plan below."
/>
<UpgradePlanModal
open={showCustomTemplateUpgrade}
onClose={() => setShowCustomTemplateUpgrade(false)}
subscriptionsOnly
title="Upgrade to use custom template"
subtitle="Custom templates are a Standard & Pro feature. Upgrade your plan to use this template in your videos."
/>
<GetMoreTemplatesModal
open={showGetMoreTemplates}
onClose={() => setShowGetMoreTemplates(false)}
onChooseLink={() => {
setShowGetMoreTemplates(false);
openStep2CustomTemplateCreator(videoStyle, null);
}}
onChooseDesigner={() => {
setShowGetMoreTemplates(false);
setShowDesignerRequest(true);
}}
/>
<DesignerTemplateRequestModal
open={showDesignerRequest}
onClose={() => setShowDesignerRequest(false)}
/>
{videoPreviewId && (
<TemplateVideoLightbox
templateId={videoPreviewId}
onClose={() => setVideoPreviewId(null)}
onSelect={() => applyTemplate(videoPreviewId)}
isSelected={template === videoPreviewId}
customTemplate={
videoPreviewId.startsWith("custom_")
? customTemplates.find((ct) => ct.id === parseInt(videoPreviewId.replace("custom_", "")))
: videoPreviewId.startsWith("crafted_")
? craftedTemplates.find((ct) => ct.id === videoPreviewId)
: null
}
/>
)}
</div>
);
}
return ReactDOM.createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center p-8">
<div
className={`absolute inset-0 ${isStep2TemplatesPending ? "bg-black/45 backdrop-blur-md" : "bg-black/40 backdrop-blur-sm"}`}
onClick={onClose}
/>
<div
className={`relative w-full ${modalWidth} bg-white/90 backdrop-blur-xl border border-gray-200/40 rounded-2xl shadow-[0_8px_40px_rgba(0,0,0,0.08)] p-7 mt-5 max-h-[85vh] overflow-y-auto transition-all duration-300`}
>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<h2 className="text-base font-semibold text-gray-900">New Project</h2>
<button onClick={onClose} className="text-gray-300 hover:text-gray-500 transition-colors">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{formContent}
</div>
{videoPreviewId && (
<TemplateVideoLightbox
templateId={videoPreviewId}
onClose={() => setVideoPreviewId(null)}
onSelect={() => applyTemplate(videoPreviewId)}
isSelected={template === videoPreviewId}
/>
)}
</div>,
document.body
);
}