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
339
components/homework/HomeworkInput.tsx
Normal file
339
components/homework/HomeworkInput.tsx
Normal file
|
|
@ -0,0 +1,339 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { CameraIcon, FolderIcon, PlayIcon, PlusIcon, SparklesIcon } from "@/components/icons";
|
||||
import { Button } from "@/components/ui";
|
||||
import {
|
||||
DOCUMENT_ACCEPT_ATTR,
|
||||
documentsToText,
|
||||
type ExtractedDocument,
|
||||
} from "@/lib/documents";
|
||||
import type { HomeworkMode, HomeworkRecord } from "@/lib/schemas";
|
||||
import { ACCEPTED_TYPES, formatBytes, imageFilesFromDataTransfer } from "@/lib/image";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import type { useNotesIntake } from "@/lib/use-notes-intake";
|
||||
import { openSettings } from "@/lib/ui-events";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { HomeworkHistory } from "./HomeworkHistory";
|
||||
|
||||
/** Same wording the create screen uses for an extracted document. */
|
||||
function describeExtraction(
|
||||
document: ExtractedDocument,
|
||||
tr: (key: string, vars?: Record<string, string | number>) => string,
|
||||
pl: (n: number, base: string, vars?: Record<string, string | number>) => string,
|
||||
): string {
|
||||
const parts = [
|
||||
pl(document.words, "upload.words", { n: document.words.toLocaleString() }),
|
||||
document.pages ? pl(document.pages, "upload.pages") : "",
|
||||
document.images.length ? pl(document.images.length, "upload.pageImages") : "",
|
||||
document.truncated ? tr("upload.truncated") : "",
|
||||
];
|
||||
return parts.filter(Boolean).join(" · ");
|
||||
}
|
||||
|
||||
const MODES: Array<{ id: HomeworkMode; icon: React.ReactNode; titleKey: string; subtitleKey: string }> = [
|
||||
{ id: "make", icon: <SparklesIcon className="size-5" />, titleKey: "homework.mode.make.title", subtitleKey: "homework.mode.make.subtitle" },
|
||||
{ id: "guide", icon: <PlayIcon className="size-5" />, titleKey: "homework.mode.guide.title", subtitleKey: "homework.mode.guide.subtitle" },
|
||||
];
|
||||
|
||||
export function HomeworkInput({
|
||||
mode,
|
||||
onModeChange,
|
||||
text,
|
||||
onTextChange,
|
||||
intake,
|
||||
onSubmit,
|
||||
error,
|
||||
history,
|
||||
onOpen,
|
||||
onDelete,
|
||||
}: {
|
||||
mode: HomeworkMode;
|
||||
onModeChange: (mode: HomeworkMode) => void;
|
||||
text: string;
|
||||
onTextChange: (text: string) => void;
|
||||
intake: ReturnType<typeof useNotesIntake>;
|
||||
onSubmit: () => void;
|
||||
error: { message: string; kind?: string } | null;
|
||||
history: HomeworkRecord[];
|
||||
onOpen: (record: HomeworkRecord) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}) {
|
||||
const { t, plural } = useI18n();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const cameraInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Clipboard: screenshots become photos, long copied text becomes the assignment.
|
||||
useEffect(() => {
|
||||
const onPaste = (event: ClipboardEvent) => {
|
||||
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 intake.addFiles(files);
|
||||
return;
|
||||
}
|
||||
if (!isTextField) {
|
||||
const pasted = event.clipboardData?.getData("text/plain")?.trim();
|
||||
if (pasted && pasted.length > 40) {
|
||||
event.preventDefault();
|
||||
onTextChange(text ? `${text}\n${pasted}` : pasted);
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener("paste", onPaste);
|
||||
return () => window.removeEventListener("paste", onPaste);
|
||||
}, [intake, onTextChange, text]);
|
||||
|
||||
const readyDocuments = intake.documents.filter((entry) => entry.status === "ready" && entry.extracted);
|
||||
const documentsBusy = intake.documents.some((entry) => entry.status === "extracting");
|
||||
const documentImages = readyDocuments.flatMap((entry) => entry.extracted!.images);
|
||||
const documentText = documentsToText(readyDocuments.map((entry) => entry.extracted!));
|
||||
const totalBytes = intake.images.reduce((sum, image) => sum + image.bytes, 0);
|
||||
|
||||
const hasContent =
|
||||
intake.images.length > 0 || documentImages.length > 0 || text.trim().length > 0 || documentText.trim().length > 0;
|
||||
const canSubmit = hasContent && !intake.busy && !documentsBusy;
|
||||
const keyError = error?.kind === "needs_key" || error?.kind === "auth";
|
||||
|
||||
return (
|
||||
<div className="relative space-y-6">
|
||||
<section className="space-y-2">
|
||||
<h1 className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[28px] font-semibold leading-tight tracking-[-0.02em] text-[var(--text)] text-balance sm:text-[32px]">
|
||||
<SparklesIcon className="size-8 shrink-0 text-frost-500 dark:text-frost-200" />
|
||||
<span>{t("homework.title")}</span>
|
||||
</h1>
|
||||
<p className="max-w-xl text-[15px] text-frost-600 dark:text-frost-300">{t("homework.subtitle")}</p>
|
||||
</section>
|
||||
|
||||
{error ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="animate-rise rounded-3xl border-2 border-rose-300 bg-rose-50/95 p-4 text-sm text-rose-900 dark:border-rose-400/40 dark:bg-rose-400/10 dark:text-rose-100"
|
||||
>
|
||||
<p className="font-bold">{keyError ? t("upload.errAuth") : t("homework.error.title")}</p>
|
||||
<p className="mt-1">{error.message || t("homework.error.body")}</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{keyError ? (
|
||||
<Button size="sm" onClick={openSettings}>
|
||||
{t("upload.openSettings")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button size="sm" variant={keyError ? "secondary" : "primary"} onClick={onSubmit}>
|
||||
{t("common.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* ------------------------------------------------ the assignment */}
|
||||
<section className="card p-4 sm:p-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-[15px] font-semibold tracking-[-0.01em] text-[var(--text)]">
|
||||
{t("homework.source.title")}
|
||||
</h2>
|
||||
<p className="text-[12.5px] text-[var(--text-subtle)]">{t("homework.source.help")}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="secondary" size="sm" className="touch-target" onClick={() => fileInputRef.current?.click()}>
|
||||
<FolderIcon className="size-4" /> {t("homework.source.add")}
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" className="touch-target" onClick={() => cameraInputRef.current?.click()}>
|
||||
<CameraIcon className="size-4" /> {t("upload.camera")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={`${ACCEPTED_TYPES.join(",")},${DOCUMENT_ACCEPT_ATTR}`}
|
||||
multiple
|
||||
className="sr-only"
|
||||
onChange={(event) => {
|
||||
void intake.addFiles(Array.from(event.target.files ?? []));
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={cameraInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
className="sr-only"
|
||||
onChange={(event) => {
|
||||
void intake.addFiles(Array.from(event.target.files ?? []));
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
|
||||
{intake.images.length === 0 && intake.documents.length === 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="mt-4 flex w-full flex-col items-center gap-2 rounded-2xl border-2 border-dashed border-white/70 bg-white/40 px-4 py-10 text-center backdrop-blur-xl transition hover:border-accent-300 hover:bg-white/65 dark:border-white/15 dark:bg-white/[0.05] dark:hover:bg-white/[0.09]"
|
||||
>
|
||||
<span aria-hidden className="text-frost-500 dark:text-frost-200">
|
||||
<PlusIcon className="size-10" />
|
||||
</span>
|
||||
<span className="text-[14px] font-medium text-[var(--text)]">{t("homework.source.drop")}</span>
|
||||
<span className="text-[12px] text-[var(--text-subtle)]">{t("homework.source.formats")}</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="mt-4 space-y-2">
|
||||
{intake.images.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{intake.images.map((image) => (
|
||||
<span key={image.id} className="relative">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- local thumbnail, no optimisation needed */}
|
||||
<img
|
||||
src={image.dataUrl}
|
||||
alt=""
|
||||
className="size-14 rounded-xl border border-[var(--hairline)] object-cover"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => intake.removeImage(image.id)}
|
||||
aria-label={t("upload.remove", { name: image.name })}
|
||||
className="absolute -right-1.5 -top-1.5 grid size-6 place-items-center rounded-full bg-frost-950/80 text-[11px] font-bold text-white"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{intake.documents.length > 0 ? (
|
||||
<ul className="space-y-2">
|
||||
{intake.documents.map((entry) => (
|
||||
<li
|
||||
key={entry.id}
|
||||
className="flex items-center gap-3 rounded-xl border border-[var(--hairline)] bg-[var(--surface-strong)] p-2.5"
|
||||
>
|
||||
<span className="rounded-md bg-[var(--surface-sunken)] px-1.5 py-0.5 text-[10px] font-bold uppercase text-[var(--text-muted)]">
|
||||
{entry.name.split(".").pop()?.slice(0, 4) ?? "doc"}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[13px] font-medium text-[var(--text)]">{entry.name}</span>
|
||||
<span className="block text-[11.5px] text-[var(--text-subtle)]">
|
||||
{entry.status === "extracting"
|
||||
? (entry.progress ?? t("upload.reading"))
|
||||
: entry.status === "error"
|
||||
? entry.error
|
||||
: entry.extracted
|
||||
? describeExtraction(entry.extracted, t, plural)
|
||||
: ""}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => intake.removeDocument(entry.id)}
|
||||
aria-label={t("upload.remove", { name: entry.name })}
|
||||
className="touch-target grid size-8 shrink-0 place-items-center rounded-lg text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label
|
||||
htmlFor="homework-assignment"
|
||||
className="mt-4 block text-[11px] font-semibold uppercase tracking-wider text-[var(--text-subtle)]"
|
||||
>
|
||||
{t("homework.source.textLabel")}
|
||||
</label>
|
||||
<textarea
|
||||
id="homework-assignment"
|
||||
value={text}
|
||||
onChange={(event) => onTextChange(event.target.value)}
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
placeholder={t("homework.source.placeholder")}
|
||||
rows={4}
|
||||
className="mt-1.5 w-full resize-y rounded-xl border border-[var(--hairline)] bg-[var(--surface-strong)] px-3 py-2.5 text-sm leading-relaxed text-[var(--text)] outline-none transition placeholder:text-[var(--text-subtle)] focus:border-accent-500"
|
||||
/>
|
||||
|
||||
{intake.images.length > 0 || documentImages.length > 0 ? (
|
||||
<p className="mt-2 text-[12px] text-[var(--text-subtle)]">
|
||||
{intake.images.length > 0
|
||||
? plural(intake.images.length, "upload.photoSummary", { size: formatBytes(totalBytes) })
|
||||
: ""}
|
||||
{intake.images.length > 0 && documentImages.length > 0 ? " · " : ""}
|
||||
{documentImages.length > 0 ? plural(documentImages.length, "upload.docImages") : ""}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{intake.busy ? (
|
||||
<p className="mt-2 text-[12.5px] font-medium text-[var(--text-muted)]">{t("upload.compressing")}</p>
|
||||
) : null}
|
||||
{intake.fileError ? (
|
||||
<p role="alert" className="mt-2 text-[12.5px] font-medium text-rose-700 dark:text-rose-300">
|
||||
{intake.fileError}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{/* ------------------------------------------------------- mode */}
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-[15px] font-semibold tracking-[-0.01em] text-[var(--text)]">{t("homework.mode.title")}</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{MODES.map((option) => {
|
||||
const selected = mode === option.id;
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
onClick={() => onModeChange(option.id)}
|
||||
className={cn(
|
||||
"flex min-h-24 items-start gap-3 rounded-2xl border p-4 text-left transition",
|
||||
selected
|
||||
? "border-accent-400 bg-accent-50/70 ring-2 ring-accent-400/40 dark:bg-accent-500/15"
|
||||
: "border-[var(--hairline)] bg-[var(--surface-strong)] hover:bg-[var(--hover)]",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"grid size-10 shrink-0 place-items-center rounded-xl",
|
||||
selected ? "grad-ice text-[#06111f]" : "bg-[var(--surface-sunken)] text-[var(--text-muted)]",
|
||||
)}
|
||||
>
|
||||
{option.icon}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-[15px] font-semibold leading-snug text-[var(--text)]">
|
||||
{t(option.titleKey)}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-[12.5px] leading-relaxed text-[var(--text-subtle)]">
|
||||
{t(option.subtitleKey)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* -------------------------------------------------------- CTA */}
|
||||
<div className="space-y-2">
|
||||
<Button size="lg" className="touch-target w-full" onClick={onSubmit} disabled={!canSubmit} loading={intake.busy}>
|
||||
{mode === "make" ? t("homework.start.make") : t("homework.start.guide")}
|
||||
</Button>
|
||||
<p className="text-center text-[12px] text-[var(--text-subtle)]">
|
||||
{canSubmit ? t("homework.source.ready") : t("homework.source.need")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<HomeworkHistory records={history} onOpen={onOpen} onDelete={onDelete} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue