"use client"; import { motion } from "framer-motion"; import { useAppReducedMotion as useReducedMotion } from "@/lib/motion-pref"; import { useRouter } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { AiDisclaimer } from "@/components/AiDisclaimer"; import { ApiError, generateDeck, type GenerateProgress } from "@/lib/client-api"; import { DOCUMENT_ACCEPT_ATTR, KIND_LABELS, describeDocumentError, describeExtraction, documentKindOf, documentsToText, extractDocument, isDocumentFile, type DocumentKind, type ExtractedDocument, } from "@/lib/documents"; import { ACCEPTED_TYPES, MAX_IMAGES, describeFileError, formatBytes, imageFilesFromDataTransfer, isImageFile, prepareImage, type PreparedImage, } from "@/lib/image"; import { createFolder, folderStore } from "@/lib/folders"; import { haptic } from "@/lib/haptics"; import { useI18n } from "@/lib/i18n"; import { stepVariants } from "@/lib/motion"; import { UNORGANIZED_ID, type Difficulty } from "@/lib/schemas"; import { takePendingShare } from "@/lib/share-pending"; import { takeDeckSeed } from "@/lib/deck-seed"; import { upsertDeck } from "@/lib/storage"; import { showToast } from "@/lib/toast"; import { openSettings } from "@/lib/ui-events"; import { modelErrorCopy } from "@/lib/model-errors"; import { ModelErrorActions } from "@/components/ModelErrorActions"; import { useBackClose } from "@/lib/use-back-close"; import { useIsPhone } from "@/lib/use-phone"; import { useOnline } from "@/lib/use-online"; import { clearWizardDraft, loadWizardDraft, saveWizardDraft } from "@/lib/wizard-draft"; import { wizardBusy } from "@/lib/wizard-busy"; import { cn, uid } from "@/lib/utils"; import { CameraIcon, ChevronDownIcon, FolderIcon, IceCubeIcon, PlusIcon, SnowflakeIcon, SparklesIcon, SunIcon } from "@/components/icons"; import { SegmentedPill } from "@/components/SegmentedPill"; import { Button } from "@/components/ui"; import { LoadingState, type LoadingStage } from "@/components/LoadingState"; import { WizardActionBar } from "@/components/WizardActionBar"; type Phase = "idle" | "preparing" | "loading" | "error"; /** A document the user added, with its client-side extraction state. */ type DocItem = { id: string; name: string; kind: DocumentKind; status: "extracting" | "ready" | "error"; progress?: string; extracted?: ExtractedDocument; error?: string; }; const MAX_DOCUMENTS = 6; /** Photos + rendered PDF pages + images embedded in documents. */ const MAX_IMAGES_TO_SEND = 12; /** Cap for the merged text sent to the API. */ const MAX_TEXT_TO_SEND = 100_000; const DIFFICULTIES: Array<{ value: Difficulty; labelKey: string; icon: React.ReactNode; blurbKey: string }> = [ { value: "easy", labelKey: "upload.easy", icon: , blurbKey: "upload.easyBlurb" }, { value: "medium", labelKey: "upload.medium", icon: , blurbKey: "upload.mediumBlurb", }, { value: "hard", labelKey: "upload.hard", icon: , blurbKey: "upload.hardBlurb" }, ]; const COUNT_OPTIONS = [8, 10, 12, 15]; const GENERATION_TIPS = ["upload.tip1", "upload.tip2", "upload.tip3", "upload.tip4"]; /** Same loading screen as the rest of the app (components/LoadingState). */ const UPLOAD_STAGES: LoadingStage[] = [ { id: "vision", labelKey: "upload.vision1", messageKeys: ["upload.vision1", "upload.vision2", "upload.vision3"] }, { id: "generation", labelKey: "upload.gen1", messageKeys: ["upload.gen1", "upload.gen2", "upload.gen3"] }, ]; export function UploadScreen() { const router = useRouter(); const { t, plural } = useI18n(); const [images, setImages] = useState([]); const [documents, setDocuments] = useState([]); const [text, setText] = useState(""); const [focus, setFocus] = useState(""); const [difficulty, setDifficulty] = useState("medium"); const [folderId, setFolderId] = useState(UNORGANIZED_ID); const [creatingFolder, setCreatingFolder] = useState(false); const [folderDraft, setFolderDraft] = useState(""); const [count, setCount] = useState(10); 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: "vision", message: "" }); /** Phones only: which step of the 3-step flow is showing. */ const [step, setStep] = useState(1); /** 1 = moving forward (slides from the right), -1 = Back (from the left). */ const [stepDir, setStepDir] = useState<1 | -1>(1); /** Phones only: the “paste text instead” field on step 1. */ const [pasteOpen, setPasteOpen] = useState(false); const isPhone = useIsPhone(); const reduceMotion = useReducedMotion(); const online = useOnline(); const [clipboardAvailable, setClipboardAvailable] = useState(false); /* System Back goes one wizard step back (phones), then leaves the page. */ useBackClose(isPhone && step > 1, () => { setStepDir(-1); setStep((value) => Math.max(1, value - 1)); }); useEffect(() => { setClipboardAvailable( typeof navigator !== "undefined" && typeof navigator.clipboard?.readText === "function", ); }, []); const folders = useSyncExternalStore( folderStore.subscribe, folderStore.getSnapshot, folderStore.getServerSnapshot, ); const sortedFolders = useMemo( () => [...folders].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" })), [folders], ); const targetFolder = folderId === UNORGANIZED_ID ? undefined : folderId; const fileInputRef = useRef(null); const cameraInputRef = useRef(null); const abortRef = useRef(null); /** * Mirrors of the current lists. React defers state updaters, so reading the * count from inside `setState` would silently skip work — always read these. */ const imagesRef = useRef([]); const documentsRef = useRef([]); useEffect(() => { imagesRef.current = images; }, [images]); useEffect(() => { documentsRef.current = documents; }, [documents]); /* A loading run locks the mode switch (see WizardModeSwitch). */ useEffect(() => { wizardBusy.set(phase === "loading"); }, [phase]); useEffect(() => () => wizardBusy.set(false), []); /* Leaving the page mid-generation stops the request instead of letting it finish invisibly and save a deck the user never sees. The loading screen's Cancel button is the in-page way out. */ useEffect(() => () => abortRef.current?.abort(), []); const addFiles = useCallback(async (files: File[]) => { if (files.length === 0) return; setFileError(null); setPhase("preparing"); const problems: string[] = []; const imageFiles: File[] = []; const documentFiles: File[] = []; for (const file of files) { if (isImageFile(file)) { const problem = describeFileError(file); if (problem) problems.push(problem); else imageFiles.push(file); } else if (isDocumentFile(file)) { const problem = describeDocumentError(file); if (problem) problems.push(problem); else documentFiles.push(file); } else { problems.push(t("upload.unsupported", { name: file.name })); } } const documentRoom = Math.max(0, MAX_DOCUMENTS - documentsRef.current.length); const acceptedDocuments = documentFiles.slice(0, documentRoom); if (documentFiles.length > documentRoom) problems.push(t("upload.tooManyDocs", { n: MAX_DOCUMENTS })); // Documents are read asynchronously (pdf.js can take a while for scans). if (acceptedDocuments.length > 0) { for (const file of acceptedDocuments) { const kind = documentKindOf(file); if (!kind) continue; const item: DocItem = { id: uid("doc"), name: file.name, kind, status: "extracting" }; documentsRef.current = [...documentsRef.current, item]; setDocuments((current) => (current.some((entry) => entry.id === item.id) ? current : [...current, item])); const update = (patch: Partial) => setDocuments((current) => current.map((entry) => (entry.id === item.id ? { ...entry, ...patch } : entry)), ); try { const extracted = await extractDocument(file, (progress) => update({ progress: progress.message })); update({ status: "ready", extracted, progress: undefined }); } catch (error) { update({ status: "error", error: error instanceof Error ? error.message : t("upload.couldNotReadDoc"), }); } } } const photoRoom = Math.max(0, MAX_IMAGES - imagesRef.current.length); const acceptedImages = imageFiles.slice(0, photoRoom); if (imageFiles.length > photoRoom) problems.push(t("upload.tooManyPhotos", { n: MAX_IMAGES })); const prepared: PreparedImage[] = []; for (const file of acceptedImages) { try { prepared.push(await prepareImage(file)); } catch { problems.push(t("upload.couldNotReadFile", { name: file.name })); } } if (prepared.length > 0) { imagesRef.current = [...imagesRef.current, ...prepared]; setImages((current) => [...current, ...prepared]); } if (problems.length) setFileError(problems.slice(0, 3).join(" ")); setPhase("idle"); }, [t]); // Clipboard paste (Ctrl/⌘ + V) of screenshots or copied photos. useEffect(() => { const onPaste = (event: ClipboardEvent) => { if (phase === "loading") return; const target = event.target as HTMLElement | null; const isTextField = target && /^(INPUT|TEXTAREA)$/.test(target.tagName); const files = imageFilesFromDataTransfer(event.clipboardData); if (files.length > 0) { event.preventDefault(); void addFiles(files); return; } if (!isTextField) { const pasted = event.clipboardData?.getData("text/plain")?.trim(); if (pasted && pasted.length > 40) { event.preventDefault(); setText((current) => (current ? `${current}\n${pasted}` : pasted)); } } }; window.addEventListener("paste", onPaste); return () => window.removeEventListener("paste", onPaste); }, [addFiles, phase]); const removeImage = (id: string) => { imagesRef.current = imagesRef.current.filter((image) => image.id !== id); setImages((current) => current.filter((image) => image.id !== id)); }; const removeDocument = (id: string) => { documentsRef.current = documentsRef.current.filter((entry) => entry.id !== id); setDocuments((current) => current.filter((entry) => entry.id !== id)); }; const readyDocuments = documents.filter((entry) => entry.status === "ready" && entry.extracted); const documentsBusy = documents.some((entry) => entry.status === "extracting"); const documentImages = readyDocuments.flatMap((entry) => entry.extracted!.images); const documentText = documentsToText(readyDocuments.map((entry) => entry.extracted!)); const totalBytes = images.reduce((sum, image) => sum + image.bytes, 0); const hasContent = images.length > 0 || documentImages.length > 0 || text.trim().length > 0 || documentText.trim().length > 0; const canGenerate = hasContent && !documentsBusy && phase !== "preparing"; const imagesToSend = [...images.map((image) => image.dataUrl), ...documentImages.map((image) => image.dataUrl)].slice( 0, MAX_IMAGES_TO_SEND, ); const imagesSkipped = images.length + documentImages.length - imagesToSend.length; /* Restore a draft after an OS kill / reload; clear it on success. */ useEffect(() => { const draft = loadWizardDraft("deck"); if (!draft) return; if (draft.text) setText(draft.text); if (draft.focus) setFocus(draft.focus); if (draft.difficulty === "easy" || draft.difficulty === "medium" || draft.difficulty === "hard") { setDifficulty(draft.difficulty); } if (draft.count) setCount(draft.count); if (draft.folderId) setFolderId(draft.folderId); if (draft.images?.length) setImages(draft.images); }, []); /* Content shared in from another app (share target) lands here. */ useEffect(() => { const pending = takePendingShare(); if (!pending) return; if (pending.text) setText((current) => (current ? `${current}\n${pending.text}` : pending.text)); if (pending.files.length > 0) void addFiles(pending.files); }, [addFiles]); /* Homework → "Turn into a study deck": content + options pre-filled. */ useEffect(() => { const seed = takeDeckSeed(); if (!seed) return; setText(seed.text); if (seed.difficulty) setDifficulty(seed.difficulty); if (seed.count) setCount(seed.count); if (seed.step && seed.step > 1) setStep(seed.step); }, []); /* Persist the draft while the user works (debounced, best-effort). */ useEffect(() => { if (phase === "loading") return; const timer = window.setTimeout(() => { saveWizardDraft("deck", { text, focus, difficulty, count, folderId, images }); }, 400); return () => window.clearTimeout(timer); }, [text, focus, difficulty, count, folderId, images, phase]); const generate = async () => { if (!canGenerate) return; setError(null); setPhase("loading"); setProgress({ stage: imagesToSend.length ? "vision" : "generation", message: imagesToSend.length ? t("upload.vision1") : t("upload.gen1"), }); const controller = new AbortController(); abortRef.current = controller; const mergedText = [text.trim(), documentText.trim()].filter(Boolean).join("\n\n").slice(0, MAX_TEXT_TO_SEND); try { const { deck } = await generateDeck( { images: imagesToSend.length ? imagesToSend : undefined, text: mergedText || undefined, focus: focus.trim() || undefined, difficulty, count, }, { onProgress: setProgress, signal: controller.signal }, ); const now = Date.now(); const stored = { ...deck, id: uid("deck"), folderId: targetFolder, createdAt: now, updatedAt: now, attempts: 0, }; upsertDeck(stored); clearWizardDraft("deck"); router.push(`/deck/${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; } }; /* Shared controls: the desktop card and the phone step 2 render the same folder / difficulty / count pickers, so their markup lives in one place. */ const folderPicker = (
{t("upload.freezeInto")}
{[{ id: UNORGANIZED_ID, name: t("common.unorganized") }, ...sortedFolders].map((folder) => { const active = folderId === folder.id; return ( ); })} {creatingFolder ? ( setFolderDraft(event.target.value)} onKeyDown={(event) => { event.stopPropagation(); if (event.key === "Escape") { setCreatingFolder(false); setFolderDraft(""); } if (event.key === "Enter") { const created = createFolder(folderDraft); if ("folder" in created) { setFolderId(created.folder.id); setFolderDraft(""); setCreatingFolder(false); } else { setFileError(created.error); } } }} aria-label={t("common.newFolderName")} placeholder={t("upload.folderPlaceholder")} maxLength={60} enterKeyHint="done" autoCapitalize="words" autoComplete="off" spellCheck={false} className="w-32 rounded-xl border border-[var(--hairline)] bg-[var(--surface-strong)] px-3 py-1.5 text-sm outline-none" /> ) : ( )}
); const difficultyPicker = (
{DIFFICULTIES.map((option) => { const selected = difficulty === option.value; return ( ); })}
); const countPicker = (
{t("upload.activities")} {COUNT_OPTIONS.map((option) => ( ))}
); const errorCard = error ? (

{modelErrorCopy(error.kind)?.title ?? (error.kind === "needs_key" ? t("upload.errNeedsKey") : error.kind === "rate_limited" ? t("upload.errRate") : error.kind === "auth" ? t("upload.errAuth") : t("upload.errFailed"))}

{modelErrorCopy(error.kind)?.body ?? error.message}

{error.kind === "needs_key" || error.kind === "auth" ? ( ) : null}
) : null; const phoneHelper = !online ? t("offline.generate") : documentsBusy ? t("upload.readingDocs") : hasContent ? t("upload.ready") : t("upload.addToStart"); if (phase === "loading") { return ( abortRef.current?.abort()} /> ); } /* Phones: the 3-step flow. Same state as the desktop card, different shape. */ if (isPhone) { const stepLabel = step === 1 ? t("upload.phone.notes") : step === 2 ? t("upload.phone.options") : t("upload.phone.summary"); const stepDesc = step === 1 ? t("upload.phone.notesDesc", { images: MAX_IMAGES, documents: MAX_DOCUMENTS }) : step === 2 ? t("upload.phone.optionsDesc") : t("upload.phone.summaryDesc"); const filesSummary = images.length > 0 || documents.length > 0 ? [ images.length > 0 ? plural(images.length, "upload.photoCount") : "", documents.length > 0 ? plural(documents.length, "upload.docSummary") : "", ] .filter(Boolean) .join(" · ") : t("upload.summaryText"); const difficultyLabel = difficulty === "easy" ? t("upload.easy") : difficulty === "hard" ? t("upload.hard") : t("upload.medium"); const folderLabel = folderId === UNORGANIZED_ID ? t("common.unorganized") : (sortedFolders.find((folder) => folder.id === folderId)?.name ?? t("common.unorganized")); const editStep = (target: 1 | 2 | 3) => { setStepDir(target < step ? -1 : 1); setStep(target); }; const fileInput = ( { void addFiles(Array.from(event.target.files ?? [])); event.target.value = ""; }} /> ); const cameraInput = ( { void addFiles(Array.from(event.target.files ?? [])); event.target.value = ""; }} /> ); return (
{errorCard} {/* progress: thin bar + “1 / 3” */}
{t("common.stepOf", { n: step, total: 3 })}
{/* the step name is the headline; the page title lives in the chrome */}

{stepLabel}

{stepDesc}

{step === 1 ? (
{clipboardAvailable ? ( ) : null} {pasteOpen || text.trim().length > 0 ? (