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.
152 lines
5.4 KiB
TypeScript
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 };
|
|
}
|