"use client"; import { motion } from "framer-motion"; 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 { 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 { ApiKeyBanner } from "@/components/ApiKeyBanner"; import { CameraIcon, FolderIcon, IceCubeIcon, PlusIcon, SnowflakeIcon, 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): string { const parts = [ `${document.words.toLocaleString()} word${document.words === 1 ? "" : "s"}`, document.pages ? `${document.pages} page${document.pages === 1 ? "" : "s"}` : "", document.images.length ? `${document.images.length} page image${document.images.length === 1 ? "" : "s"}` : "", document.truncated ? "truncated" : "", ]; return parts.filter(Boolean).join(" · "); } const DIFFICULTIES: Array<{ value: Difficulty; label: string; icon: React.ReactNode; blurb: string }> = [ { value: "easy", label: "Easy", icon: , blurb: "Recall & recognition", }, { value: "medium", label: "Medium", icon: , blurb: "Understanding & application" }, { value: "hard", label: "Hard", icon: , blurb: "Analysis & tricky edges" }, ]; const COUNT_OPTIONS = [8, 10, 12, 15]; const GENERATION_TIPS = [ "Tip: swipe up and down through your deck like a feed.", "Tip: matching questions only give full XP for a perfect match.", "Tip: your decks are saved in this browser — no account needed.", "Tip: export a deck as JSON to share it with a friend.", ]; export function UploadScreen() { const router = useRouter(); 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: "Warming up…" }); 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( `"${file.name}" is not a supported format. Add photos or a PDF, DOCX, PPTX, ODT/ODP, TXT or MD file.`, ); } } const documentRoom = Math.max(0, MAX_DOCUMENTS - documentsRef.current.length); const acceptedDocuments = documentFiles.slice(0, documentRoom); if (documentFiles.length > documentRoom) problems.push(`Up to ${MAX_DOCUMENTS} documents per deck.`); // 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 : "Could not read this file.", }); } } } const photoRoom = Math.max(0, MAX_IMAGES - imagesRef.current.length); const acceptedImages = imageFiles.slice(0, photoRoom); if (imageFiles.length > photoRoom) problems.push(`Up to ${MAX_IMAGES} photos per deck.`); const prepared: PreparedImage[] = []; for (const file of acceptedImages) { try { prepared.push(await prepareImage(file)); } catch { problems.push(`Could not read "${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"); }, []); // 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 ? "Reading your notes…" : "Finding the key ideas…", }); 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 : "Something went wrong while generating your deck. Check your Ollama settings and try again.", kind: generateError instanceof ApiError ? generateError.kind : undefined, }); setPhase("error"); } finally { abortRef.current = null; } }; if (phase === "loading") { return abortRef.current?.abort()} />; } return (

Turn your notes into a study feed

Snap or drop photos of your notes. Freezmo reads them with your own Ollama vision model and builds a swipeable deck of mini study games.

{error ? (

{error.kind === "needs_key" ? "API key required" : error.kind === "rate_limited" ? "Slow down a moment" : error.kind === "auth" ? "Ollama rejected your key" : "Generation failed"}

{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", )} >

1. Add your notes

Drag & drop, paste a screenshot (Ctrl/⌘+V), or use the camera. Photos, PDF, Word, PowerPoint, OpenDocument and text files. Up to {MAX_IMAGES} photos and {MAX_DOCUMENTS}{" "} 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 ?? "Reading…"}

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

    {entry.error}

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

    {describeExtraction(entry.extracted)}

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

    No readable text — is this a scan without a text layer?

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

    ⓘ {warning}

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

{images.length > 0 ? `${images.length} photo${images.length === 1 ? "" : "s"} · ${formatBytes(totalBytes)} after compression` : ""} {images.length > 0 && documents.length > 0 ? " · " : ""} {documents.length > 0 ? `${documents.length} document${documents.length === 1 ? "" : "s"}${ documentImages.length ? ` (${documentImages.length} page image${documentImages.length === 1 ? "" : "s"})` : "" }` : ""} {imagesSkipped > 0 ? ` · ${imagesSkipped} image${imagesSkipped === 1 ? "" : "s"} will be skipped (max ${MAX_IMAGES_TO_SEND} per deck)` : ""}

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

Compressing photos…

) : null} {fileError ? (

{fileError}

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

2. Optional extras

Paste extra notes or give the AI a hint about what to focus on.