Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
| 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 | |
| ); | |
| } | |