Correcting the previous change: instead of stripping the snowflake from "Freeze my deck", "Make my homework" now carries the homework flow's own sparkles icon, and the snowflake is back on the deck button. Both CTAs are now identical in shape — feature icon + label, full-width, 44px, 14px radius, helper line centred underneath, same muted disabled chip and ice gradient when enabled. The computed-style diff between the two buttons is empty and their accessible names are unchanged. Service worker: freezmo-v14. Verified: CTA-parity suite 32/32, desktop regression 74/74, mobile 51/51. Lint + build clean.
340 lines
15 KiB
TypeScript
340 lines
15 KiB
TypeScript
"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("homeworkHeroTitle")}</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}>
|
|
<SparklesIcon className="size-5" />
|
|
{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>
|
|
);
|
|
}
|