Check-my-work attachments, smart review follows deletions, GPL-3.0-only
- "Check my work" now takes photos and documents of the attempt: documents
are read in the browser into text, photos go to /api/homework as
solutionImages and are transcribed by the vision model before the check
("Reading your solution..."). The finished attempt comes back in
meta.solutionText and is stored as the record's attempt, so "Redo"
re-checks the same work without keeping the files.
- Deleting a deck now drops its due schedules and mistakes from the library's
smart review immediately (the counts resolve against the live deck list);
Undo brings them back, because the learning data itself is never erased.
- The phone override for the glacier blobs was still at 0.1/0.2: it is now a
quarter of the original value there too (0.025/0.05), matching the desktop.
- License: GPL-3.0-only (LICENSE, package.json, README section).
Tests: phase-3 photos-only API + desktop attachment checks (28/28), review
suite deck-delete checks (18/18), i18n/mobile/phase-2 green. README:
decisions 57-58. SW -> freezmo-v45.
This commit is contained in:
parent
9d204b85be
commit
d559865a09
13 changed files with 1033 additions and 24 deletions
|
|
@ -59,6 +59,7 @@ export function HomeworkInput({
|
|||
solution,
|
||||
onSolutionChange,
|
||||
intake,
|
||||
solutionIntake,
|
||||
onSubmit,
|
||||
error,
|
||||
history,
|
||||
|
|
@ -73,6 +74,8 @@ export function HomeworkInput({
|
|||
solution: string;
|
||||
onSolutionChange: (solution: string) => void;
|
||||
intake: ReturnType<typeof useNotesIntake>;
|
||||
/** Mode "check": photos/documents of the attempt itself. */
|
||||
solutionIntake: ReturnType<typeof useNotesIntake>;
|
||||
/** `modeOverride` lets the phone mode cards start the run they tapped. */
|
||||
onSubmit: (modeOverride?: HomeworkMode) => void;
|
||||
error: { message: string; kind?: string } | null;
|
||||
|
|
@ -84,6 +87,8 @@ export function HomeworkInput({
|
|||
const router = useRouter();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const cameraInputRef = useRef<HTMLInputElement>(null);
|
||||
const solutionFileRef = useRef<HTMLInputElement>(null);
|
||||
const solutionCameraRef = useRef<HTMLInputElement>(null);
|
||||
const [step, setStep] = useState(1);
|
||||
const [stepDir, setStepDir] = useState<1 | -1>(1);
|
||||
const [pasteOpen, setPasteOpen] = useState(false);
|
||||
|
|
@ -158,6 +163,15 @@ export function HomeworkInput({
|
|||
const documentText = documentsToText(readyDocuments.map((entry) => entry.extracted!));
|
||||
const totalBytes = intake.images.reduce((sum, image) => sum + image.bytes, 0);
|
||||
|
||||
/* Mode "check": attachments of the attempt itself. */
|
||||
const solutionReadyDocuments = solutionIntake.documents.filter(
|
||||
(entry) => entry.status === "ready" && entry.extracted,
|
||||
);
|
||||
const solutionBusy = solutionIntake.busy || solutionIntake.documents.some((entry) => entry.status === "extracting");
|
||||
const solutionDocumentText = documentsToText(solutionReadyDocuments.map((entry) => entry.extracted!));
|
||||
const solutionHasContent =
|
||||
solution.trim().length > 0 || solutionIntake.images.length > 0 || solutionDocumentText.trim().length > 0;
|
||||
|
||||
const hasContent =
|
||||
intake.images.length > 0 || documentImages.length > 0 || text.trim().length > 0 || documentText.trim().length > 0;
|
||||
const canSubmit = hasContent && !intake.busy && !documentsBusy;
|
||||
|
|
@ -185,7 +199,9 @@ export function HomeworkInput({
|
|||
</div>
|
||||
) : null;
|
||||
|
||||
/* Shared solution field: typed/pasted only (documented in the note). */
|
||||
/* Shared solution field (mode "check"): type it, paste it, or attach photos
|
||||
and documents of the work itself. Documents are read in the browser; the
|
||||
photos are transcribed by the server before the check runs. */
|
||||
const solutionField = (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[12px] font-semibold text-[var(--text)]" htmlFor="homework-solution">
|
||||
|
|
@ -204,6 +220,119 @@ export function HomeworkInput({
|
|||
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"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="touch-target"
|
||||
onClick={() => solutionFileRef.current?.click()}
|
||||
>
|
||||
<FolderIcon className="size-4" /> {t("homework.solution.add")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="touch-target"
|
||||
onClick={() => solutionCameraRef.current?.click()}
|
||||
>
|
||||
<CameraIcon className="size-4" /> {t("upload.camera")}
|
||||
</Button>
|
||||
{solutionBusy ? (
|
||||
<span className="text-[11.5px] text-[var(--text-subtle)]">{t("upload.reading")}</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={solutionFileRef}
|
||||
type="file"
|
||||
accept={`${ACCEPTED_TYPES.join(",")},${DOCUMENT_ACCEPT_ATTR}`}
|
||||
multiple
|
||||
className="sr-only"
|
||||
onChange={(event) => {
|
||||
void solutionIntake.addFiles(Array.from(event.target.files ?? []));
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={solutionCameraRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
className="sr-only"
|
||||
onChange={(event) => {
|
||||
void solutionIntake.addFiles(Array.from(event.target.files ?? []));
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
|
||||
{solutionIntake.images.length > 0 || solutionIntake.documents.length > 0 ? (
|
||||
<div className="space-y-2 pt-0.5">
|
||||
{solutionIntake.images.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{solutionIntake.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={() => solutionIntake.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}
|
||||
|
||||
{solutionIntake.documents.length > 0 ? (
|
||||
<ul className="space-y-2">
|
||||
{solutionIntake.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={() => solutionIntake.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>
|
||||
) : null}
|
||||
|
||||
{solutionIntake.fileError ? (
|
||||
<p role="alert" className="text-[12px] font-medium text-rose-700 dark:text-rose-300">
|
||||
{solutionIntake.fileError}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-[11px] leading-snug text-[var(--text-subtle)]">{t("homework.solution.note")}</p>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -530,7 +659,7 @@ export function HomeworkInput({
|
|||
<Button
|
||||
size="lg"
|
||||
className="touch-target w-full"
|
||||
disabled={!canSubmit || !solution.trim()}
|
||||
disabled={!canSubmit || !solutionHasContent || solutionBusy}
|
||||
onClick={() => {
|
||||
clearWizardDraft("homework");
|
||||
onSubmit("check");
|
||||
|
|
@ -772,7 +901,7 @@ export function HomeworkInput({
|
|||
size="lg"
|
||||
className="touch-target w-full"
|
||||
onClick={() => onSubmit()}
|
||||
disabled={!canSubmit || (mode === "check" && !solution.trim())}
|
||||
disabled={!canSubmit || (mode === "check" && (!solutionHasContent || solutionBusy))}
|
||||
loading={intake.busy}
|
||||
>
|
||||
<SparklesIcon className="size-5" />
|
||||
|
|
|
|||
|
|
@ -49,6 +49,11 @@ export function HomeworkScreen({ initialRecordId }: { initialRecordId?: string }
|
|||
tooManyPhotosKey: "homework.source.tooManyPhotos",
|
||||
tooManyDocsKey: "homework.source.tooManyDocs",
|
||||
});
|
||||
/* Mode "check" has its own intake: photos/scans of the attempt itself. */
|
||||
const solutionIntake = useNotesIntake({
|
||||
tooManyPhotosKey: "homework.solution.tooManyPhotos",
|
||||
tooManyDocsKey: "homework.solution.tooManyDocs",
|
||||
});
|
||||
|
||||
const [mode, setMode] = useState<HomeworkMode>("make");
|
||||
const [text, setText] = useState("");
|
||||
|
|
@ -88,16 +93,36 @@ export function HomeworkScreen({ initialRecordId }: { initialRecordId?: string }
|
|||
...intake.images.map((image) => image.dataUrl),
|
||||
...documentImages.map((image) => image.dataUrl),
|
||||
].slice(0, MAX_IMAGES_TO_SEND);
|
||||
|
||||
/* Mode "check": the attempt can be typed and/or attached. Documents are
|
||||
already text in the browser, so their text joins what was typed; photos
|
||||
go to the server to be transcribed (see /api/homework). */
|
||||
const solutionReady = solutionIntake.documents.filter((entry) => entry.status === "ready" && entry.extracted);
|
||||
const solutionDocumentText = documentsToText(solutionReady.map((entry) => entry.extracted!));
|
||||
const mergedSolution = [solution.trim(), solutionDocumentText.trim()].filter(Boolean).join("\n\n");
|
||||
const solutionImages = [
|
||||
...solutionIntake.images.map((image) => image.dataUrl),
|
||||
...solutionReady.flatMap((entry) => entry.extracted!.images).map((image) => image.dataUrl),
|
||||
].slice(0, MAX_IMAGES_TO_SEND);
|
||||
|
||||
return {
|
||||
mode: modeValue,
|
||||
images,
|
||||
text: mergedText,
|
||||
solution: modeValue === "check" ? solution.trim() : undefined,
|
||||
solution: modeValue === "check" ? mergedSolution : undefined,
|
||||
solutionImages: modeValue === "check" && solutionImages.length > 0 ? solutionImages : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const run = useCallback(
|
||||
async (input: { mode: HomeworkMode; images: string[]; text: string; solution?: string; recordId?: string }) => {
|
||||
async (input: {
|
||||
mode: HomeworkMode;
|
||||
images: string[];
|
||||
text: string;
|
||||
solution?: string;
|
||||
solutionImages?: string[];
|
||||
recordId?: string;
|
||||
}) => {
|
||||
setError(null);
|
||||
setView({ kind: "loading" });
|
||||
const vision = input.images.length > 0;
|
||||
|
|
@ -116,12 +141,13 @@ export function HomeworkScreen({ initialRecordId }: { initialRecordId?: string }
|
|||
abortRef.current = controller;
|
||||
|
||||
try {
|
||||
const { homework } = await generateHomework(
|
||||
const { homework, meta } = await generateHomework(
|
||||
{
|
||||
mode: input.mode,
|
||||
images: input.images.length > 0 ? input.images : undefined,
|
||||
text: input.text || undefined,
|
||||
solution: input.solution,
|
||||
solutionImages: input.solutionImages?.length ? input.solutionImages : undefined,
|
||||
},
|
||||
{ onProgress: setProgress, signal: controller.signal },
|
||||
);
|
||||
|
|
@ -148,7 +174,10 @@ export function HomeworkScreen({ initialRecordId }: { initialRecordId?: string }
|
|||
correct: homework.correct,
|
||||
wrong: homework.wrong,
|
||||
corrected: homework.corrected,
|
||||
attempt: input.solution,
|
||||
/* What was actually checked: typed text plus the server's
|
||||
transcription of any attached photos, so "Redo" can
|
||||
re-check the same work (the photos are never stored). */
|
||||
attempt: (typeof meta.solutionText === "string" && meta.solutionText.trim()) || input.solution,
|
||||
}
|
||||
: {
|
||||
mode: "guide",
|
||||
|
|
@ -182,11 +211,12 @@ export function HomeworkScreen({ initialRecordId }: { initialRecordId?: string }
|
|||
/** `modeOverride` lets the phone flow start straight from a tapped mode card. */
|
||||
const submit = (modeOverride?: HomeworkMode) => {
|
||||
const target = modeOverride ?? mode;
|
||||
if (target === "check" && !solution.trim()) {
|
||||
const payload = buildPayload(target);
|
||||
/* Check mode needs an attempt — typed, attached, or both. */
|
||||
if (target === "check" && !payload.solution?.trim() && !payload.solutionImages?.length) {
|
||||
setError({ message: t("homework.solution.required") });
|
||||
return;
|
||||
}
|
||||
const payload = buildPayload(target);
|
||||
void run(payload);
|
||||
};
|
||||
|
||||
|
|
@ -216,6 +246,7 @@ export function HomeworkScreen({ initialRecordId }: { initialRecordId?: string }
|
|||
|
||||
const newAssignment = () => {
|
||||
intake.clear();
|
||||
solutionIntake.clear();
|
||||
setText("");
|
||||
setSolution("");
|
||||
setError(null);
|
||||
|
|
@ -272,6 +303,7 @@ export function HomeworkScreen({ initialRecordId }: { initialRecordId?: string }
|
|||
solution={solution}
|
||||
onSolutionChange={setSolution}
|
||||
intake={intake}
|
||||
solutionIntake={solutionIntake}
|
||||
onSubmit={submit}
|
||||
error={error}
|
||||
history={history}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue