"use client"; import { useRouter } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { AiDisclaimer } from "@/components/AiDisclaimer"; import { ApiError, generatePodcast, type GenerateProgress } from "@/lib/client-api"; import { DOCUMENT_ACCEPT_ATTR, KIND_LABELS, describeDocumentError, documentKindOf, extractDocument, isDocumentFile, type DocumentKind, type ExtractedDocument, } from "@/lib/documents"; import { ACCEPTED_TYPES, MAX_IMAGES, describeFileError, imageFilesFromDataTransfer, isImageFile, prepareImage, type PreparedImage, } from "@/lib/image"; import { useI18n } from "@/lib/i18n"; import { ModelErrorActions } from "@/components/ModelErrorActions"; import { takePendingShare } from "@/lib/share-pending"; import { upsertPodcast } from "@/lib/podcasts"; import { useIsPhone } from "@/lib/use-phone"; import { useOnline } from "@/lib/use-online"; import { uid } from "@/lib/utils"; import { wizardBusy } from "@/lib/wizard-busy"; import { CameraIcon, ClockIcon, MusicIcon, PlusIcon, SparklesIcon, XIcon } from "@/components/icons"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui"; import { LoadingState, type LoadingStage } from "@/components/LoadingState"; import { WizardActionBar } from "@/components/WizardActionBar"; type Phase = "idle" | "loading" | "error"; type DocItem = { id: string; name: string; kind: DocumentKind; status: "extracting" | "ready" | "error"; progress?: string; extracted?: ExtractedDocument; error?: string; }; const MAX_DOCUMENTS = 6; const MAX_IMAGES_TO_SEND = 12; const MAX_TEXT_TO_SEND = 100_000; /** Episode lengths, in minutes; the prompt turns these into a word target. */ const LENGTH_OPTIONS = [2, 4, 7] as const; const PODCAST_STAGES: LoadingStage[] = [ { id: "vision", labelKey: "podcast.loading.read1", messageKeys: ["podcast.loading.read1", "podcast.loading.read2"] }, { id: "generation", labelKey: "podcast.loading.write1", messageKeys: ["podcast.loading.write1", "podcast.loading.write2", "podcast.loading.write3"] }, ]; const LOADING_TIPS = ["podcast.tip1", "podcast.tip2"]; /** * Podcast mode: the same notes intake as the deck wizard, but the output is a * spoken episode. One screen — the choices are only "how long" and an optional * topic hint — so it does not need the deck wizard's three steps. */ export function PodcastWizard() { const router = useRouter(); const { t, plural } = useI18n(); const isPhone = useIsPhone(); const online = useOnline(); const [images, setImages] = useState([]); const [documents, setDocuments] = useState([]); const [text, setText] = useState(""); const [focus, setFocus] = useState(""); const [minutes, setMinutes] = useState(4); const [phase, setPhase] = useState("idle"); const [error, setError] = useState<{ message: string; kind?: string } | null>(null); const [fileError, setFileError] = useState(null); const [dragging, setDragging] = useState(false); const [progress, setProgress] = useState({ stage: "generation", message: "" }); const fileInput = useRef(null); const docInput = useRef(null); const cameraInput = useRef(null); const abortRef = useRef(null); const documentText = useMemo( () => documents .filter((entry) => entry.status === "ready" && entry.extracted) .map((entry) => entry.extracted?.text ?? "") .filter(Boolean) .join("\n\n"), [documents], ); const imagesToSend = useMemo( () => [ ...images.map((image) => image.dataUrl), ...documents.flatMap((entry) => (entry.extracted?.images ?? []).map((image) => image.dataUrl)), ].slice(0, MAX_IMAGES_TO_SEND), [images, documents], ); const mergedText = [text.trim(), documentText.trim()].filter(Boolean).join("\n\n").slice(0, MAX_TEXT_TO_SEND); const canGenerate = online && phase !== "loading" && (imagesToSend.length > 0 || mergedText.length > 0); const extracting = documents.some((entry) => entry.status === "extracting"); useEffect(() => { wizardBusy.set(phase === "loading"); return () => wizardBusy.set(false); }, [phase]); const addImages = useCallback( async (files: File[]) => { setFileError(null); const room = MAX_IMAGES - images.length; if (room <= 0) { setFileError(t("upload.tooManyPhotos", { n: MAX_IMAGES })); return; } const accepted = files.slice(0, room); for (const file of accepted) { const failure = describeFileError(file); if (failure) { setFileError(failure); continue; } try { const prepared = await prepareImage(file); setImages((current) => (current.length >= MAX_IMAGES ? current : [...current, prepared])); } catch (error) { setFileError(error instanceof Error ? error.message : t("upload.couldNotReadFile")); } } if (files.length > room) setFileError(t("upload.tooManyPhotos", { n: MAX_IMAGES })); }, [images.length, t], ); const addDocuments = useCallback( async (files: File[]) => { setFileError(null); for (const file of files) { const kind = documentKindOf(file); if (!kind) { setFileError(describeDocumentError(file)); continue; } if (documents.length >= MAX_DOCUMENTS) { setFileError(t("upload.tooManyDocs", { n: MAX_DOCUMENTS })); break; } const id = uid("doc"); setDocuments((current) => [ ...current, { id, name: file.name, kind, status: "extracting", progress: t("upload.readingDocs") }, ]); try { const extracted = await extractDocument(file, (update) => setDocuments((current) => current.map((entry) => (entry.id === id ? { ...entry, progress: update.message } : entry)), ), ); setDocuments((current) => current.map((entry) => (entry.id === id ? { ...entry, status: "ready", extracted } : entry)), ); } catch (error) { setDocuments((current) => current.map((entry) => entry.id === id ? { ...entry, status: "error", error: error instanceof Error ? error.message : t("upload.couldNotReadDoc"), } : entry, ), ); } } }, [documents.length, t], ); /* A share into the app (web share target) lands here as pending notes. */ useEffect(() => { const pending = takePendingShare(); if (!pending) return; if (pending.text) setText((current) => (current ? `${current}\n\n${pending.text}` : pending.text)); if (pending.files.length > 0) void addImages(pending.files.filter(isImageFile)); }, [addImages]); const generate = async () => { if (!canGenerate) return; setError(null); setPhase("loading"); setProgress({ stage: imagesToSend.length ? "vision" : "generation", message: imagesToSend.length ? t("podcast.loading.read1") : t("podcast.loading.write1"), }); const controller = new AbortController(); abortRef.current = controller; try { const { podcast } = await generatePodcast( { images: imagesToSend.length ? imagesToSend : undefined, text: mergedText || undefined, minutes, focus: focus.trim() || undefined, }, { onProgress: setProgress, signal: controller.signal }, ); const now = Date.now(); const stored = { ...podcast, id: uid("pod"), createdAt: now, updatedAt: now, }; upsertPodcast(stored); router.push(`/podcast/${stored.id}`); } catch (generateError) { setError({ message: generateError instanceof ApiError ? generateError.message : t("upload.genericError"), kind: generateError instanceof ApiError ? generateError.kind : undefined, }); setPhase("error"); } finally { abortRef.current = null; } }; if (phase === "loading") { return ( { abortRef.current?.abort(); setPhase("idle"); }} /> ); } const notesReady = imagesToSend.length > 0 || mergedText.length > 0; const dropzone = ( ); return (

{t("podcast.title")}

{t("podcast.subtitle")}

{dropzone}
{isPhone ? ( ) : null}
{images.length > 0 || documents.length > 0 ? (
    {images.map((image) => (
  • {/* eslint-disable-next-line @next/next/no-img-element */}
  • ))} {documents.map((entry) => (
  • {KIND_LABELS[entry.kind]} {entry.name} {entry.status === "extracting" ? entry.progress ?? t("upload.readingDocs") : null} {entry.status === "error" ? entry.error : null}
  • ))}
) : null} {fileError ? (

{fileError}

) : null}

{t("podcast.lengthAria")}

{LENGTH_OPTIONS.map((option) => ( ))}

{t("podcast.lengthHelp")}

setFocus(event.target.value)} placeholder={t("upload.topicPlaceholder")} inputMode="text" enterKeyHint="done" autoComplete="off" autoCapitalize="sentences" spellCheck={false} className="w-full rounded-2xl border border-[var(--hairline)] bg-[var(--surface-strong)] px-4 py-3 text-sm text-[var(--text)] outline-none transition placeholder:text-[var(--text-subtle)] focus:border-accent-400" />