"use client"; /** * Client-side file intake for an assignment: validates photos and documents, * reads documents in the browser (lib/documents) and compresses photos * (lib/image). The raw files never leave the device — everything reaches the * server as text and JPEG data URLs. * * The same building blocks the /create screen uses, packaged for the homework * screen (which has its own caps and wording). */ import { useCallback, useRef, useState } from "react"; import { describeDocumentError, documentKindOf, extractDocument, isDocumentFile, type DocumentKind, type ExtractedDocument, } from "./documents"; import { MAX_IMAGES, describeFileError, isImageFile, prepareImage, type PreparedImage } from "./image"; import { t } from "./i18n"; import { uid } from "./utils"; export type IntakeDocument = { id: string; name: string; kind: DocumentKind; status: "extracting" | "ready" | "error"; progress?: string; extracted?: ExtractedDocument; error?: string; }; export function useNotesIntake(options: { maxImages?: number; maxDocuments?: number; /** i18n key for “too many photos”, with an {n} placeholder. */ tooManyPhotosKey: string; /** i18n key for “too many documents”, with an {n} placeholder. */ tooManyDocsKey: string; }) { const maxImages = options.maxImages ?? MAX_IMAGES; const maxDocuments = options.maxDocuments ?? 6; const tooManyPhotosKey = options.tooManyPhotosKey; const tooManyDocsKey = options.tooManyDocsKey; const [images, setImages] = useState([]); const [documents, setDocuments] = useState([]); const [fileError, setFileError] = useState(null); const [busy, setBusy] = useState(false); /** Mirrors of the current lists; React defers state updaters. */ const imagesRef = useRef([]); const documentsRef = useRef([]); const addFiles = useCallback( async (files: File[]) => { if (files.length === 0) return; setFileError(null); setBusy(true); 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, maxDocuments - documentsRef.current.length); const acceptedDocuments = documentFiles.slice(0, documentRoom); if (documentFiles.length > documentRoom) problems.push(t(tooManyDocsKey, { n: maxDocuments })); // Documents are read asynchronously (pdf.js can take a while for scans). for (const file of acceptedDocuments) { const kind = documentKindOf(file); if (!kind) continue; const item: IntakeDocument = { 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, maxImages - imagesRef.current.length); const acceptedImages = imageFiles.slice(0, photoRoom); if (imageFiles.length > photoRoom) problems.push(t(tooManyPhotosKey, { n: maxImages })); 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(" ")); setBusy(false); }, [maxDocuments, maxImages, tooManyDocsKey, tooManyPhotosKey], ); const removeImage = useCallback((id: string) => { imagesRef.current = imagesRef.current.filter((image) => image.id !== id); setImages((current) => current.filter((image) => image.id !== id)); }, []); const removeDocument = useCallback((id: string) => { documentsRef.current = documentsRef.current.filter((entry) => entry.id !== id); setDocuments((current) => current.filter((entry) => entry.id !== id)); }, []); const clear = useCallback(() => { imagesRef.current = []; documentsRef.current = []; setImages([]); setDocuments([]); setFileError(null); }, []); return { images, documents, fileError, busy, addFiles, removeImage, removeDocument, clear }; }