"use client"; import { motion } from "framer-motion"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { ApiError, generateDeck, type GenerateProgress } from "@/lib/client-api"; import { DOCUMENT_ACCEPT_ATTR, KIND_LABELS, describeDocumentError, 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 { useI18n } from "@/lib/i18n"; import { UNORGANIZED_ID, type Difficulty } from "@/lib/schemas"; import { upsertDeck } from "@/lib/storage"; import { openSettings } from "@/lib/ui-events"; import { cn, uid } from "@/lib/utils"; import { CameraIcon, ChevronRightIcon, FolderIcon, IceCubeIcon, PlusIcon, SnowflakeIcon, SparklesIcon, SunIcon } from "@/components/icons"; import { Button, ProgressBar } from "@/components/ui"; 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; function describeExtraction( document: ExtractedDocument, tr: (key: string, vars?: Record) => string, pl: (n: number, base: string, vars?: Record) => string, ): string { const parts = [ pl(document.words, "upload.words", { n: document.words.toLocaleString() }), document.pages ? pl(document.pages, "upload.pages") : "", document.images.length ? pl(document.images.length, "upload.pageImages") : "", document.truncated ? tr("upload.truncated") : "", ]; return parts.filter(Boolean).join(" · "); } 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"]; 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: "" }); 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]); 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; 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); 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; } }; if (phase === "loading") { return abortRef.current?.abort()} />; } return (

{t("upload.title")}

{t("upload.intro")}

{/* The other thing you can bring here: an assignment. */} {t("homework.entry")} {t("homework.nav")} → {error ? (

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

{error.message}

{error.kind === "needs_key" || error.kind === "auth" ? ( ) : null}
) : null} {/* ---------------------------------------------------------- */}
{ event.preventDefault(); setDragging(true); }} onDragLeave={() => setDragging(false)} onDrop={(event) => { event.preventDefault(); setDragging(false); void addFiles(imageFilesFromDataTransfer(event.dataTransfer)); }} className={cn( "card relative overflow-hidden p-4 transition-all sm:p-5", dragging && "border-frost-400 bg-frost-50 ring-4 ring-frost-200 dark:bg-frost-500/10 dark:ring-frost-500/30", )} >

{t("upload.step1")}

{t("upload.step1Help", { images: MAX_IMAGES, documents: MAX_DOCUMENTS })}

{ void addFiles(Array.from(event.target.files ?? [])); event.target.value = ""; }} /> { void addFiles(Array.from(event.target.files ?? [])); event.target.value = ""; }} /> {images.length === 0 ? ( ) : (
    {images.map((image) => (
  • {/* eslint-disable-next-line @next/next/no-img-element */} {image.name} {formatBytes(image.bytes)}
  • ))} {images.length < MAX_IMAGES ? (
  • ) : null}
)} {documents.length > 0 ? (
    {documents.map((entry) => (
  • {KIND_LABELS[entry.kind]}

    {entry.name}

    {entry.status === "extracting" ? (

    {entry.progress ?? t("upload.reading")}

    ) : null} {entry.status === "error" ? (

    {entry.error}

    ) : null} {entry.status === "ready" && entry.extracted ? ( <>

    {describeExtraction(entry.extracted, t, plural)}

    {entry.extracted.words === 0 && entry.extracted.images.length === 0 ? (

    {t("upload.noReadable")}

    ) : null} {entry.extracted.warnings.map((warning) => (

    ⓘ {warning}

    ))} ) : null}
  • ))}
) : null} {images.length > 0 || documents.length > 0 ? (

{images.length > 0 ? plural(images.length, "upload.photoSummary", { size: formatBytes(totalBytes) }) : ""} {images.length > 0 && documents.length > 0 ? " · " : ""} {documents.length > 0 ? `${plural(documents.length, "upload.docSummary")}${ documentImages.length ? ` (${plural(documentImages.length, "upload.docImages")})` : "" }` : ""} {imagesSkipped > 0 ? ` · ${plural(imagesSkipped, "upload.skipImages", { max: MAX_IMAGES_TO_SEND })}` : ""}

) : null} {phase === "preparing" ? (

{t("upload.compressing")}

) : null} {fileError ? (

{fileError}

) : null}
{/* ---------------------------------------------------------- */}

{t("upload.step2")}

{t("upload.step2Help")}