freezmo/components/UploadScreen.tsx
pepaskopec-blip bfc0f806b9 The scene stops heating the MacBook
"Terribly high CPU usage, the MacBook gets really hot" - a fanless MacBook
Air on a 144Hz screen. Measured on that machine with headed engines
(process CPU plus the per-process GPU time macOS keeps): an idle Aero
library cost LibreWolf ~110% CPU and ~230ms/s of GPU, the study feed ~130%,
Chromium up to ~57% CPU and ~350ms/s of GPU, Safari ~220-250ms/s of
WindowServer GPU. Priced one by one:

- the gradient mask on .ice-floats re-rendered the animated layer offscreen
  on every frame (~75 points of Gecko CPU). The floats now rise through a
  plain rectangular band clear of the header, the bottom chrome and the
  rail, and fade in and out inside it, so the clip never cuts a visible one;
- the overlay grain forced a backdrop readback per frame for a texture that
  measured a mean <=0.11/255 difference: removed;
- the invisible blob drift made every frame a full-screen redraw: the blobs
  are painted once;
- static glass (answer tiles, feedback panel, chips, inputs, dropzones,
  lists, segmented controls, Aero hint chips) re-blurred the moving scene
  every frame: only floating glass blurs now, and Aero's baked chat chrome
  drops a live blur that never showed under the bake;
- the scene also parks while the window has no focus and under any dialog
  or sheet scrim (.scene-pause); the loading icon is a compositor-run CSS
  animation instead of a framer-motion main-thread loop.

After, focused and animating: LibreWolf library 109% -> 33% CPU, study
134% -> 34%, chat 130% -> 42%; Chromium GPU 2.5-4.4x lower with 7-22 points
less CPU; Safari's WindowServer GPU -20-26% in Aero, about half in dark.
Unfocused or under a sheet: nothing. bubbles-test sweeps every float through
41 phases against the band and pins the parking and the cost contract;
tests/heat-probe.mjs (macOS) repeats the measurement; the suites find
Playwright's Chromium on macOS.

All 15 suites pass, lint clean, the contrast scan lost a finding. Version
0.6.7, SW v89.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 19:42:29 +02:00

1357 lines
56 KiB
TypeScript

"use client";
import { motion } from "framer-motion";
import { useAppReducedMotion as useReducedMotion } from "@/lib/motion-pref";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { AiDisclaimer } from "@/components/AiDisclaimer";
import { ApiError, generateDeck, type GenerateProgress } from "@/lib/client-api";
import {
DOCUMENT_ACCEPT_ATTR,
KIND_LABELS,
describeDocumentError,
describeExtraction,
documentKindOf,
documentsToText,
extractDocument,
isDocumentFile,
type DocumentKind,
type ExtractedDocument,
} from "@/lib/documents";
import {
ACCEPTED_TYPES,
MAX_IMAGES,
describeFileError,
formatBytes,
imageFilesFromDataTransfer,
isImageFile,
prepareImage,
type PreparedImage,
} from "@/lib/image";
import { createFolder, folderStore } from "@/lib/folders";
import { haptic } from "@/lib/haptics";
import { useI18n } from "@/lib/i18n";
import { stepVariants } from "@/lib/motion";
import { UNORGANIZED_ID, type Difficulty } from "@/lib/schemas";
import { takePendingShare } from "@/lib/share-pending";
import { takeDeckSeed } from "@/lib/deck-seed";
import { upsertDeck } from "@/lib/storage";
import { showToast } from "@/lib/toast";
import { openSettings } from "@/lib/ui-events";
import { modelErrorCopy } from "@/lib/model-errors";
import { ModelErrorActions } from "@/components/ModelErrorActions";
import { useBackClose } from "@/lib/use-back-close";
import { useIsPhone } from "@/lib/use-phone";
import { useOnline } from "@/lib/use-online";
import { clearWizardDraft, loadWizardDraft, saveWizardDraft } from "@/lib/wizard-draft";
import { wizardBusy } from "@/lib/wizard-busy";
import { cn, uid } from "@/lib/utils";
import { CameraIcon, ChevronDownIcon, FolderIcon, IceCubeIcon, PlusIcon, SnowflakeIcon, SparklesIcon, SunIcon } from "@/components/icons";
import { SegmentedPill } from "@/components/SegmentedPill";
import { Button } from "@/components/ui";
import { LoadingState, type LoadingStage } from "@/components/LoadingState";
import { WizardActionBar } from "@/components/WizardActionBar";
type Phase = "idle" | "preparing" | "loading" | "error";
/** A document the user added, with its client-side extraction state. */
type DocItem = {
id: string;
name: string;
kind: DocumentKind;
status: "extracting" | "ready" | "error";
progress?: string;
extracted?: ExtractedDocument;
error?: string;
};
const MAX_DOCUMENTS = 6;
/** Photos + rendered PDF pages + images embedded in documents. */
const MAX_IMAGES_TO_SEND = 12;
/** Cap for the merged text sent to the API. */
const MAX_TEXT_TO_SEND = 100_000;
const DIFFICULTIES: Array<{ value: Difficulty; labelKey: string; icon: React.ReactNode; blurbKey: string }> = [
{ value: "easy", labelKey: "upload.easy", icon: <SunIcon className="size-5" />, blurbKey: "upload.easyBlurb" },
{
value: "medium",
labelKey: "upload.medium",
icon: <SnowflakeIcon className="size-5" />,
blurbKey: "upload.mediumBlurb",
},
{ value: "hard", labelKey: "upload.hard", icon: <IceCubeIcon className="size-5" />, blurbKey: "upload.hardBlurb" },
];
const COUNT_OPTIONS = [8, 10, 12, 15];
const GENERATION_TIPS = ["upload.tip1", "upload.tip2", "upload.tip3", "upload.tip4"];
/** Same loading screen as the rest of the app (components/LoadingState). */
const UPLOAD_STAGES: LoadingStage[] = [
{ id: "vision", labelKey: "upload.vision1", messageKeys: ["upload.vision1", "upload.vision2", "upload.vision3"] },
{ id: "generation", labelKey: "upload.gen1", messageKeys: ["upload.gen1", "upload.gen2", "upload.gen3"] },
];
export function UploadScreen() {
const router = useRouter();
const { t, plural } = useI18n();
const [images, setImages] = useState<PreparedImage[]>([]);
const [documents, setDocuments] = useState<DocItem[]>([]);
const [text, setText] = useState("");
const [focus, setFocus] = useState("");
const [difficulty, setDifficulty] = useState<Difficulty>("medium");
const [folderId, setFolderId] = useState<string>(UNORGANIZED_ID);
const [creatingFolder, setCreatingFolder] = useState(false);
const [folderDraft, setFolderDraft] = useState("");
const [count, setCount] = useState(10);
const [phase, setPhase] = useState<Phase>("idle");
const [error, setError] = useState<{ message: string; kind?: string } | null>(null);
const [fileError, setFileError] = useState<string | null>(null);
const [dragging, setDragging] = useState(false);
const [progress, setProgress] = useState<GenerateProgress>({ stage: "vision", message: "" });
/** Phones only: which step of the 3-step flow is showing. */
const [step, setStep] = useState(1);
/** 1 = moving forward (slides from the right), -1 = Back (from the left). */
const [stepDir, setStepDir] = useState<1 | -1>(1);
/** Phones only: the “paste text instead” field on step 1. */
const [pasteOpen, setPasteOpen] = 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((value) => Math.max(1, value - 1));
});
useEffect(() => {
setClipboardAvailable(
typeof navigator !== "undefined" && typeof navigator.clipboard?.readText === "function",
);
}, []);
const folders = useSyncExternalStore(
folderStore.subscribe,
folderStore.getSnapshot,
folderStore.getServerSnapshot,
);
const sortedFolders = useMemo(
() => [...folders].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" })),
[folders],
);
const targetFolder = folderId === UNORGANIZED_ID ? undefined : folderId;
const fileInputRef = useRef<HTMLInputElement>(null);
const cameraInputRef = useRef<HTMLInputElement>(null);
const abortRef = useRef<AbortController | null>(null);
/**
* Mirrors of the current lists. React defers state updaters, so reading the
* count from inside `setState` would silently skip work — always read these.
*/
const imagesRef = useRef<PreparedImage[]>([]);
const documentsRef = useRef<DocItem[]>([]);
useEffect(() => {
imagesRef.current = images;
}, [images]);
useEffect(() => {
documentsRef.current = documents;
}, [documents]);
/* A loading run locks the mode switch (see WizardModeSwitch). */
useEffect(() => {
wizardBusy.set(phase === "loading");
}, [phase]);
useEffect(() => () => wizardBusy.set(false), []);
/* Leaving the page mid-generation stops the request instead of letting it
finish invisibly and save a deck the user never sees. The loading
screen's Cancel button is the in-page way out. */
useEffect(() => () => abortRef.current?.abort(), []);
const addFiles = useCallback(async (files: File[]) => {
if (files.length === 0) return;
setFileError(null);
setPhase("preparing");
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, MAX_DOCUMENTS - documentsRef.current.length);
const acceptedDocuments = documentFiles.slice(0, documentRoom);
if (documentFiles.length > documentRoom) problems.push(t("upload.tooManyDocs", { n: MAX_DOCUMENTS }));
// Documents are read asynchronously (pdf.js can take a while for scans).
if (acceptedDocuments.length > 0) {
for (const file of acceptedDocuments) {
const kind = documentKindOf(file);
if (!kind) continue;
const item: DocItem = { 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<DocItem>) =>
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, MAX_IMAGES - imagesRef.current.length);
const acceptedImages = imageFiles.slice(0, photoRoom);
if (imageFiles.length > photoRoom) problems.push(t("upload.tooManyPhotos", { n: MAX_IMAGES }));
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(" "));
setPhase("idle");
}, [t]);
// Clipboard paste (Ctrl/⌘ + V) of screenshots or copied photos.
useEffect(() => {
const onPaste = (event: ClipboardEvent) => {
if (phase === "loading") return;
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 addFiles(files);
return;
}
if (!isTextField) {
const pasted = event.clipboardData?.getData("text/plain")?.trim();
if (pasted && pasted.length > 40) {
event.preventDefault();
setText((current) => (current ? `${current}\n${pasted}` : pasted));
}
}
};
window.addEventListener("paste", onPaste);
return () => window.removeEventListener("paste", onPaste);
}, [addFiles, phase]);
const removeImage = (id: string) => {
imagesRef.current = imagesRef.current.filter((image) => image.id !== id);
setImages((current) => current.filter((image) => image.id !== id));
};
const removeDocument = (id: string) => {
documentsRef.current = documentsRef.current.filter((entry) => entry.id !== id);
setDocuments((current) => current.filter((entry) => entry.id !== id));
};
const readyDocuments = documents.filter((entry) => entry.status === "ready" && entry.extracted);
const documentsBusy = documents.some((entry) => entry.status === "extracting");
const documentImages = readyDocuments.flatMap((entry) => entry.extracted!.images);
const documentText = documentsToText(readyDocuments.map((entry) => entry.extracted!));
const totalBytes = images.reduce((sum, image) => sum + image.bytes, 0);
const hasContent =
images.length > 0 || documentImages.length > 0 || text.trim().length > 0 || documentText.trim().length > 0;
const canGenerate = hasContent && !documentsBusy && phase !== "preparing";
const imagesToSend = [...images.map((image) => image.dataUrl), ...documentImages.map((image) => image.dataUrl)].slice(
0,
MAX_IMAGES_TO_SEND,
);
const imagesSkipped =
images.length + documentImages.length - imagesToSend.length;
/* Restore a draft after an OS kill / reload; clear it on success. */
useEffect(() => {
const draft = loadWizardDraft("deck");
if (!draft) return;
if (draft.text) setText(draft.text);
if (draft.focus) setFocus(draft.focus);
if (draft.difficulty === "easy" || draft.difficulty === "medium" || draft.difficulty === "hard") {
setDifficulty(draft.difficulty);
}
if (draft.count) setCount(draft.count);
if (draft.folderId) setFolderId(draft.folderId);
if (draft.images?.length) setImages(draft.images);
}, []);
/* Content shared in from another app (share target) lands here. */
useEffect(() => {
const pending = takePendingShare();
if (!pending) return;
if (pending.text) setText((current) => (current ? `${current}\n${pending.text}` : pending.text));
if (pending.files.length > 0) void addFiles(pending.files);
}, [addFiles]);
/* Homework → "Turn into a study deck": content + options pre-filled. */
useEffect(() => {
const seed = takeDeckSeed();
if (!seed) return;
setText(seed.text);
if (seed.difficulty) setDifficulty(seed.difficulty);
if (seed.count) setCount(seed.count);
if (seed.step && seed.step > 1) setStep(seed.step);
}, []);
/* Persist the draft while the user works (debounced, best-effort). */
useEffect(() => {
if (phase === "loading") return;
const timer = window.setTimeout(() => {
saveWizardDraft("deck", { text, focus, difficulty, count, folderId, images });
}, 400);
return () => window.clearTimeout(timer);
}, [text, focus, difficulty, count, folderId, images, phase]);
const generate = async () => {
if (!canGenerate) return;
setError(null);
setPhase("loading");
setProgress({
stage: imagesToSend.length ? "vision" : "generation",
message: imagesToSend.length ? t("upload.vision1") : t("upload.gen1"),
});
const controller = new AbortController();
abortRef.current = controller;
const mergedText = [text.trim(), documentText.trim()].filter(Boolean).join("\n\n").slice(0, MAX_TEXT_TO_SEND);
try {
const { deck } = await generateDeck(
{
images: imagesToSend.length ? imagesToSend : undefined,
text: mergedText || undefined,
focus: focus.trim() || undefined,
difficulty,
count,
},
{ onProgress: setProgress, signal: controller.signal },
);
const now = Date.now();
const stored = {
...deck,
id: uid("deck"),
folderId: targetFolder,
createdAt: now,
updatedAt: now,
attempts: 0,
};
upsertDeck(stored);
clearWizardDraft("deck");
router.push(`/deck/${stored.id}`);
} catch (generateError) {
setError({
message:
generateError instanceof ApiError
? generateError.message
: t("upload.genericError"),
kind: generateError instanceof ApiError ? generateError.kind : undefined,
});
setPhase("error");
} finally {
abortRef.current = null;
}
};
/* Shared controls: the desktop card and the phone step 2 render the same
folder / difficulty / count pickers, so their markup lives in one place. */
const folderPicker = (
<div className="space-y-2">
<span className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
{t("upload.freezeInto")}
</span>
<div className="flex flex-wrap items-center gap-2">
{[{ id: UNORGANIZED_ID, name: t("common.unorganized") }, ...sortedFolders].map((folder) => {
const active = folderId === folder.id;
return (
<button
key={folder.id}
type="button"
aria-pressed={active}
onClick={() => {
haptic("toggle");
setFolderId(folder.id);
}}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-3.5 py-1.5 text-[13px] font-medium transition",
active
? "border-accent-500 bg-accent-50 text-accent-800 dark:bg-accent-500/20 dark:text-accent-100"
: "border-[var(--hairline)] text-frost-600 hover:text-frost-900 dark:text-frost-300 dark:hover:text-white",
)}
>
<FolderIcon className="size-4" />
{folder.name}
</button>
);
})}
{creatingFolder ? (
<span className="inline-flex items-center gap-1">
<input
autoFocus
value={folderDraft}
onChange={(event) => setFolderDraft(event.target.value)}
onKeyDown={(event) => {
event.stopPropagation();
if (event.key === "Escape") {
setCreatingFolder(false);
setFolderDraft("");
}
if (event.key === "Enter") {
const created = createFolder(folderDraft);
if ("folder" in created) {
setFolderId(created.folder.id);
setFolderDraft("");
setCreatingFolder(false);
} else {
setFileError(created.error);
}
}
}}
aria-label={t("common.newFolderName")}
placeholder={t("upload.folderPlaceholder")}
maxLength={60}
enterKeyHint="done"
autoCapitalize="words"
autoComplete="off"
spellCheck={false}
className="w-32 rounded-xl border border-[var(--hairline)] bg-[var(--surface-strong)] px-3 py-1.5 text-sm outline-none"
/>
<Button
size="sm"
onClick={() => {
const created = createFolder(folderDraft);
if ("folder" in created) {
setFolderId(created.folder.id);
setFolderDraft("");
setCreatingFolder(false);
} else {
setFileError(created.error);
}
}}
>
{t("common.add")}
</Button>
</span>
) : (
<button
type="button"
onClick={() => setCreatingFolder(true)}
className="inline-flex items-center gap-1.5 rounded-xl border border-dashed border-[var(--hairline)] px-3 py-1.5 text-sm font-semibold text-frost-600 transition-colors hover:text-accent-700 dark:text-frost-400 dark:hover:text-accent-200"
>
<PlusIcon className="size-4" /> {t("common.newFolder")}
</button>
)}
</div>
</div>
);
const difficultyPicker = (
<div role="radiogroup" aria-label={t("upload.difficultyAria")} className="grid grid-cols-3 gap-2">
{DIFFICULTIES.map((option) => {
const selected = difficulty === option.value;
return (
<button
key={option.value}
type="button"
role="radio"
aria-checked={selected}
onClick={() => {
haptic("toggle");
setDifficulty(option.value);
}}
className={cn(
"relative flex flex-col items-center gap-1 overflow-hidden rounded-2xl border px-2 py-3 text-center transition-colors",
selected && isPhone
? "border-transparent"
: selected
? "border-accent-400 bg-accent-50/80 shadow-lift dark:bg-accent-500/20"
: "border-white/60 bg-white/45 hover:bg-white/70 dark:border-white/12 dark:bg-white/[0.05]",
)}
>
{selected && isPhone ? (
<SegmentedPill
layoutId="difficulty-pill"
plain
className="rounded-2xl border border-accent-400 bg-accent-50/80 shadow-lift dark:bg-accent-500/20"
/>
) : null}
<span aria-hidden className="relative z-10 text-frost-600 dark:text-frost-200">
{option.icon}
</span>
<span className="relative z-10 text-sm font-bold text-[var(--text)]">{t(option.labelKey)}</span>
<span className="relative z-10 hidden text-[11px] text-frost-600 sm:block dark:text-frost-400">{t(option.blurbKey)}</span>
</button>
);
})}
</div>
);
const countPicker = (
<div className="flex flex-wrap items-center gap-2">
<span className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
{t("upload.activities")}
</span>
{COUNT_OPTIONS.map((option) => (
<button
key={option}
type="button"
aria-pressed={count === option}
onClick={() => {
haptic("toggle");
setCount(option);
}}
className={cn(
"relative min-w-12 overflow-hidden rounded-xl border-2 px-3 py-1.5 text-sm font-bold transition",
count === option && isPhone
? "border-transparent"
: count === option
? "grad-ice border-transparent text-[#06111f]"
: "border-[var(--hairline)] text-[var(--text-muted)] hover:bg-[var(--hover)]",
)}
>
{count === option && isPhone ? <SegmentedPill layoutId="count-pill" className="rounded-xl" /> : null}
<span className={cn("relative z-10", count === option && isPhone && "text-[#06111f]")}>{option}</span>
</button>
))}
</div>
);
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">
{modelErrorCopy(error.kind)?.title ??
(error.kind === "needs_key"
? t("upload.errNeedsKey")
: error.kind === "rate_limited"
? t("upload.errRate")
: error.kind === "auth"
? t("upload.errAuth")
: t("upload.errFailed"))}
</p>
<p className="mt-1">{modelErrorCopy(error.kind)?.body ?? error.message}</p>
<div className="mt-3 flex flex-wrap gap-2">
{error.kind === "needs_key" || error.kind === "auth" ? (
<Button size="sm" onClick={() => openSettings("key")}>
{t("upload.openSettings")}
</Button>
) : null}
<ModelErrorActions kind={error.kind} />
<Button
size="sm"
variant={error.kind === "needs_key" || error.kind === "auth" ? "secondary" : "primary"}
onClick={() => void generate()}
>
{t("common.retry")}
</Button>
</div>
</div>
) : null;
const phoneHelper = !online
? t("offline.generate")
: documentsBusy
? t("upload.readingDocs")
: hasContent
? t("upload.ready")
: t("upload.addToStart");
if (phase === "loading") {
return (
<LoadingState
stages={UPLOAD_STAGES}
activeStage={progress.stage}
message={progress.message}
tipKeys={GENERATION_TIPS}
onCancel={() => abortRef.current?.abort()}
/>
);
}
/* Phones: the 3-step flow. Same state as the desktop card, different shape. */
if (isPhone) {
const stepLabel =
step === 1 ? t("upload.phone.notes") : step === 2 ? t("upload.phone.options") : t("upload.phone.summary");
const stepDesc =
step === 1
? t("upload.phone.notesDesc", { images: MAX_IMAGES, documents: MAX_DOCUMENTS })
: step === 2
? t("upload.phone.optionsDesc")
: t("upload.phone.summaryDesc");
const filesSummary =
images.length > 0 || documents.length > 0
? [
images.length > 0 ? plural(images.length, "upload.photoCount") : "",
documents.length > 0 ? plural(documents.length, "upload.docSummary") : "",
]
.filter(Boolean)
.join(" · ")
: t("upload.summaryText");
const difficultyLabel =
difficulty === "easy" ? t("upload.easy") : difficulty === "hard" ? t("upload.hard") : t("upload.medium");
const folderLabel =
folderId === UNORGANIZED_ID
? t("common.unorganized")
: (sortedFolders.find((folder) => folder.id === folderId)?.name ?? t("common.unorganized"));
const editStep = (target: 1 | 2 | 3) => {
setStepDir(target < step ? -1 : 1);
setStep(target);
};
const fileInput = (
<input
ref={fileInputRef}
type="file"
aria-hidden="true"
tabIndex={-1}
accept={`${ACCEPTED_TYPES.join(",")},${DOCUMENT_ACCEPT_ATTR}`}
multiple
className="sr-only"
onChange={(event) => {
void addFiles(Array.from(event.target.files ?? []));
event.target.value = "";
}}
/>
);
const cameraInput = (
<input
ref={cameraInputRef}
type="file"
aria-hidden="true"
tabIndex={-1}
accept="image/*"
capture="environment"
className="sr-only"
onChange={(event) => {
void addFiles(Array.from(event.target.files ?? []));
event.target.value = "";
}}
/>
);
return (
<div className="relative">
{errorCard}
{/* progress: thin bar + “1 / 3” */}
<div className="mt-1 flex items-center gap-3">
<div
role="progressbar"
aria-label={stepLabel}
aria-valuemin={1}
aria-valuemax={3}
aria-valuenow={step}
className="h-1 flex-1 overflow-hidden rounded-full bg-[var(--track)]"
>
<div
className="grad-ice h-full rounded-full transition-[width] duration-300 motion-reduce:transition-none"
style={{ width: `${(step / 3) * 100}%` }}
/>
</div>
<span className="shrink-0 text-[11px] font-semibold tabular-nums text-[var(--text-subtle)]">
{t("common.stepOf", { n: step, total: 3 })}
</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("upload.addPhotosFiles")}</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={pasteOpen || text.trim().length > 0}
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) {
setText((current) => (current ? `${current}\n${clip}` : clip));
setPasteOpen(true);
}
haptic("tap");
} catch {
setFileError(t("upload.clipboardDenied"));
}
})();
}}
>
{t("upload.pasteClipboard")}
</Button>
) : null}
{pasteOpen || text.trim().length > 0 ? (
<textarea
value={text}
onChange={(event) => setText(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
if (hasContent && !documentsBusy) {
setStepDir(1);
setStep(2);
}
}
}}
rows={5}
placeholder={t("upload.pastedPlaceholder")}
aria-label={t("upload.pastedNotes")}
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 transition placeholder:text-frost-400 focus:border-accent-400 dark:border-white/12 dark:bg-white/[0.07] dark:text-frost-50"
/>
) : null}
{images.length > 0 || documents.length > 0 ? (
<ul className="flex flex-wrap items-center gap-2">
{images.map((image) => (
<li key={image.id} className="relative">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={image.dataUrl}
alt=""
className="size-14 rounded-xl border border-[var(--hairline)] object-cover"
/>
<button
type="button"
onClick={() => {
const removed = images.find((entry) => entry.id === image.id);
removeImage(image.id);
if (removed) {
showToast({
message: t("toast.fileRemoved"),
actionLabel: t("toast.undo"),
onAction: () =>
setImages((current) =>
current.some((entry) => entry.id === removed.id) ? current : [...current, removed],
),
});
}
}}
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>
))}
{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)]">
{KIND_LABELS[entry.kind]}
</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={() => {
const snapshot = documents.find((doc) => doc.id === entry.id);
removeDocument(entry.id);
if (snapshot) {
showToast({
message: t("toast.fileRemoved"),
actionLabel: t("toast.undo"),
onAction: () =>
setDocuments((current) =>
current.some((doc) => doc.id === snapshot.id) ? current : [...current, snapshot],
),
});
}
}}
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>
) : (
<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>
)}
{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>
))}
{phase === "preparing" ? (
<p className="text-[12.5px] font-medium text-[var(--text-muted)]">{t("upload.compressing")}</p>
) : null}
{fileError ? (
<p role="alert" className="text-xs font-semibold text-rose-600 dark:text-rose-300">
{fileError}
</p>
) : null}
{fileInput}
{cameraInput}
</div>
) : null}
{step === 2 ? (
<div className="space-y-5">
<div className="space-y-2">
{folderPicker}
<p className="text-[12px] text-[var(--text-subtle)]">{t("upload.phone.folderHelp")}</p>
</div>
<div className="space-y-2">
<p className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
{t("upload.difficultyAria")}
</p>
{difficultyPicker}
<p className="text-[12px] text-[var(--text-subtle)]">{t("upload.phone.difficultyHelp")}</p>
</div>
<div className="space-y-2">
{countPicker}
<p className="text-[12px] text-[var(--text-subtle)]">{t("upload.phone.activitiesHelp")}</p>
</div>
<details className="group rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
<summary className="flex cursor-pointer list-none items-center gap-2 text-[13px] font-semibold text-[var(--text)] [&::-webkit-details-marker]:hidden">
<ChevronDownIcon className="size-4 shrink-0 transition-transform group-open:rotate-180" />
{t("upload.focusExtra")}
</summary>
<div className="space-y-3 pt-3">
<div className="space-y-1.5">
<label
htmlFor="notes-text-phone"
className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]"
>
{t("upload.pastedNotes")}
</label>
<textarea
id="notes-text-phone"
value={text}
onChange={(event) => setText(event.target.value)}
rows={4}
placeholder={t("upload.pastedPlaceholder")}
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 transition placeholder:text-frost-400 focus:border-accent-400 dark:border-white/12 dark:bg-white/[0.07] dark:text-frost-50"
/>
</div>
<div className="space-y-1.5">
<label
htmlFor="focus-phone"
className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]"
>
{t("upload.topicHint")}
</label>
<input
id="focus-phone"
value={focus}
onChange={(event) => setFocus(event.target.value)}
placeholder={t("upload.topicPlaceholder")}
inputMode="text"
enterKeyHint="done"
autoComplete="off"
autoCapitalize="sentences"
spellCheck={false}
className="w-full rounded-2xl border border-white/60 bg-white/55 px-4 py-3 text-sm text-frost-950 outline-none transition placeholder:text-frost-400 focus:border-accent-400 dark:border-white/12 dark:bg-white/[0.07] dark:text-frost-50"
/>
</div>
</div>
</details>
</div>
) : null}
{step === 3 ? (
<div className="space-y-4">
{images.length > 0 || documents.length > 0 ? (
<ul className="flex flex-wrap items-center gap-2">
{images.map((image) => (
<li key={image.id}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={image.dataUrl}
alt=""
className="size-12 rounded-xl border border-[var(--hairline)] object-cover"
/>
</li>
))}
{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)] px-2.5 py-1.5"
>
<span className="rounded-md bg-[var(--surface-sunken)] px-1.5 py-0.5 text-[10px] font-bold uppercase text-[var(--text-muted)]">
{KIND_LABELS[entry.kind]}
</span>
<span className="max-w-32 truncate text-[12.5px] font-medium text-[var(--text)]" title={entry.name}>
{entry.name}
</span>
</li>
))}
</ul>
) : null}
<dl className="overflow-hidden rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] px-3">
{[
{ label: t("upload.summary.files"), value: filesSummary, target: 1 as const },
{ label: t("upload.difficultyAria"), value: difficultyLabel, target: 2 as const },
{ label: t("upload.activities"), value: plural(count, "deck.activities"), target: 2 as const },
{ label: t("upload.summary.folder"), value: folderLabel, target: 2 as const },
].map((row, index) => (
<div
key={row.label}
className={cn(
"flex items-center justify-between gap-3 py-2.5",
index > 0 && "border-t border-[var(--hairline-soft)]",
)}
>
<dt className="shrink-0 text-[12px] text-[var(--text-subtle)]">{row.label}</dt>
<dd className="flex min-w-0 items-center gap-2">
<span className="truncate text-[13px] font-medium text-[var(--text)]">{row.value}</span>
<button
type="button"
onClick={() => editStep(row.target)}
className="shrink-0 text-[12px] font-semibold text-accent-700 dark:text-accent-200"
>
{t("common.edit")}
</button>
</dd>
</div>
))}
</dl>
<p className="text-[12.5px] text-[var(--text-subtle)]">{phoneHelper}</p>
<AiDisclaimer />
</div>
) : null}
</motion.div>
{/* 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((value) => Math.max(1, value - 1));
}}
>
{t("common.back")}
</Button>
)}
{step < 3 ? (
<Button
size="lg"
className="touch-target flex-[1.5]"
onClick={() => {
setStepDir(1);
setStep((value) => Math.min(3, value + 1));
}}
disabled={!hasContent || documentsBusy}
>
{t("common.next")}
</Button>
) : (
<Button size="lg" className="touch-target flex-[1.5]" onClick={() => void generate()} disabled={!canGenerate || !online}>
<SparklesIcon className="size-5" /> {t("upload.freezeDeck")}
</Button>
)}
</WizardActionBar>
</div>
);
}
return (
<div className="relative">
<div className="relative space-y-5">
<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]">
<SnowflakeIcon className="size-8 shrink-0 text-frost-500 dark:text-frost-200" />
<span>{t("upload.title")}</span>
</h1>
<p className="max-w-xl text-[15px] text-frost-600 dark:text-frost-300">
{t("upload.intro")}
</p>
</section>
{/* Dedicated flow: this page is the Study Deck uploader. Homework has
its own screen (/homework), reached from the mode switch above, the
centre tab or the library's homework tab. */}
{errorCard}
{/* ---------------------------------------------------------- */}
<section
aria-label={t("upload.addPhotosAria")}
onDragOver={(event) => {
event.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={(event) => {
event.preventDefault();
setDragging(false);
const dropped = Array.from(event.dataTransfer?.files ?? []);
void addFiles(dropped.length > 0 ? dropped : imageFilesFromDataTransfer(event.dataTransfer));
}}
className={cn(
"card relative overflow-hidden p-4 transition-all sm:p-5",
dragging && "border-frost-400 bg-frost-50 ring-4 ring-frost-200 dark:bg-frost-500/10 dark:ring-frost-500/30",
)}
>
<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("upload.step1")}</h2>
<p className="text-[12.5px] text-[var(--text-subtle)]">
{t("upload.step1Help", { images: MAX_IMAGES, documents: MAX_DOCUMENTS })}
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="secondary" size="sm" onClick={() => fileInputRef.current?.click()}>
<FolderIcon className="size-4" /> {t("upload.chooseFiles")}
</Button>
<Button variant="secondary" size="sm" onClick={() => cameraInputRef.current?.click()}>
<CameraIcon className="size-4" /> {t("upload.camera")}
</Button>
</div>
</div>
<input
ref={fileInputRef}
type="file"
aria-hidden="true"
tabIndex={-1}
accept={`${ACCEPTED_TYPES.join(",")},${DOCUMENT_ACCEPT_ATTR}`}
multiple
className="sr-only"
onChange={(event) => {
void addFiles(Array.from(event.target.files ?? []));
event.target.value = "";
}}
/>
<input
ref={cameraInputRef}
type="file"
aria-hidden="true"
tabIndex={-1}
accept="image/*"
capture="environment"
className="sr-only"
onChange={(event) => {
void addFiles(Array.from(event.target.files ?? []));
event.target.value = "";
}}
/>
{images.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 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="animate-float text-frost-500 dark:text-frost-200">
<SnowflakeIcon className="size-12" />
</span>
<span className="text-[14px] font-medium text-[var(--text)]">
{t("upload.dropHere")}
</span>
<span className="text-[12.5px] text-[var(--text-subtle)]">
{t("upload.formats")}
</span>
</button>
) : (
<ul className="mt-4 grid grid-cols-3 gap-2 sm:grid-cols-4">
{images.map((image) => (
<li key={image.id} className="group relative overflow-hidden rounded-2xl border border-frost-200 dark:border-white/15">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={image.dataUrl}
alt={image.name}
className="aspect-square w-full object-cover"
/>
<button
type="button"
onClick={() => removeImage(image.id)}
aria-label={t("upload.remove", { name: image.name })}
className="absolute right-1 top-1 grid size-7 place-items-center rounded-full bg-frost-950/70 text-xs font-bold text-white transition hover:bg-rose-600"
>
✕
</button>
<span className="absolute inset-x-0 bottom-0 bg-frost-950/60 px-2 py-1 text-[10px] font-semibold text-white">
{formatBytes(image.bytes)}
</span>
</li>
))}
{images.length < MAX_IMAGES ? (
<li>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="grid aspect-square w-full place-items-center rounded-2xl border-2 border-dashed border-white/70 text-2xl text-accent-500 transition hover:border-accent-300 hover:bg-white/60 dark:border-white/15 dark:text-accent-200"
aria-label={t("upload.addMorePhotos")}
>
+
</button>
</li>
) : null}
</ul>
)}
{documents.length > 0 ? (
<ul className="mt-3 space-y-2">
{documents.map((entry) => (
<li
key={entry.id}
className="flex items-start gap-3 rounded-2xl border border-white/60 bg-white/50 p-3 dark:border-white/12 dark:bg-white/[0.06]"
>
<span className="grid size-10 shrink-0 place-items-center rounded-xl bg-frost-100 text-[10px] font-extrabold uppercase tracking-wide text-frost-700 dark:bg-white/10 dark:text-frost-100">
{KIND_LABELS[entry.kind]}
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-[var(--text)]" title={entry.name}>
{entry.name}
</p>
{entry.status === "extracting" ? (
<p className="text-xs font-medium text-frost-700 dark:text-frost-200">
{entry.progress ?? t("upload.reading")}
</p>
) : null}
{entry.status === "error" ? (
<p className="text-xs font-medium text-rose-600 dark:text-rose-300">{entry.error}</p>
) : null}
{entry.status === "ready" && entry.extracted ? (
<>
<p className="text-[12.5px] text-[var(--text-subtle)]">
{describeExtraction(entry.extracted, t, plural)}
</p>
{entry.extracted.words === 0 && entry.extracted.images.length === 0 ? (
<p className="text-xs font-medium text-amber-700 dark:text-amber-300">
{t("upload.noReadable")}
</p>
) : null}
{entry.extracted.warnings.map((warning) => (
<p key={warning} className="text-[12.5px] text-[var(--text-subtle)]">
ⓘ {warning}
</p>
))}
</>
) : null}
</div>
<button
type="button"
onClick={() => removeDocument(entry.id)}
aria-label={t("upload.remove", { name: entry.name })}
className="grid size-7 shrink-0 place-items-center rounded-full bg-frost-950/10 text-xs font-bold text-frost-900 transition hover:bg-rose-500 hover:text-white dark:bg-white/10 dark:text-frost-50"
>
✕
</button>
</li>
))}
</ul>
) : null}
{images.length > 0 || documents.length > 0 ? (
<p className="mt-2 text-[12.5px] text-[var(--text-subtle)]">
{images.length > 0
? plural(images.length, "upload.photoSummary", { size: formatBytes(totalBytes) })
: ""}
{images.length > 0 && documents.length > 0 ? " · " : ""}
{documents.length > 0
? `${plural(documents.length, "upload.docSummary")}${
documentImages.length
? ` (${plural(documentImages.length, "upload.docImages")})`
: ""
}`
: ""}
{imagesSkipped > 0
? ` · ${plural(imagesSkipped, "upload.skipImages", { max: MAX_IMAGES_TO_SEND })}`
: ""}
</p>
) : null}
{phase === "preparing" ? (
<p className="mt-2 text-[12.5px] font-medium text-[var(--text-muted)]">{t("upload.compressing")}</p>
) : null}
{fileError ? (
<p role="alert" className="mt-2 text-xs font-semibold text-rose-600 dark:text-rose-300">
{fileError}
</p>
) : null}
</section>
{/* ---------------------------------------------------------- */}
<section className="card space-y-4 p-4 sm:p-5">
<div>
<h2 className="text-[15px] font-semibold tracking-[-0.01em] text-[var(--text)]">{t("upload.step2")}</h2>
<p className="text-[12.5px] text-[var(--text-subtle)]">
{t("upload.step2Help")}
</p>
</div>
<div className="space-y-1.5">
<label htmlFor="notes-text" className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
{t("upload.pastedNotes")}
</label>
<textarea
id="notes-text"
value={text}
onChange={(event) => setText(event.target.value)}
rows={4}
placeholder={t("upload.pastedPlaceholder")}
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 transition placeholder:text-frost-400 focus:border-accent-400 dark:border-white/12 dark:bg-white/[0.07] dark:text-frost-50"
/>
</div>
<div className="space-y-1.5">
<label htmlFor="focus" className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
{t("upload.topicHint")}
</label>
<input
id="focus"
value={focus}
onChange={(event) => setFocus(event.target.value)}
placeholder={t("upload.topicPlaceholder")}
className="w-full rounded-2xl border border-white/60 bg-white/55 px-4 py-3 text-sm text-frost-950 outline-none transition placeholder:text-frost-400 focus:border-accent-400 dark:border-white/12 dark:bg-white/[0.07] dark:text-frost-50"
/>
</div>
</section>
{/* ---------------------------------------------------------- */}
<section className="card space-y-4 p-4 sm:p-5">
<h2 className="text-[15px] font-semibold tracking-[-0.01em] text-[var(--text)]">{t("upload.step3")}</h2>
{folderPicker}
{difficultyPicker}
{countPicker}
</section>
{/* Same CTA block as the homework flow: full-width button with the
helper line centred underneath, in the page flow — no sticky bar. */}
<div className="space-y-2">
<Button
size="lg"
className="touch-target w-full"
onClick={() => void generate()}
disabled={!canGenerate}
>
<SparklesIcon className="size-5" /> {t("upload.freezeDeck")}
</Button>
<p className="text-center text-[12px] text-[var(--text-subtle)]">
{documentsBusy
? t("upload.readingDocs")
: hasContent
? t("upload.ready")
: t("upload.addToStart")}
</p>
<AiDisclaimer />
</div>
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Loading state */
/* ------------------------------------------------------------------ */