freezmo/components/homework/HomeworkInput.tsx
Martin Dvorak 37a84df462 AI disclaimer: one shared component, shown where model output appears
"Freezmo uses AI and can make mistakes. Double-check important info."
(plus a natural informal Czech translation), rendered by a shared
AiDisclaimer so the copy, style and translations live in one place.

Placements — only where the model's words reach the screen:
- Chat: under the composer, below the context/model chips — always (chat
  is pure model output, so it also shows in the empty state).
- Make my homework: under the start CTA (desktop) and the mode cards'
  helper line (phone), plus once under the generated result for all three
  modes (make / check / guide).
- New deck wizard: under the summary helper (phone step 3) and the desktop
  CTA, next to Freeze my deck.
- AI grading (short answers): under the graded feedback; study, review and
  exam all share it.

Stored, already-generated content (deck page, study cards, explanations)
deliberately carries no note: the disclaimer belongs to the moment of
generation, not to browsing. Style: --text-subtle (documented at 5.6:1
light / 8.6:1 dark), 11.5px, centred, wraps to two lines at 360px, not
clickable, no icon.

Suites: chat (composer, phone + desktop), wizard (deck step 3 across
360/390 x en/cs x dark/light, homework start cards), phase3 (homework
trigger + result, phone + desktop), phase2 (stubbed grading feedback).
SW -> freezmo-v37. README: decision 50 + chat composer note.

Also fixes the deploy script's test instance: it now execs the server so
the cleanup kill reaps the real PID (the wrapper subshell left orphans
squatting on the test port — three from earlier sessions were found while
verifying this task), and the readiness poll checks the port serves the
NEW commit rather than just answering, so a leftover instance can never
be tested silently.
2026-10-11 22:51:16 +00:00

790 lines
32 KiB
TypeScript

"use client";
import { motion, useReducedMotion } from "framer-motion";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { AiDisclaimer } from "@/components/AiDisclaimer";
import { CameraIcon, CheckCircleIcon, FolderIcon, PlayIcon, PlusIcon, SparklesIcon } from "@/components/icons";
import { Button } from "@/components/ui";
import { WizardActionBar } from "@/components/WizardActionBar";
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 { stepVariants } from "@/lib/motion";
import { modelErrorCopy } from "@/lib/model-errors";
import { takePendingShare } from "@/lib/share-pending";
import { useBackClose } from "@/lib/use-back-close";
import type { useNotesIntake } from "@/lib/use-notes-intake";
import { useIsPhone } from "@/lib/use-phone";
import { useOnline } from "@/lib/use-online";
import { clearWizardDraft, loadWizardDraft, saveWizardDraft } from "@/lib/wizard-draft";
import { openSettings } from "@/lib/ui-events";
import { ModelErrorActions } from "@/components/ModelErrorActions";
import { cn, truncate } 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" },
{ id: "check", icon: <CheckCircleIcon className="size-5" />, titleKey: "homework.mode.check", subtitleKey: "homework.mode.checkDesc" },
];
export function HomeworkInput({
mode,
onModeChange,
text,
onTextChange,
solution,
onSolutionChange,
intake,
onSubmit,
error,
history,
onOpen,
onDelete,
}: {
mode: HomeworkMode;
onModeChange: (mode: HomeworkMode) => void;
text: string;
onTextChange: (text: string) => void;
/** Mode "check": the student's own attempt. */
solution: string;
onSolutionChange: (solution: string) => void;
intake: ReturnType<typeof useNotesIntake>;
/** `modeOverride` lets the phone mode cards start the run they tapped. */
onSubmit: (modeOverride?: HomeworkMode) => void;
error: { message: string; kind?: string } | null;
history: HomeworkRecord[];
onOpen: (record: HomeworkRecord) => void;
onDelete: (id: string) => void;
}) {
const { t, plural } = useI18n();
const router = useRouter();
const fileInputRef = useRef<HTMLInputElement>(null);
const cameraInputRef = useRef<HTMLInputElement>(null);
const [step, setStep] = useState(1);
const [stepDir, setStepDir] = useState<1 | -1>(1);
const [pasteOpen, setPasteOpen] = useState(false);
/** Phones: the check card reveals the solution field instead of starting. */
const [checkOpen, setCheckOpen] = useState(false);
const isPhone = useIsPhone();
const reduceMotion = useReducedMotion();
const online = useOnline();
const [clipboardAvailable, setClipboardAvailable] = useState(false);
/* System Back goes one wizard step back (phones), then leaves the page. */
useBackClose(isPhone && step > 1, () => {
setStepDir(-1);
setStep(1);
});
useEffect(() => {
setClipboardAvailable(
typeof navigator !== "undefined" && typeof navigator.clipboard?.readText === "function",
);
}, []);
/* Restore a draft after an OS kill / reload. */
useEffect(() => {
const draft = loadWizardDraft("homework");
if (draft?.text) onTextChange(draft.text);
// eslint-disable-next-line react-hooks/exhaustive-deps -- restore once on mount
}, []);
/* Content shared in from another app (share target) lands here. */
useEffect(() => {
const pending = takePendingShare();
if (!pending) return;
if (pending.text) onTextChange(pending.text);
if (pending.files.length > 0) void intake.addFiles(pending.files);
// eslint-disable-next-line react-hooks/exhaustive-deps -- once on mount
}, [intake.addFiles]);
/* Persist the typed assignment while the user works (debounced). */
useEffect(() => {
if (!isPhone) return;
const timer = window.setTimeout(() => saveWizardDraft("homework", { text }), 400);
return () => window.clearTimeout(timer);
}, [text, isPhone]);
// 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 modelCopy = modelErrorCopy(error?.kind);
const keyError = error?.kind === "needs_key" || error?.kind === "auth";
const errorCard = 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">{modelCopy?.title ?? (keyError ? t("upload.errAuth") : t("homework.error.title"))}</p>
<p className="mt-1">{modelCopy?.body ?? (error.message || t("homework.error.body"))}</p>
<div className="mt-3 flex flex-wrap gap-2">
{keyError ? (
<Button size="sm" onClick={() => openSettings("key")}>
{t("upload.openSettings")}
</Button>
) : null}
<ModelErrorActions kind={error.kind} />
<Button size="sm" variant={keyError ? "secondary" : "primary"} onClick={() => onSubmit()}>
{t("common.retry")}
</Button>
</div>
</div>
) : null;
/* Shared solution field: typed/pasted only (documented in the note). */
const solutionField = (
<div className="space-y-1.5">
<label className="text-[12px] font-semibold text-[var(--text)]" htmlFor="homework-solution">
{t("homework.solution.label")}
</label>
<textarea
id="homework-solution"
value={solution}
onChange={(event) => onSolutionChange(event.target.value)}
rows={5}
placeholder={t("homework.solution.placeholder")}
inputMode="text"
enterKeyHint="done"
autoComplete="off"
autoCapitalize="sentences"
spellCheck
className="w-full resize-y rounded-2xl border border-white/60 bg-white/55 px-4 py-3 text-sm text-frost-950 outline-none backdrop-blur-xl transition placeholder:text-frost-400 focus:border-accent-400 dark:border-white/12 dark:bg-white/[0.07] dark:text-frost-50"
/>
<p className="text-[11px] leading-snug text-[var(--text-subtle)]">{t("homework.solution.note")}</p>
</div>
);
/* Shared mode card: desktop toggles the selection, the phone flow starts it. */
const modeCard = (option: (typeof MODES)[number], onPick: () => void) => {
const selected = mode === option.id;
return (
<button
key={option.id}
type="button"
aria-pressed={selected}
onClick={onPick}
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>
);
};
const fileInput = (
<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 = "";
}}
/>
);
const cameraInput = (
<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 = "";
}}
/>
);
/* Phones: the 2-step flow. Same intake and mode state as the desktop page. */
if (isPhone) {
const stepLabel = step === 1 ? t("homework.phone.assignment") : t("homework.phone.mode");
const fileSummary = [
intake.images.length > 0 ? plural(intake.images.length, "upload.photoCount") : "",
intake.documents.length > 0 ? plural(intake.documents.length, "upload.docSummary") : "",
]
.filter(Boolean)
.join(" · ");
const preview = text.trim()
? truncate(text.replace(/\s+/g, " ").trim(), 140)
: fileSummary || t("homework.source.need");
const showPaste = pasteOpen || text.trim().length > 0;
const stepDesc =
step === 1
? t("upload.phone.notesDesc", { images: 8, documents: 6 })
: t("homework.phone.modeDesc");
return (
<div className="relative">
{errorCard}
{/* progress: thin bar + “1 / 2” */}
<div className="mt-1 flex items-center gap-3">
<div
role="progressbar"
aria-label={stepLabel}
aria-valuemin={1}
aria-valuemax={2}
aria-valuenow={step}
className="h-1 flex-1 overflow-hidden rounded-full bg-[var(--surface-sunken)]"
>
<div
className="grad-ice h-full rounded-full transition-[width] duration-300 motion-reduce:transition-none"
style={{ width: `${(step / 2) * 100}%` }}
/>
</div>
<span className="shrink-0 text-[11px] font-semibold tabular-nums text-[var(--text-subtle)]">
{t("common.stepOf", { n: step, total: 2 })}
</span>
</div>
{/* the step name is the headline; the page title lives in the chrome */}
<h1 className="mt-4 text-[26px] font-semibold leading-tight tracking-[-0.02em] text-[var(--text)]">
{stepLabel}
</h1>
<p className="mt-1 text-[14px] text-frost-600 dark:text-frost-300">{stepDesc}</p>
<motion.div
key={step}
initial="initial"
animate="animate"
variants={stepVariants(stepDir, !!reduceMotion)}
className="mt-5"
>
{step === 1 ? (
<div className="space-y-3">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="press flex min-h-32 w-full flex-col items-center justify-center gap-2 rounded-3xl border-2 border-dashed border-[var(--glass-border)] bg-[var(--surface-strong)] px-4 py-6 text-center transition hover:border-accent-400"
>
<span
aria-hidden
className="grid size-12 place-items-center rounded-2xl bg-[var(--surface-sunken)] text-accent-600 dark:text-accent-200"
>
<PlusIcon className="size-6" />
</span>
<span className="text-[15px] font-semibold text-[var(--text)]">{t("homework.source.add")}</span>
</button>
<div className="grid grid-cols-2 gap-2">
<Button
variant="secondary"
size="lg"
className="touch-target"
onClick={() => cameraInputRef.current?.click()}
>
<CameraIcon className="size-4" /> {t("upload.camera")}
</Button>
<Button
variant="secondary"
size="lg"
className="touch-target"
aria-expanded={showPaste}
onClick={() => setPasteOpen((value) => !value)}
>
{t("upload.pasteInstead")}
</Button>
</div>
{clipboardAvailable ? (
<Button
variant="ghost"
size="lg"
className="touch-target w-full"
onClick={() => {
void (async () => {
try {
const clip = await navigator.clipboard.readText();
if (clip) {
onTextChange(text ? `${text}\n${clip}` : clip);
setPasteOpen(true);
}
} catch {
/* denied — the textarea still works */
}
})();
}}
>
{t("upload.pasteClipboard")}
</Button>
) : null}
{showPaste ? (
<textarea
id="homework-assignment-phone"
value={text}
onChange={(event) => onTextChange(event.target.value)}
onKeyDown={(event) => {
event.stopPropagation();
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
if (canSubmit) {
setStepDir(1);
setStep(2);
}
}
}}
rows={6}
placeholder={t("homework.source.placeholder")}
aria-label={t("homework.source.textLabel")}
inputMode="text"
enterKeyHint="done"
autoComplete="off"
autoCapitalize="sentences"
spellCheck
className="w-full resize-y rounded-2xl border border-white/60 bg-white/55 px-4 py-3 text-sm text-frost-950 outline-none backdrop-blur-xl transition placeholder:text-frost-400 focus:border-accent-400 dark:border-white/12 dark:bg-white/[0.07] dark:text-frost-50"
/>
) : null}
{intake.images.length > 0 ? (
<ul className="flex flex-wrap items-center gap-2">
{intake.images.map((image) => (
<li 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>
</li>
))}
</ul>
) : null}
{intake.documents.length > 0 ? (
<ul className="flex flex-wrap items-center gap-2">
{intake.documents.map((entry) => (
<li
key={entry.id}
className="inline-flex max-w-full items-center gap-1.5 rounded-full border border-[var(--hairline)] bg-[var(--surface-strong)] py-1 pl-2 pr-1"
>
<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="max-w-32 truncate text-[12.5px] font-medium text-[var(--text)]" title={entry.name}>
{entry.name}
</span>
{entry.status === "extracting" ? (
<span className="text-[11px] text-[var(--text-subtle)]" title={entry.progress ?? t("upload.reading")}>
…
</span>
) : null}
{entry.status === "error" ? (
<span className="text-[11px] font-bold text-rose-600 dark:text-rose-300" title={entry.error}>
!
</span>
) : null}
<button
type="button"
onClick={() => intake.removeDocument(entry.id)}
aria-label={t("upload.remove", { name: entry.name })}
className="grid size-6 place-items-center rounded-full text-[11px] font-bold text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
>
✕
</button>
</li>
))}
</ul>
) : null}
{intake.images.length === 0 && intake.documents.length === 0 ? (
<p className="rounded-2xl border border-dashed border-[var(--hairline)] px-4 py-3 text-center text-[12.5px] text-[var(--text-subtle)]">
{t("upload.nothingAdded")}
</p>
) : null}
{intake.documents
.filter((entry) => entry.status === "error")
.map((entry) => (
<p key={entry.id} className="text-[12px] font-medium text-rose-600 dark:text-rose-300">
{entry.name}: {entry.error}
</p>
))}
{intake.busy ? (
<p className="text-[12.5px] font-medium text-[var(--text-muted)]">{t("upload.compressing")}</p>
) : null}
{intake.fileError ? (
<p role="alert" className="text-xs font-semibold text-rose-600 dark:text-rose-300">
{intake.fileError}
</p>
) : null}
{fileInput}
{cameraInput}
</div>
) : null}
{step === 2 ? (
<div className="space-y-3">
<div className="rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
<p className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
{t("homework.phone.preview")}
</p>
<p className="mt-1 text-[13px] leading-relaxed text-[var(--text)]">{preview}</p>
</div>
<div className="grid gap-3">
{MODES.map((option) =>
modeCard(option, () => {
if (!canSubmit || !online) return;
if (option.id === "check") {
// Needs the solution first: reveal the field instead of
// starting the run.
onModeChange("check");
setCheckOpen(true);
return;
}
clearWizardDraft("homework");
onModeChange(option.id);
onSubmit(option.id);
}),
)}
</div>
{checkOpen ? (
<div className="space-y-3">
{solutionField}
<Button
size="lg"
className="touch-target w-full"
disabled={!canSubmit || !solution.trim()}
onClick={() => {
clearWizardDraft("homework");
onSubmit("check");
}}
>
{t("homework.mode.check")}
</Button>
</div>
) : null}
<p className="text-center text-[12px] text-[var(--text-subtle)]">
{online ? t("homework.phone.startHint") : t("offline.generate")}
</p>
<AiDisclaimer />
</div>
) : null}
</motion.div>
<HomeworkHistory records={history} onOpen={onOpen} onDelete={onDelete} />
{/* The action bar is portalled to <body> — never inside the animated
content wrapper, where a transform would break position: fixed. */}
<WizardActionBar>
{step === 1 ? (
<Button
variant="secondary"
size="lg"
className="touch-target flex-1"
onClick={() => router.push("/")}
>
{t("common.cancel")}
</Button>
) : (
<Button
variant="secondary"
size="lg"
className="touch-target flex-1"
onClick={() => {
setStepDir(-1);
setStep(1);
}}
>
{t("common.back")}
</Button>
)}
{step === 1 ? (
<Button
size="lg"
className="touch-target flex-[1.5]"
onClick={() => {
setStepDir(1);
setStep(2);
}}
disabled={!canSubmit}
>
{t("common.next")}
</Button>
) : null}
</WizardActionBar>
</div>
);
}
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("homeworkHeroTitle")}</span>
</h1>
<p className="max-w-xl text-[15px] text-frost-600 dark:text-frost-300">{t("homework.subtitle")}</p>
</section>
{errorCard}
{/* ------------------------------------------------ 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-3">
{MODES.map((option) => modeCard(option, () => onModeChange(option.id)))}
</div>
{mode === "check" ? solutionField : null}
</section>
{/* -------------------------------------------------------- CTA */}
<div className="space-y-2">
<Button
size="lg"
className="touch-target w-full"
onClick={() => onSubmit()}
disabled={!canSubmit || (mode === "check" && !solution.trim())}
loading={intake.busy}
>
<SparklesIcon className="size-5" />
{mode === "make" ? t("homework.start.make") : mode === "guide" ? t("homework.start.guide") : t("homework.mode.check")}
</Button>
<p className="text-center text-[12px] text-[var(--text-subtle)]">
{canSubmit ? t("homework.source.ready") : t("homework.source.need")}
</p>
<AiDisclaimer />
</div>
<HomeworkHistory records={history} onOpen={onOpen} onDelete={onDelete} />
</div>
);
}