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.
This commit is contained in:
parent
9fb728d735
commit
ef2a6a3589
35 changed files with 2334 additions and 100 deletions
152
lib/use-notes-intake.ts
Normal file
152
lib/use-notes-intake.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
"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 };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue