freezmo/lib/use-notes-intake.ts
Martin Dvorak ef2a6a3589 Add “Make my homework”, and finish the mobile polish pass
Homework (/homework — entry points in the drawer and on the create screen)

- Assignment intake reuses the study pipeline: pasted text, photos
  (camera/picker, compressed in the browser) and documents (PDF/DOCX/PPTX/
  ODT/text) through lib/image + lib/documents, the same vision step and the
  same language guard. Raw files never leave the device — only text and JPEG
  data URLs reach the server.
- Two large choice cards: “Just make it” returns the finished homework
  (answer only, plain text, copy + make it again); “Explain how to make this
  homework” builds an interactive guide.
- The guide is one step per screen in the study-feed snap layout:
  explanation, three progressive hints (nudge -> hint -> the answer) and an
  optional checkpoint rendered by the existing activity components (graded
  through /api/grade). The last screen assembles the solution from the step
  answers, with a copy button. Checkpoints carry xp 0 so the guide stays free
  of XP/streak chrome.
- Results live in freezmo.homework.v1 with a small history (open/delete);
  “Make it again” re-runs from the stored assignment text (the vision
  transcription when photos were used) — no original files are stored.
- The answer and the guide are written in the assignment's language, never
  translated into the UI language.

Server

- POST /api/homework: NDJSON stage events like /api/generate, the same
  http-guard, auth checked before parsing, and its own RATE_LIMIT_HOMEWORK
  (documented in .env.example + the README table).
- New prompts in lib/prompts.ts and zod schemas in lib/schemas.ts with the
  same single repair retry; TEXT_MODEL for content, VISION_MODEL only for
  photos. New shared client pieces: useNotesIntake, lib/homework store,
  lib/clipboard, and a shared NDJSON reader in lib/client-api.

Also finishing the mobile polish pass from the previous request

- Phone ice scene: the top-left orb moved behind the glass header (it sat
  behind the page heading/subtitle). The settings sheet's scroll cue is now a
  bare chevron inside a content fade (mask) instead of a chip over the status
  banner, and the API key card's note/disclosure/actions are tightened.
- theme-color now follows the in-app theme (SSR metas still fall back to the
  OS preference); apple-mobile-web-app-status-bar-style stays “default”
  because iOS fixes the status-bar text colour at launch.

Verified: 78/78 homework checks (text/photo/PDF, both modes, EN+CS, dark+light,
360/390px, copy, history persistence, storage privacy, API language, axe), plus
the existing suites re-run (mobile 47/47, pixel contrast 0 below AA, i18n 27/27,
desktop regression 72/72). Lint + build clean; service worker freezmo-v6.
2026-10-08 18:30:33 +00:00

152 lines
5.4 KiB
TypeScript

"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<PreparedImage[]>([]);
const [documents, setDocuments] = useState<IntakeDocument[]>([]);
const [fileError, setFileError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
/** Mirrors of the current lists; React defers state updaters. */
const imagesRef = useRef<PreparedImage[]>([]);
const documentsRef = useRef<IntakeDocument[]>([]);
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<IntakeDocument>) =>
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 };
}