Download src/components/dashboard/StudyVideoWizard.tsx from asnannp/DocDoeAI: direct link, hf CLI and curl.
- Browser
- Download file 50.7 kB
-
https://huggingface.co/spaces/asnannp/DocDoeAI/resolve/main/src/components/dashboard/StudyVideoWizard.tsx
- Command line
-
hf download hf://spaces/asnannp/DocDoeAI/src/components/dashboard/StudyVideoWizard.tsx
-
curl -L -o StudyVideoWizard.tsx https://huggingface.co/spaces/asnannp/DocDoeAI/resolve/main/src/components/dashboard/StudyVideoWizard.tsx
50.7 kB
| "use client"; | |
| import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; | |
| import { | |
| AlertCircle, | |
| Check, | |
| ChevronDown, | |
| Clapperboard, | |
| Download, | |
| ExternalLink, | |
| LoaderCircle, | |
| Lock, | |
| RotateCcw, | |
| X, | |
| } from "lucide-react"; | |
| import { cn } from "@/lib/utils"; | |
| import { AnswerTrustFooter, EvidenceBadge } from "@/components/dashboard/EvidenceBadge"; | |
| import { useJobPolling } from "@/hooks/useJobPolling"; | |
| import { normalizeApiError, resolveMediaUrl } from "@/lib/api/client"; | |
| import { | |
| createStudyVideoJob, | |
| getVideoJob, | |
| renderStudyVideoPreview, | |
| } from "@/lib/api/video"; | |
| import type { | |
| StudyVideoJobResponse, | |
| StudyVideoMode, | |
| StudyVideoPlan, | |
| StudyVideoScene, | |
| StudyVideoPreviewResponse, | |
| VideoRenderJob, | |
| } from "@/lib/api/types"; | |
| import { | |
| VIDEO_DURATION_OPTIONS, | |
| type VideoDurationMinutes, | |
| videoDurationOption, | |
| } from "@/lib/video/durations"; | |
| type WizardStage = | |
| | "setup" | |
| | "planning" | |
| | "script_ready" | |
| | "preview_rendering" | |
| | "preview_ready" | |
| | "failed"; | |
| type JobViewState = { | |
| id: string; | |
| status: string; | |
| current_step?: string | null; | |
| progress?: number | null; | |
| output_url?: string | null; | |
| evidence_label?: string | null; | |
| error_message?: string | null; | |
| }; | |
| type MaterialType = | |
| | "notes" | |
| | "chapter" | |
| | "textbook" | |
| | "question_paper" | |
| | "handwritten_notes" | |
| | "syllabus" | |
| | "resume_or_personal_doc" | |
| | "unknown"; | |
| export type StudyVideoWizardSource = { | |
| id: string; | |
| title: string; | |
| subject?: string; | |
| status?: string; | |
| materialType?: MaterialType | null; | |
| material_type?: MaterialType | null; | |
| evidenceLabel?: string | null; | |
| evidence_label?: string | null; | |
| }; | |
| type StudyVideoWizardProps = { | |
| isOpen: boolean; | |
| source: StudyVideoWizardSource; | |
| onClose: () => void; | |
| onUploadClick?: () => void; | |
| }; | |
| const validVideoMaterialTypes = new Set<MaterialType>([ | |
| "notes", | |
| "chapter", | |
| "textbook", | |
| "handwritten_notes", | |
| "question_paper", | |
| "syllabus", | |
| ]); | |
| const materialLabels: Record<MaterialType, string> = { | |
| notes: "Notes", | |
| chapter: "Chapter", | |
| textbook: "Textbook", | |
| question_paper: "Question paper", | |
| handwritten_notes: "Handwritten notes", | |
| syllabus: "Syllabus", | |
| resume_or_personal_doc: "Personal document", | |
| unknown: "Unknown source", | |
| }; | |
| const specialModeOptions: { | |
| value: StudyVideoMode; | |
| label: string; | |
| helper: string; | |
| target: number; | |
| durationMinutes: VideoDurationMinutes; | |
| }[] = [ | |
| { | |
| value: "last_night_revision", | |
| label: "Last-night revision", | |
| helper: "Quick recall and scoring points.", | |
| target: 600, | |
| durationMinutes: 10, | |
| }, | |
| { | |
| value: "pyq_review", | |
| label: "PYQ review", | |
| helper: "Only for uploaded question paper sources.", | |
| target: 600, | |
| durationMinutes: 10, | |
| }, | |
| ]; | |
| const languageOptions = ["English", "Malayalam", "Mixed"]; | |
| const formatOptions = [ | |
| { value: "16:9", label: "Landscape" }, | |
| { value: "9:16", label: "Vertical" }, | |
| ]; | |
| const visualStyleOptions = [ | |
| { value: "clean_explainer", label: "Clean explainer" }, | |
| { value: "anime_visual_explainer", label: "Anime visual explainer" }, | |
| ]; | |
| const voiceOptions = [ | |
| { value: "", label: "Default" }, | |
| { value: "calm_teacher", label: "Calm teacher" }, | |
| { value: "malayalam_english_teacher", label: "Malayalam + English teacher" }, | |
| ]; | |
| const previewPollingTimeoutMs = 10 * 60 * 1000; | |
| const previewTimeoutCopy = "Preview is still rendering. You can close this and check Recent videos later."; | |
| function getMaterialType(source: StudyVideoWizardSource) { | |
| return source.materialType ?? source.material_type ?? null; | |
| } | |
| function defaultModeForSource(source: StudyVideoWizardSource): StudyVideoMode { | |
| if (getMaterialType(source) === "question_paper") return "pyq_review"; | |
| return "exam_study_video"; | |
| } | |
| function targetForMode(mode: StudyVideoMode, durationMinutes: VideoDurationMinutes) { | |
| const special = specialModeOptions.find((option) => option.value === mode); | |
| if (special) return special.target; | |
| return videoDurationOption(durationMinutes).targetSeconds; | |
| } | |
| function modeForDuration(durationMinutes: VideoDurationMinutes, materialType: MaterialType | null | undefined): StudyVideoMode { | |
| if (materialType === "question_paper") return "pyq_review"; | |
| return videoDurationOption(durationMinutes).studyMode; | |
| } | |
| function getSourceEvidenceLabel(source: StudyVideoWizardSource) { | |
| return source.evidenceLabel ?? source.evidence_label ?? null; | |
| } | |
| function getEligibility(source: StudyVideoWizardSource) { | |
| const materialType = getMaterialType(source); | |
| if (!source.id) { | |
| return { | |
| canCreate: false, | |
| title: "Upload material to create a study video.", | |
| body: "Choose a readable PDF or TXT source containing notes, a chapter, syllabus, textbook, or question paper before creating a video.", | |
| cta: "Upload material", | |
| }; | |
| } | |
| if (source.status === "processing" || source.status === "uploaded") { | |
| return { | |
| canCreate: false, | |
| title: "This source is still processing.", | |
| body: "Create the video after DocDoe finishes reading the source.", | |
| cta: null, | |
| }; | |
| } | |
| if (source.status === "failed") { | |
| return { | |
| canCreate: false, | |
| title: "This source is no longer eligible for study videos.", | |
| body: "DocDoe could not read this file. Upload another copy or use another source.", | |
| cta: "Upload material", | |
| }; | |
| } | |
| if (materialType === "resume_or_personal_doc") { | |
| return { | |
| canCreate: false, | |
| title: "Use study material to create a video.", | |
| body: "Personal documents are blocked from exam video generation.", | |
| cta: null, | |
| }; | |
| } | |
| if (materialType === "unknown" || !materialType) { | |
| return { | |
| canCreate: false, | |
| title: "Classify this source to create a video.", | |
| body: "Use readable notes, a chapter, syllabus, textbook, or question paper to create a study video.", | |
| cta: null, | |
| }; | |
| } | |
| if (!validVideoMaterialTypes.has(materialType)) { | |
| return { | |
| canCreate: false, | |
| title: "This source is no longer eligible for study videos.", | |
| body: "Use readable notes, a chapter, syllabus, textbook, or question paper to create a study video.", | |
| cta: null, | |
| }; | |
| } | |
| return { canCreate: true, title: "", body: "", cta: null }; | |
| } | |
| function sourceHeader(source: StudyVideoWizardSource) { | |
| const materialType = getMaterialType(source); | |
| if (materialType === "question_paper") return `Analyzing paper: ${source.title}`; | |
| if ( | |
| materialType === "notes" || | |
| materialType === "chapter" || | |
| materialType === "textbook" || | |
| materialType === "handwritten_notes" || | |
| materialType === "syllabus" | |
| ) { | |
| return `Working from: ${source.title}`; | |
| } | |
| return null; | |
| } | |
| // The "verified" note is only earned by the real evidence marker: the backend | |
| // evidence contract emits a "verified" label solely when 8+ distinct years of | |
| // reviewed papers are uploaded (backend/app/services/evidence_contract.py). | |
| // A marketing span like "10-year" must never mint this state, and a label that | |
| // explicitly denies verification ("not verified", "unverified") must not be | |
| // read as its opposite. | |
| function hasVerifiedEvidence(text: string): boolean { | |
| return text.includes("verified") && !text.includes("not verified") && !text.includes("unverified"); | |
| } | |
| function evidenceContextNote(label: string | null | undefined) { | |
| if (!label) return null; | |
| const text = label.toLowerCase(); | |
| if (text.includes("uploaded material only") || text.includes("material only")) { | |
| return "Video will be based on uploaded material only. Upload question papers to unlock PYQ-based video signals."; | |
| } | |
| if (text.includes("1 uploaded") || text.includes("one uploaded")) { | |
| return "Video will analyze this paper only. Upload more papers for trend analysis."; | |
| } | |
| if (hasVerifiedEvidence(text)) { | |
| return "Verified PYQ evidence is available for this context."; | |
| } | |
| return null; | |
| } | |
| function isTerminalStatus(status: string | null | undefined) { | |
| return status === "complete" || status === "ready" || status === "failed"; | |
| } | |
| function isCompleteStatus(status: string | null | undefined) { | |
| return status === "complete" || status === "ready"; | |
| } | |
| function fallbackProgress(status: string | null | undefined) { | |
| if (status === "audio_generating") return 35; | |
| if (status === "rendering" || status === "running" || status === "uploading") return 65; | |
| if (isCompleteStatus(status)) return 100; | |
| if (status === "script_ready") return 25; | |
| return 10; | |
| } | |
| function progressFor(job: JobViewState | null, stage: WizardStage) { | |
| if (!job) return stage === "planning" ? 18 : 0; | |
| return Math.max(0, Math.min(job.progress ?? fallbackProgress(job.status), 100)); | |
| } | |
| function toJobState(job: StudyVideoJobResponse | StudyVideoPreviewResponse | VideoRenderJob): JobViewState { | |
| const record = job as Partial<VideoRenderJob & StudyVideoJobResponse & StudyVideoPreviewResponse>; | |
| return { | |
| id: job.id, | |
| status: job.status, | |
| current_step: job.current_step, | |
| progress: record.progress ?? null, | |
| output_url: record.output_url ?? record.public_url ?? record.download_url ?? null, | |
| evidence_label: record.evidence_label ?? null, | |
| error_message: record.error_message ?? null, | |
| }; | |
| } | |
| function safeVideoError(error: unknown) { | |
| const message = normalizeApiError(error); | |
| const lower = message.toLowerCase(); | |
| if (lower.includes("sign in")) return "Please sign in again."; | |
| if (lower.includes("offline") || lower.includes("unreachable") || lower.includes("failed to fetch")) { | |
| return "DocDoe is temporarily offline. Check your connection and try again."; | |
| } | |
| if (lower.includes("pyq") && lower.includes("question")) { | |
| return "PYQ review needs a question paper source."; | |
| } | |
| if (lower.includes("script") && lower.includes("ready")) { | |
| return "Preview render can only start after the study video script is ready."; | |
| } | |
| if (lower.includes("scene plan") || lower.includes("missing")) { | |
| return "Scene plan is missing. Create the study video plan again."; | |
| } | |
| if (lower.includes("source") || lower.includes("eligible") || lower.includes("material")) { | |
| return "Use readable notes, a chapter, syllabus, textbook, or question paper to create a study video."; | |
| } | |
| if (message.includes("Traceback") || message.includes("{") || message.length > 180) { | |
| return "Preview render failed."; | |
| } | |
| return message || "Preview render failed."; | |
| } | |
| function materialLabel(source: StudyVideoWizardSource) { | |
| const materialType = getMaterialType(source); | |
| return materialType ? materialLabels[materialType] : "Unknown source"; | |
| } | |
| function jobTitle(plan: StudyVideoPlan | null, source: StudyVideoWizardSource) { | |
| if (plan?.title) return plan.title; | |
| const materialType = getMaterialType(source); | |
| if (materialType === "question_paper") return "PYQ review video"; | |
| if (materialType === "syllabus") return "Syllabus map video"; | |
| return "Study video"; | |
| } | |
| function isStudyVideoPlan(value: unknown): value is StudyVideoPlan { | |
| if (!value || typeof value !== "object") return false; | |
| const record = value as Partial<StudyVideoPlan>; | |
| return typeof record.title === "string" && Array.isArray(record.scenes); | |
| } | |
| function normalizeScene(scene: Partial<StudyVideoScene>, index: number): StudyVideoScene { | |
| const id = typeof scene.id === "string" && scene.id.trim() ? scene.id : `scene_${index + 1}`; | |
| const title = typeof scene.title === "string" && scene.title.trim() ? scene.title : `Scene ${index + 1}`; | |
| const narration = typeof scene.narration === "string" ? scene.narration : ""; | |
| return { | |
| id, | |
| type: typeof scene.type === "string" && scene.type.trim() ? scene.type : "explanation", | |
| title, | |
| narration, | |
| duration_seconds: typeof scene.duration_seconds === "number" && Number.isFinite(scene.duration_seconds) | |
| ? scene.duration_seconds | |
| : 30, | |
| visual_hint: typeof scene.visual_hint === "string" ? scene.visual_hint : "", | |
| on_screen_text: typeof scene.on_screen_text === "string" && scene.on_screen_text.trim() | |
| ? scene.on_screen_text | |
| : title, | |
| keywords: Array.isArray(scene.keywords) ? scene.keywords.filter((keyword): keyword is string => typeof keyword === "string") : [], | |
| evidence_note: typeof scene.evidence_note === "string" ? scene.evidence_note : null, | |
| animation_style: typeof scene.animation_style === "string" && scene.animation_style.trim() | |
| ? scene.animation_style | |
| : "clean_explainer", | |
| subtitle_segments: Array.isArray(scene.subtitle_segments) ? scene.subtitle_segments : [], | |
| }; | |
| } | |
| function normalizeStudyVideoPlan(value: unknown): StudyVideoPlan | null { | |
| if (!isStudyVideoPlan(value)) return null; | |
| const rawScenes = value.scenes as unknown[]; | |
| const scenes = rawScenes | |
| .filter((scene): scene is Partial<StudyVideoScene> => Boolean(scene) && typeof scene === "object") | |
| .map(normalizeScene); | |
| if (scenes.length === 0) return null; | |
| return { ...value, scenes }; | |
| } | |
| export function StudyVideoWizard({ isOpen, source, onClose, onUploadClick }: StudyVideoWizardProps) { | |
| const [stage, setStage] = useState<WizardStage>("setup"); | |
| const [durationMinutes, setDurationMinutes] = useState<VideoDurationMinutes>(10); | |
| const [mode, setMode] = useState<StudyVideoMode>("exam_study_video"); | |
| const [language, setLanguage] = useState("English"); | |
| const [format, setFormat] = useState("16:9"); | |
| const [visualStyle, setVisualStyle] = useState("clean_explainer"); | |
| const [voice, setVoice] = useState(""); | |
| const [planResponse, setPlanResponse] = useState<StudyVideoJobResponse | null>(null); | |
| const [job, setJob] = useState<JobViewState | null>(null); | |
| const [error, setError] = useState<string | null>(null); | |
| const [pollTimedOut, setPollTimedOut] = useState(false); | |
| const [expandedScenes, setExpandedScenes] = useState<Record<string, boolean>>({}); | |
| const eligibility = useMemo(() => getEligibility(source), [source]); | |
| const sourceEvidenceLabel = getSourceEvidenceLabel(source); | |
| const plan = useMemo(() => normalizeStudyVideoPlan(planResponse?.plan), [planResponse?.plan]); | |
| const currentEvidenceLabel = job?.evidence_label ?? plan?.evidence_label ?? planResponse?.evidence_label ?? sourceEvidenceLabel; | |
| const sourceContextHeader = sourceHeader(source); | |
| const evidenceNote = evidenceContextNote(currentEvidenceLabel); | |
| const previewUrl = resolveMediaUrl(job?.output_url); | |
| const progress = progressFor(job, stage); | |
| const selectedDuration = videoDurationOption(durationMinutes); | |
| const selectedSpecialMode = specialModeOptions.find((option) => option.value === mode); | |
| const jobId = job?.id ?? null; | |
| useEffect(() => { | |
| if (!isOpen) return; | |
| const resetTimeoutId = window.setTimeout(() => { | |
| const nextMode = defaultModeForSource(source); | |
| setStage("setup"); | |
| setDurationMinutes(nextMode === "pyq_review" ? 10 : 10); | |
| setMode(nextMode); | |
| setLanguage("English"); | |
| setFormat("16:9"); | |
| setVisualStyle("clean_explainer"); | |
| setVoice(""); | |
| setPlanResponse(null); | |
| setJob(null); | |
| setError(null); | |
| setPollTimedOut(false); | |
| setExpandedScenes({}); | |
| }, 0); | |
| return () => window.clearTimeout(resetTimeoutId); | |
| }, [isOpen, source]); | |
| const pollFetcher = useCallback(async () => { | |
| if (!jobId) throw new Error("No preview job is running."); | |
| return getVideoJob(jobId); | |
| }, [jobId]); | |
| const onPollData = useCallback((nextJob: VideoRenderJob) => { | |
| const nextState = toJobState(nextJob); | |
| setJob(nextState); | |
| if (nextState.status === "failed") { | |
| setStage("failed"); | |
| setError(nextState.error_message ? safeVideoError(new Error(nextState.error_message)) : "Preview render failed."); | |
| setPollTimedOut(false); | |
| return; | |
| } | |
| if (isCompleteStatus(nextState.status)) { | |
| setStage("preview_ready"); | |
| setPollTimedOut(false); | |
| return; | |
| } | |
| setStage("preview_rendering"); | |
| setPollTimedOut(false); | |
| }, []); | |
| const shouldContinuePolling = useCallback((nextJob: VideoRenderJob) => { | |
| return !isTerminalStatus(nextJob.status); | |
| }, []); | |
| const onPollingError = useCallback((caughtError: unknown) => { | |
| setStage("failed"); | |
| setError(safeVideoError(caughtError)); | |
| setPollTimedOut(false); | |
| }, []); | |
| const onPollingTimeout = useCallback(() => { | |
| setPollTimedOut(true); | |
| }, []); | |
| useJobPolling({ | |
| enabled: isOpen && stage === "preview_rendering" && Boolean(jobId) && !pollTimedOut, | |
| fetcher: pollFetcher, | |
| intervalMs: 2500, | |
| timeoutMs: previewPollingTimeoutMs, | |
| shouldContinue: shouldContinuePolling, | |
| onData: onPollData, | |
| onError: onPollingError, | |
| onTimeout: onPollingTimeout, | |
| }); | |
| if (!isOpen) return null; | |
| async function handleCreatePlan() { | |
| const latestEligibility = getEligibility(source); | |
| if (!latestEligibility.canCreate) { | |
| setError("This source is no longer eligible for study videos."); | |
| return; | |
| } | |
| if (mode === "pyq_review" && getMaterialType(source) !== "question_paper") { | |
| setError("PYQ review needs a question paper source."); | |
| return; | |
| } | |
| setStage("planning"); | |
| setError(null); | |
| setPollTimedOut(false); | |
| setPlanResponse(null); | |
| setJob(null); | |
| setExpandedScenes({}); | |
| try { | |
| const response = await createStudyVideoJob({ | |
| document_id: source.id, | |
| video_mode: mode, | |
| target_duration_seconds: targetForMode(mode, durationMinutes), | |
| language, | |
| voice: voice || null, | |
| visual_style: visualStyle, | |
| format, | |
| preview_quality: true, | |
| }); | |
| if (!normalizeStudyVideoPlan(response.plan)) { | |
| throw new Error("Scene plan is missing. Create the study video plan again."); | |
| } | |
| setPlanResponse(response); | |
| setJob(toJobState(response)); | |
| setStage("script_ready"); | |
| } catch (caughtError) { | |
| setStage("failed"); | |
| setError(safeVideoError(caughtError)); | |
| } | |
| } | |
| async function handleRenderPreview() { | |
| const latestEligibility = getEligibility(source); | |
| if (!latestEligibility.canCreate) { | |
| setError("This source is no longer eligible for study videos."); | |
| return; | |
| } | |
| if (!planResponse?.id) { | |
| setStage("failed"); | |
| setError("Scene plan is missing. Create the study video plan again."); | |
| return; | |
| } | |
| if (job?.status !== "script_ready") { | |
| setError("Preview render can only start after the study video script is ready."); | |
| return; | |
| } | |
| setStage("preview_rendering"); | |
| setError(null); | |
| setPollTimedOut(false); | |
| try { | |
| const response = await renderStudyVideoPreview(planResponse.id); | |
| const nextState = toJobState(response); | |
| setJob(nextState); | |
| if (nextState.status === "failed") { | |
| setStage("failed"); | |
| setError("Preview render failed."); | |
| setPollTimedOut(false); | |
| } else if (isCompleteStatus(nextState.status)) { | |
| setStage("preview_ready"); | |
| setPollTimedOut(false); | |
| } | |
| } catch (caughtError) { | |
| setStage("failed"); | |
| setError(safeVideoError(caughtError)); | |
| setPollTimedOut(false); | |
| } | |
| } | |
| function handleRetrySetup() { | |
| setStage("setup"); | |
| setError(null); | |
| setPollTimedOut(false); | |
| setJob(null); | |
| } | |
| function toggleScene(sceneId: string) { | |
| setExpandedScenes((current) => ({ ...current, [sceneId]: !current[sceneId] })); | |
| } | |
| return ( | |
| <div className="fixed inset-0 z-[110] flex items-end justify-center p-2 sm:items-center sm:p-4"> | |
| <button | |
| type="button" | |
| aria-label="Close study video wizard" | |
| className="absolute inset-0 bg-black/72 backdrop-blur-sm" | |
| onClick={onClose} | |
| /> | |
| <section | |
| aria-modal="true" | |
| role="dialog" | |
| className="relative flex max-h-[94dvh] w-full max-w-4xl flex-col overflow-hidden rounded-[22px] border border-white/[0.1] bg-[#050507] shadow-[0_32px_120px_rgba(0,0,0,0.68)] sm:max-h-[88dvh]" | |
| > | |
| <header className="flex shrink-0 items-center justify-between gap-3 border-b border-white/[0.08] bg-white/[0.025] px-4 py-3 sm:px-5"> | |
| <div className="flex min-w-0 items-center gap-3"> | |
| <span className="grid size-9 shrink-0 place-items-center rounded-2xl border border-white/[0.08] bg-white/[0.04] text-white/72"> | |
| <Clapperboard className="size-4.5" strokeWidth={1.8} /> | |
| </span> | |
| <div className="min-w-0"> | |
| <p className="text-[10px] font-semibold uppercase text-white/36">Study video</p> | |
| <h2 className="truncate text-[15px] font-bold text-white">Create study video</h2> | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| <StagePill stage={stage} /> | |
| <button | |
| type="button" | |
| onClick={onClose} | |
| className="grid size-9 shrink-0 place-items-center rounded-2xl border border-white/[0.08] bg-white/[0.035] text-white/48 transition hover:bg-white/[0.07] hover:text-white" | |
| aria-label="Close wizard" | |
| > | |
| <X className="size-4" strokeWidth={2} /> | |
| </button> | |
| </div> | |
| </header> | |
| <div className="min-h-0 flex-1 overflow-y-auto px-4 py-4 [scrollbar-color:rgba(255,255,255,0.14)_transparent] [scrollbar-width:thin] sm:px-5"> | |
| <div className="space-y-4"> | |
| <SourceSummaryCard | |
| source={source} | |
| sourceContextHeader={sourceContextHeader} | |
| evidenceLabel={currentEvidenceLabel} | |
| /> | |
| {!eligibility.canCreate ? ( | |
| <BlockedPanel | |
| title={eligibility.title} | |
| body={eligibility.body} | |
| cta={eligibility.cta} | |
| onUploadClick={onUploadClick} | |
| /> | |
| ) : null} | |
| {error ? ( | |
| <div className="rounded-2xl border border-rose-300/16 bg-rose-500/[0.06] px-4 py-3 text-[12px] leading-5 text-rose-50/78"> | |
| <div className="flex gap-2.5"> | |
| <AlertCircle className="mt-0.5 size-4 shrink-0 text-rose-100/70" strokeWidth={1.8} /> | |
| <span>{error}</span> | |
| </div> | |
| </div> | |
| ) : null} | |
| {stage === "setup" || stage === "planning" ? ( | |
| <SetupStep | |
| canCreate={eligibility.canCreate} | |
| durationMinutes={durationMinutes} | |
| mode={mode} | |
| language={language} | |
| format={format} | |
| visualStyle={visualStyle} | |
| voice={voice} | |
| selectedDuration={selectedDuration} | |
| selectedSpecialMode={selectedSpecialMode} | |
| materialType={getMaterialType(source)} | |
| evidenceNote={evidenceNote} | |
| isPlanning={stage === "planning"} | |
| onDurationChange={(minutes) => { | |
| setDurationMinutes(minutes); | |
| setMode(modeForDuration(minutes, getMaterialType(source))); | |
| }} | |
| onModeChange={setMode} | |
| onLanguageChange={setLanguage} | |
| onFormatChange={setFormat} | |
| onVisualStyleChange={setVisualStyle} | |
| onVoiceChange={setVoice} | |
| onCreatePlan={() => void handleCreatePlan()} | |
| /> | |
| ) : null} | |
| {stage === "script_ready" && plan ? ( | |
| <PlanReadyStep | |
| plan={plan} | |
| warnings={planResponse?.warnings ?? []} | |
| evidenceLabel={currentEvidenceLabel} | |
| evidenceNote={evidenceNote} | |
| canRender={eligibility.canCreate && job?.status === "script_ready"} | |
| expandedScenes={expandedScenes} | |
| onToggleScene={toggleScene} | |
| onRenderPreview={() => void handleRenderPreview()} | |
| onBackToSettings={handleRetrySetup} | |
| /> | |
| ) : null} | |
| {stage === "preview_rendering" ? ( | |
| <PreviewProgressStep | |
| title={jobTitle(plan, source)} | |
| job={job} | |
| progress={progress} | |
| evidenceLabel={currentEvidenceLabel} | |
| hasTimedOut={pollTimedOut} | |
| /> | |
| ) : null} | |
| {stage === "preview_ready" ? ( | |
| <PreviewReadyStep | |
| title={jobTitle(plan, source)} | |
| previewUrl={previewUrl} | |
| evidenceLabel={currentEvidenceLabel} | |
| onRetryLater={handleRetrySetup} | |
| /> | |
| ) : null} | |
| {stage === "failed" ? ( | |
| <FailedStep | |
| message={error ?? "Preview render failed."} | |
| onBackToSettings={handleRetrySetup} | |
| /> | |
| ) : null} | |
| </div> | |
| </div> | |
| </section> | |
| </div> | |
| ); | |
| } | |
| function StagePill({ stage }: { stage: WizardStage }) { | |
| const label: Record<WizardStage, string> = { | |
| setup: "Setup", | |
| planning: "Planning", | |
| script_ready: "Scene plan ready", | |
| preview_rendering: "Rendering", | |
| preview_ready: "Preview ready", | |
| failed: "Failed", | |
| }; | |
| return ( | |
| <span className="hidden rounded-full border border-white/[0.1] bg-white/[0.035] px-2.5 py-1 text-[10px] font-semibold text-white/55 sm:inline-flex"> | |
| {label[stage]} | |
| </span> | |
| ); | |
| } | |
| function SourceSummaryCard({ | |
| source, | |
| sourceContextHeader, | |
| evidenceLabel, | |
| }: { | |
| source: StudyVideoWizardSource; | |
| sourceContextHeader: string | null; | |
| evidenceLabel: string | null | undefined; | |
| }) { | |
| return ( | |
| <div className="rounded-2xl border border-white/[0.08] bg-white/[0.025] p-3.5"> | |
| <div className="flex min-w-0 flex-wrap items-start justify-between gap-3"> | |
| <div className="min-w-0"> | |
| {sourceContextHeader ? ( | |
| <p className="mb-1 truncate text-[11px] font-semibold text-white/46">{sourceContextHeader}</p> | |
| ) : null} | |
| <h3 className="break-words text-[14px] font-bold text-white/88">{source.title}</h3> | |
| <div className="mt-2 flex flex-wrap items-center gap-2"> | |
| <span className="rounded-full border border-white/[0.08] bg-black/30 px-2.5 py-1 text-[10px] font-semibold text-white/50"> | |
| {materialLabel(source)} | |
| </span> | |
| {source.subject ? ( | |
| <span className="rounded-full border border-white/[0.08] bg-black/30 px-2.5 py-1 text-[10px] font-semibold text-white/50"> | |
| {source.subject} | |
| </span> | |
| ) : null} | |
| <span className="rounded-full border border-white/[0.08] bg-black/30 px-2.5 py-1 text-[10px] font-semibold text-white/50"> | |
| {source.status === "ready" || !source.status | |
| ? "Ready" | |
| : source.status.charAt(0).toUpperCase() + source.status.slice(1)} | |
| </span> | |
| </div> | |
| </div> | |
| <EvidenceBadge label={evidenceLabel} /> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| function BlockedPanel({ | |
| title, | |
| body, | |
| cta, | |
| onUploadClick, | |
| }: { | |
| title: string; | |
| body: string; | |
| cta: string | null; | |
| onUploadClick?: () => void; | |
| }) { | |
| return ( | |
| <div className="rounded-2xl border border-white/[0.08] bg-white/[0.025] p-4"> | |
| <div className="flex gap-3"> | |
| <span className="grid size-9 shrink-0 place-items-center rounded-2xl border border-white/[0.08] bg-black/30 text-white/46"> | |
| <Lock className="size-4" strokeWidth={1.8} /> | |
| </span> | |
| <div className="min-w-0"> | |
| <h3 className="text-[14px] font-bold text-white/86">{title}</h3> | |
| <p className="mt-1 text-[12px] leading-5 text-white/46">{body}</p> | |
| {cta && onUploadClick ? ( | |
| <button | |
| type="button" | |
| onClick={onUploadClick} | |
| className="mt-3 inline-flex h-9 items-center justify-center rounded-2xl border border-white/[0.12] bg-white/[0.06] px-3 text-[12px] font-bold text-white/74 transition hover:bg-white/[0.1] hover:text-white" | |
| > | |
| {cta} | |
| </button> | |
| ) : null} | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| function SetupStep({ | |
| canCreate, | |
| durationMinutes, | |
| mode, | |
| language, | |
| format, | |
| visualStyle, | |
| voice, | |
| selectedDuration, | |
| selectedSpecialMode, | |
| materialType, | |
| evidenceNote, | |
| isPlanning, | |
| onDurationChange, | |
| onModeChange, | |
| onLanguageChange, | |
| onFormatChange, | |
| onVisualStyleChange, | |
| onVoiceChange, | |
| onCreatePlan, | |
| }: { | |
| canCreate: boolean; | |
| durationMinutes: VideoDurationMinutes; | |
| mode: StudyVideoMode; | |
| language: string; | |
| format: string; | |
| visualStyle: string; | |
| voice: string; | |
| selectedDuration: (typeof VIDEO_DURATION_OPTIONS)[number]; | |
| selectedSpecialMode?: (typeof specialModeOptions)[number]; | |
| materialType?: MaterialType | null; | |
| evidenceNote: string | null; | |
| isPlanning: boolean; | |
| onDurationChange: (minutes: VideoDurationMinutes) => void; | |
| onModeChange: (mode: StudyVideoMode) => void; | |
| onLanguageChange: (language: string) => void; | |
| onFormatChange: (format: string) => void; | |
| onVisualStyleChange: (style: string) => void; | |
| onVoiceChange: (voice: string) => void; | |
| onCreatePlan: () => void; | |
| }) { | |
| return ( | |
| <div className="space-y-4"> | |
| <div> | |
| <h3 className="text-[18px] font-bold text-white">Create study video</h3> | |
| <p className="mt-1 max-w-2xl text-[12px] leading-5 text-white/48"> | |
| DocDoe will turn this source into a short exam-focused video plan, then render a low-quality preview. | |
| </p> | |
| </div> | |
| {evidenceNote ? ( | |
| <p className="rounded-2xl border border-white/[0.08] bg-black/28 px-3.5 py-3 text-[12px] leading-5 text-white/50"> | |
| {evidenceNote} | |
| </p> | |
| ) : null} | |
| <div> | |
| <p className="mb-2 text-[11px] font-semibold uppercase text-white/36">Video length</p> | |
| <div className="grid gap-2 sm:grid-cols-3"> | |
| {VIDEO_DURATION_OPTIONS.map((option) => { | |
| const active = durationMinutes === option.minutes && !selectedSpecialMode; | |
| return ( | |
| <button | |
| key={option.minutes} | |
| type="button" | |
| onClick={() => onDurationChange(option.minutes)} | |
| className={cn( | |
| "min-h-[88px] rounded-2xl border p-3 text-left transition", | |
| active | |
| ? "border-white/[0.22] bg-white/[0.09] text-white" | |
| : "border-white/[0.08] bg-white/[0.025] text-white/66 hover:border-white/[0.14] hover:bg-white/[0.045]", | |
| )} | |
| > | |
| <span className="block text-[1.35rem] font-bold">{option.minutes} min</span> | |
| <span className="mt-1 block text-[11px] leading-4 text-white/42">{option.helper}</span> | |
| </button> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| <div> | |
| <p className="mb-2 text-[11px] font-semibold uppercase text-white/36">Special formats</p> | |
| <div className="grid gap-2 sm:grid-cols-2"> | |
| {specialModeOptions.map((option) => { | |
| const disabled = option.value === "pyq_review" && materialType !== "question_paper"; | |
| const active = mode === option.value; | |
| return ( | |
| <button | |
| key={option.value} | |
| type="button" | |
| disabled={disabled} | |
| onClick={() => onModeChange(option.value)} | |
| className={cn( | |
| "min-h-[72px] rounded-2xl border p-3 text-left transition disabled:cursor-not-allowed disabled:opacity-45", | |
| active | |
| ? "border-white/[0.22] bg-white/[0.09] text-white" | |
| : "border-white/[0.08] bg-white/[0.025] text-white/66 hover:border-white/[0.14] hover:bg-white/[0.045]", | |
| )} | |
| > | |
| <span className="block text-[12.5px] font-bold">{option.label}</span> | |
| <span className="mt-1 block text-[11px] leading-4 text-white/42"> | |
| {disabled ? "PYQ review needs a question paper source." : option.helper} | |
| </span> | |
| </button> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| <div className="grid gap-3 sm:grid-cols-2"> | |
| <SettingGroup label="Language"> | |
| <SegmentedControl value={language} options={languageOptions} onChange={onLanguageChange} /> | |
| </SettingGroup> | |
| <SettingGroup label="Format"> | |
| <SegmentedControl | |
| value={format} | |
| options={formatOptions.map((option) => option.value)} | |
| labels={Object.fromEntries(formatOptions.map((option) => [option.value, option.label]))} | |
| onChange={onFormatChange} | |
| /> | |
| </SettingGroup> | |
| <SettingGroup label="Visual style"> | |
| <SelectControl | |
| value={visualStyle} | |
| options={visualStyleOptions} | |
| onChange={onVisualStyleChange} | |
| /> | |
| </SettingGroup> | |
| <SettingGroup label="Voice"> | |
| <SelectControl | |
| value={voice} | |
| options={voiceOptions} | |
| onChange={onVoiceChange} | |
| /> | |
| </SettingGroup> | |
| </div> | |
| <div className="flex flex-col gap-3 rounded-2xl border border-white/[0.08] bg-white/[0.025] p-3 sm:flex-row sm:items-center sm:justify-between"> | |
| <div> | |
| <p className="text-[12px] font-bold text-white/78"> | |
| {selectedSpecialMode?.label ?? selectedDuration.label} | |
| </p> | |
| <p className="mt-1 text-[11px] text-white/38"> | |
| Target duration: {Math.round((selectedSpecialMode?.target ?? selectedDuration.targetSeconds) / 60)} minutes | |
| </p> | |
| </div> | |
| <button | |
| type="button" | |
| disabled={!canCreate || isPlanning} | |
| onClick={onCreatePlan} | |
| className="inline-flex h-10 items-center justify-center gap-2 rounded-2xl border border-white/[0.14] bg-white/[0.08] px-4 text-[12px] font-bold text-white/84 transition hover:bg-white/[0.13] disabled:cursor-not-allowed disabled:opacity-45" | |
| > | |
| {isPlanning ? ( | |
| <> | |
| <LoaderCircle className="size-4 animate-spin" strokeWidth={1.8} /> | |
| Building video plan... | |
| </> | |
| ) : ( | |
| "Create scene plan" | |
| )} | |
| </button> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| function SettingGroup({ label, children }: { label: string; children: ReactNode }) { | |
| return ( | |
| <div className="min-w-0 rounded-2xl border border-white/[0.08] bg-white/[0.025] p-3"> | |
| <p className="mb-2 text-[10px] font-semibold uppercase text-white/34">{label}</p> | |
| {children} | |
| </div> | |
| ); | |
| } | |
| function SegmentedControl({ | |
| value, | |
| options, | |
| labels, | |
| onChange, | |
| }: { | |
| value: string; | |
| options: string[]; | |
| labels?: Record<string, string>; | |
| onChange: (value: string) => void; | |
| }) { | |
| return ( | |
| <div className="flex min-w-0 flex-wrap gap-1.5"> | |
| {options.map((option) => ( | |
| <button | |
| key={option} | |
| type="button" | |
| onClick={() => onChange(option)} | |
| className={cn( | |
| "min-h-9 rounded-xl border px-2.5 text-[11px] font-semibold transition", | |
| value === option | |
| ? "border-white/[0.18] bg-white/[0.1] text-white" | |
| : "border-white/[0.08] bg-black/24 text-white/50 hover:bg-white/[0.05] hover:text-white/72", | |
| )} | |
| > | |
| {labels?.[option] ?? option} | |
| </button> | |
| ))} | |
| </div> | |
| ); | |
| } | |
| function SelectControl({ | |
| value, | |
| options, | |
| onChange, | |
| }: { | |
| value: string; | |
| options: { value: string; label: string }[]; | |
| onChange: (value: string) => void; | |
| }) { | |
| return ( | |
| <label className="relative block"> | |
| <select | |
| value={value} | |
| onChange={(event) => onChange(event.target.value)} | |
| className="h-10 w-full appearance-none rounded-xl border border-white/[0.08] bg-black/36 px-3 pr-9 text-[12px] font-semibold text-white/72 outline-none transition focus:border-white/[0.18]" | |
| > | |
| {options.map((option) => ( | |
| <option key={option.value} value={option.value}> | |
| {option.label} | |
| </option> | |
| ))} | |
| </select> | |
| <ChevronDown className="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-white/36" strokeWidth={1.8} /> | |
| </label> | |
| ); | |
| } | |
| function PlanReadyStep({ | |
| plan, | |
| warnings, | |
| evidenceLabel, | |
| evidenceNote, | |
| canRender, | |
| expandedScenes, | |
| onToggleScene, | |
| onRenderPreview, | |
| onBackToSettings, | |
| }: { | |
| plan: StudyVideoPlan; | |
| warnings: string[]; | |
| evidenceLabel: string | null | undefined; | |
| evidenceNote: string | null; | |
| canRender: boolean; | |
| expandedScenes: Record<string, boolean>; | |
| onToggleScene: (sceneId: string) => void; | |
| onRenderPreview: () => void; | |
| onBackToSettings: () => void; | |
| }) { | |
| return ( | |
| <div className="space-y-4"> | |
| <div className="rounded-2xl border border-white/[0.08] bg-white/[0.025] p-4"> | |
| <div className="flex flex-wrap items-start justify-between gap-3"> | |
| <div className="min-w-0"> | |
| <p className="text-[10px] font-semibold uppercase text-white/34">Scene plan ready</p> | |
| <h3 className="mt-1 break-words text-[18px] font-bold text-white">{plan.title}</h3> | |
| </div> | |
| <EvidenceBadge label={evidenceLabel} /> | |
| </div> | |
| <div className="mt-3 grid gap-2 sm:grid-cols-3"> | |
| <Metric label="Target" value={`${Math.round(plan.target_duration_seconds / 60)} min`} /> | |
| <Metric label="Scene time" value={`${plan.total_duration_seconds}s`} /> | |
| <Metric label="Scenes" value={String(plan.scenes.length)} /> | |
| </div> | |
| {plan.caution_note ? ( | |
| <p className="mt-3 rounded-xl border border-white/[0.08] bg-black/28 px-3 py-2 text-[12px] leading-5 text-white/50"> | |
| {plan.caution_note} | |
| </p> | |
| ) : null} | |
| {evidenceNote ? ( | |
| <p className="mt-3 text-[12px] leading-5 text-white/46">{evidenceNote}</p> | |
| ) : null} | |
| <p className="mt-3 text-[12px] leading-5 text-white/52"> | |
| Next: {plan.next_study_action} | |
| </p> | |
| {warnings.length > 0 ? ( | |
| <div className="mt-3 space-y-1.5"> | |
| {warnings.map((warning) => ( | |
| <p key={warning} className="text-[11px] leading-4 text-amber-100/70"> | |
| {warning} | |
| </p> | |
| ))} | |
| </div> | |
| ) : null} | |
| </div> | |
| <div className="space-y-2.5"> | |
| {plan.scenes.map((scene, index) => ( | |
| <article | |
| key={scene.id} | |
| className="rounded-2xl border border-white/[0.08] bg-white/[0.025] p-3.5" | |
| > | |
| <div className="flex flex-wrap items-start justify-between gap-3"> | |
| <div className="min-w-0"> | |
| <p className="text-[10px] font-semibold uppercase text-white/34"> | |
| Scene {index + 1} - {scene.type.replace(/_/g, " ")} | |
| </p> | |
| <h4 className="mt-1 break-words text-[13px] font-bold text-white/84">{scene.title}</h4> | |
| </div> | |
| <span className="rounded-full border border-white/[0.08] bg-black/28 px-2.5 py-1 text-[10px] font-semibold text-white/50"> | |
| {scene.duration_seconds}s | |
| </span> | |
| </div> | |
| <p className="mt-3 break-words rounded-xl border border-white/[0.06] bg-black/26 px-3 py-2 text-[12px] leading-5 text-white/58"> | |
| {scene.on_screen_text} | |
| </p> | |
| {scene.evidence_note ? ( | |
| <p className="mt-2 text-[11px] leading-4 text-white/40">{scene.evidence_note}</p> | |
| ) : null} | |
| {scene.keywords && scene.keywords.length > 0 ? ( | |
| <div className="mt-3 flex flex-wrap gap-1.5"> | |
| {scene.keywords.slice(0, 8).map((keyword) => ( | |
| <span | |
| key={keyword} | |
| className="rounded-full border border-white/[0.08] bg-black/24 px-2 py-0.5 text-[10px] font-semibold text-white/42" | |
| > | |
| {keyword} | |
| </span> | |
| ))} | |
| </div> | |
| ) : null} | |
| <button | |
| type="button" | |
| onClick={() => onToggleScene(scene.id)} | |
| className="mt-3 inline-flex h-8 items-center gap-1.5 rounded-xl border border-white/[0.08] bg-white/[0.025] px-3 text-[11px] font-bold text-white/54 transition hover:bg-white/[0.06] hover:text-white/74" | |
| > | |
| {expandedScenes[scene.id] ? "Hide narration" : "View narration"} | |
| </button> | |
| {expandedScenes[scene.id] ? ( | |
| <p className="mt-2 whitespace-pre-wrap break-words rounded-xl border border-white/[0.06] bg-black/30 px-3 py-2 text-[12px] leading-5 text-white/50"> | |
| {scene.narration} | |
| </p> | |
| ) : null} | |
| </article> | |
| ))} | |
| </div> | |
| <div className="flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-end"> | |
| <button | |
| type="button" | |
| onClick={onBackToSettings} | |
| className="inline-flex h-10 items-center justify-center rounded-2xl border border-white/[0.09] bg-white/[0.025] px-4 text-[12px] font-bold text-white/58 transition hover:bg-white/[0.06] hover:text-white/78" | |
| > | |
| Back to settings | |
| </button> | |
| <button | |
| type="button" | |
| disabled={!canRender} | |
| onClick={onRenderPreview} | |
| className="inline-flex h-10 items-center justify-center gap-2 rounded-2xl border border-white/[0.16] bg-white/[0.09] px-4 text-[12px] font-bold text-white/88 transition hover:bg-white/[0.14] disabled:cursor-not-allowed disabled:opacity-45" | |
| > | |
| Render preview | |
| </button> | |
| </div> | |
| <AnswerTrustFooter /> | |
| </div> | |
| ); | |
| } | |
| function Metric({ label, value }: { label: string; value: string }) { | |
| return ( | |
| <div className="rounded-xl border border-white/[0.07] bg-black/24 px-3 py-2"> | |
| <p className="text-[10px] font-semibold uppercase text-white/32">{label}</p> | |
| <p className="mt-1 text-[13px] font-bold text-white/78">{value}</p> | |
| </div> | |
| ); | |
| } | |
| // Friendly render pipeline. Each stage maps to a backend status so the student | |
| // sees real movement (and which step is live) instead of one vague bar. | |
| const RENDER_STAGES: { key: string; label: string; statuses: string[] }[] = [ | |
| { key: "script", label: "Writing the script", statuses: ["queued", "script_ready", "planning"] }, | |
| { key: "audio", label: "Recording the narration", statuses: ["audio_generating"] }, | |
| { key: "render", label: "Drawing the video", statuses: ["rendering", "running", "visuals_generating"] }, | |
| { key: "finish", label: "Finishing up", statuses: ["uploading"] }, | |
| ]; | |
| function renderStageIndex(status: string | null | undefined): number { | |
| const s = (status ?? "").toLowerCase(); | |
| if (isCompleteStatus(s)) return RENDER_STAGES.length; // all done | |
| const found = RENDER_STAGES.findIndex((stage) => stage.statuses.includes(s)); | |
| return found === -1 ? 0 : found; | |
| } | |
| function ElapsedTime() { | |
| const [seconds, setSeconds] = useState(0); | |
| useEffect(() => { | |
| const t = window.setInterval(() => setSeconds((value) => value + 1), 1000); | |
| return () => window.clearInterval(t); | |
| }, []); | |
| const mm = String(Math.floor(seconds / 60)).padStart(2, "0"); | |
| const ss = String(seconds % 60).padStart(2, "0"); | |
| return <span className="tabular-nums">{mm}:{ss}</span>; | |
| } | |
| function PreviewProgressStep({ | |
| title, | |
| job, | |
| progress, | |
| evidenceLabel, | |
| hasTimedOut, | |
| }: { | |
| title: string; | |
| job: JobViewState | null; | |
| progress: number; | |
| evidenceLabel: string | null | undefined; | |
| hasTimedOut: boolean; | |
| }) { | |
| const activeIndex = renderStageIndex(job?.status); | |
| return ( | |
| <div className="rounded-2xl border border-white/[0.08] bg-white/[0.025] p-4"> | |
| <div className="flex flex-wrap items-start justify-between gap-3"> | |
| <div> | |
| <p className="text-[10px] font-semibold uppercase text-white/34">Building your video</p> | |
| <h3 className="mt-1 text-[18px] font-bold text-white">{title}</h3> | |
| </div> | |
| <EvidenceBadge label={evidenceLabel} /> | |
| </div> | |
| <div className="mt-3 flex items-center gap-2 text-[11px] font-semibold text-white/42"> | |
| <span className="inline-flex size-1.5 rounded-full bg-white/50 animate-pulse" /> | |
| <span>Working for <ElapsedTime /></span> | |
| <span className="ml-auto tabular-nums text-white/30">{progress}%</span> | |
| </div> | |
| <div className="mt-3 h-1.5 overflow-hidden rounded-full bg-white/[0.06]"> | |
| <div | |
| className="h-full rounded-full bg-white/60 transition-[width] duration-700" | |
| style={{ width: `${progress}%` }} | |
| /> | |
| </div> | |
| {/* Live stage checklist */} | |
| <ol className="mt-5 space-y-2.5"> | |
| {RENDER_STAGES.map((stage, index) => { | |
| const done = index < activeIndex; | |
| const active = index === activeIndex; | |
| return ( | |
| <li key={stage.key} className="flex items-center gap-3"> | |
| <span | |
| className={cn( | |
| "grid size-5 shrink-0 place-items-center rounded-full border", | |
| done | |
| ? "border-white/40 bg-white/85 text-black" | |
| : active | |
| ? "border-white/35 bg-white/[0.06]" | |
| : "border-white/12 bg-transparent", | |
| )} | |
| > | |
| {done ? ( | |
| <Check className="size-3" strokeWidth={3} /> | |
| ) : active ? ( | |
| <LoaderCircle className="size-3 animate-spin text-white/80" strokeWidth={2.4} /> | |
| ) : ( | |
| <span className="size-1 rounded-full bg-white/25" /> | |
| )} | |
| </span> | |
| <span | |
| className={cn( | |
| "text-[13px]", | |
| done ? "font-medium text-white/55" : active ? "font-bold text-white" : "text-white/35", | |
| )} | |
| > | |
| {stage.label} | |
| </span> | |
| </li> | |
| ); | |
| })} | |
| </ol> | |
| <p className="mt-4 text-[11px] leading-4 text-white/38"> | |
| {hasTimedOut | |
| ? previewTimeoutCopy | |
| : "Keep this open. A short video takes about a minute; longer ones take a few."} | |
| </p> | |
| </div> | |
| ); | |
| } | |
| function PreviewReadyStep({ | |
| title, | |
| previewUrl, | |
| evidenceLabel, | |
| onRetryLater, | |
| }: { | |
| title: string; | |
| previewUrl: string | null; | |
| evidenceLabel: string | null | undefined; | |
| onRetryLater: () => void; | |
| }) { | |
| const durationLabel = "Preview"; | |
| return ( | |
| <div className="space-y-4"> | |
| <div className="rounded-2xl border border-white/[0.08] bg-white/[0.025] p-4"> | |
| <div className="flex flex-wrap items-start justify-between gap-3"> | |
| <div> | |
| <p className="text-[10px] font-semibold uppercase text-white/34">{durationLabel}</p> | |
| <h3 className="mt-1 text-[18px] font-bold text-white">Preview video ready</h3> | |
| <p className="mt-1 max-w-xl text-[12px] leading-5 text-white/46">{title}</p> | |
| </div> | |
| <EvidenceBadge label={evidenceLabel} /> | |
| </div> | |
| {previewUrl ? ( | |
| <div className="mt-4 overflow-hidden rounded-2xl border border-white/[0.09] bg-black"> | |
| <video | |
| className="aspect-video w-full max-h-[400px] bg-black object-contain sm:max-h-[480px]" | |
| controls | |
| playsInline | |
| preload="metadata" | |
| src={previewUrl} | |
| /> | |
| </div> | |
| ) : ( | |
| <div className="mt-4 rounded-2xl border border-white/[0.08] bg-black/30 p-4 text-[12px] leading-5 text-white/52"> | |
| <p>Preview finished, but no video file was returned.</p> | |
| <button | |
| type="button" | |
| onClick={onRetryLater} | |
| className="mt-3 inline-flex h-9 items-center justify-center rounded-2xl border border-white/[0.1] bg-white/[0.04] px-3 text-[12px] font-bold text-white/62 transition hover:bg-white/[0.08] hover:text-white" | |
| > | |
| Try again later | |
| </button> | |
| </div> | |
| )} | |
| <p className="mt-3 text-[12px] leading-5 text-white/44"> | |
| This is a low-quality preview. Final render comes later. | |
| </p> | |
| <div className="mt-3 flex flex-wrap gap-2"> | |
| {previewUrl ? ( | |
| <> | |
| <a | |
| href={previewUrl} | |
| target="_blank" | |
| rel="noreferrer" | |
| className="inline-flex h-9 items-center gap-2 rounded-2xl border border-white/[0.1] bg-white/[0.04] px-3 text-[12px] font-bold text-white/64 transition hover:bg-white/[0.08] hover:text-white" | |
| > | |
| <ExternalLink className="size-3.5" strokeWidth={1.8} /> | |
| Open | |
| </a> | |
| <a | |
| href={previewUrl} | |
| download | |
| className="inline-flex h-9 items-center gap-2 rounded-2xl border border-white/[0.1] bg-white/[0.04] px-3 text-[12px] font-bold text-white/64 transition hover:bg-white/[0.08] hover:text-white" | |
| > | |
| <Download className="size-3.5" strokeWidth={1.8} /> | |
| Download | |
| </a> | |
| </> | |
| ) : null} | |
| <button | |
| type="button" | |
| disabled | |
| aria-disabled="true" | |
| className="inline-flex h-9 cursor-not-allowed items-center justify-center rounded-2xl border border-white/[0.07] bg-white/[0.02] px-3 text-[12px] font-bold text-white/32" | |
| > | |
| Final render coming next | |
| </button> | |
| </div> | |
| </div> | |
| <AnswerTrustFooter /> | |
| </div> | |
| ); | |
| } | |
| function FailedStep({ | |
| message, | |
| onBackToSettings, | |
| }: { | |
| message: string; | |
| onBackToSettings: () => void; | |
| }) { | |
| return ( | |
| <div className="rounded-2xl border border-white/[0.08] bg-white/[0.025] p-4"> | |
| <div className="flex gap-3"> | |
| <span className="grid size-10 shrink-0 place-items-center rounded-2xl border border-white/[0.08] bg-black/30 text-white/50"> | |
| <AlertCircle className="size-5" strokeWidth={1.8} /> | |
| </span> | |
| <div className="min-w-0"> | |
| <h3 className="text-[16px] font-bold text-white">Preview render failed</h3> | |
| <p className="mt-1 text-[12px] leading-5 text-white/48">{message}</p> | |
| <button | |
| type="button" | |
| onClick={onBackToSettings} | |
| className="mt-4 inline-flex h-9 items-center gap-2 rounded-2xl border border-white/[0.1] bg-white/[0.045] px-3 text-[12px] font-bold text-white/68 transition hover:bg-white/[0.08] hover:text-white" | |
| > | |
| <RotateCcw className="size-3.5" strokeWidth={1.8} /> | |
| Back to settings | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |