freezmo/components/UploadScreen.tsx
Martin Dvorak efbdb5a55a Give both creation CTAs their feature icon (the other way around)
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.
2026-10-09 08:06:25 +00:00

806 lines
34 KiB
TypeScript

"use client";
import { motion } from "framer-motion";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { ApiError, generateDeck, type GenerateProgress } from "@/lib/client-api";
import {
DOCUMENT_ACCEPT_ATTR,
KIND_LABELS,
describeDocumentError,
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 { useI18n } from "@/lib/i18n";
import { UNORGANIZED_ID, type Difficulty } from "@/lib/schemas";
import { upsertDeck } from "@/lib/storage";
import { openSettings } from "@/lib/ui-events";
import { cn, uid } from "@/lib/utils";
import { CameraIcon, FolderIcon, IceCubeIcon, PlusIcon, SnowflakeIcon, SunIcon } from "@/components/icons";
import { Button, ProgressBar } from "@/components/ui";
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;
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 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"];
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: "" });
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]);
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;
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);
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;
}
};
if (phase === "loading") {
return <LoadingState progress={progress} onCancel={() => abortRef.current?.abort()} />;
}
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 library tab, the
drawer or the bottom bar — no mode picker here. */}
{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">
{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">{error.message}</p>
<div className="mt-3 flex flex-wrap gap-2">
{error.kind === "needs_key" || error.kind === "auth" ? (
<Button size="sm" onClick={openSettings}>
{t("upload.openSettings")}
</Button>
) : null}
<Button size="sm" variant={error.kind === "needs_key" || error.kind === "auth" ? "secondary" : "primary"} onClick={() => void generate()}>
{t("common.retry")}
</Button>
</div>
</div>
) : null}
{/* ---------------------------------------------------------- */}
<section
aria-label={t("upload.addPhotosAria")}
onDragOver={(event) => {
event.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={(event) => {
event.preventDefault();
setDragging(false);
void addFiles(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"
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"
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 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="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 backdrop-blur-xl 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 backdrop-blur-xl 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 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>
<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 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>
</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>
<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={() => 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}
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>
<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={() => setDifficulty(option.value)}
className={cn(
"flex flex-col items-center gap-1 rounded-2xl border px-2 py-3 text-center backdrop-blur-xl transition-colors",
selected
? "border-accent-400 bg-accent-50/80 shadow-lift dark:bg-accent-500/20"
: "border-white/60 bg-white/45 backdrop-blur-xl hover:bg-white/70 dark:border-white/12 dark:bg-white/[0.05]",
)}
>
<span aria-hidden className="text-frost-600 dark:text-frost-200">
{option.icon}
</span>
<span className="text-sm font-bold text-[var(--text)]">{t(option.labelKey)}</span>
<span className="hidden text-[11px] text-frost-600 sm:block dark:text-frost-400">{t(option.blurbKey)}</span>
</button>
);
})}
</div>
<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={() => setCount(option)}
className={cn(
"min-w-12 rounded-xl border-2 px-3 py-1.5 text-sm font-bold transition",
count === option
? "grad-ice border-transparent text-[#06111f]"
: "border-[var(--hairline)] text-[var(--text-muted)] hover:bg-[var(--hover)]",
)}
>
{option}
</button>
))}
</div>
</section>
<div className="sticky bottom-[calc(env(safe-area-inset-bottom,0px)+4.6rem)] z-20 -mx-4 space-y-2 border-t border-[var(--hairline)] bg-white/65 px-4 py-3 backdrop-blur-2xl dark:bg-[color-mix(in_oklab,var(--surface)_70%,transparent)] sm:bottom-0 sm:mx-0 sm:rounded-[1.35rem] sm:border sm:px-5">
{/* Same CTA shape as the homework flow: feature icon, full-width,
helper line centred underneath. */}
<Button
size="lg"
className="touch-target w-full"
onClick={() => void generate()}
disabled={!canGenerate}
>
<SnowflakeIcon 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>
</div>
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Loading state */
/* ------------------------------------------------------------------ */
const STAGE_STEPS: Array<{ stage: GenerateProgress["stage"]; keys: string[] }> = [
{ stage: "vision", keys: ["upload.vision1", "upload.vision2", "upload.vision3"] },
{ stage: "generation", keys: ["upload.gen1", "upload.gen2", "upload.gen3"] },
];
function LoadingState({ progress, onCancel }: { progress: GenerateProgress; onCancel: () => void }) {
const { t } = useI18n();
const [tick, setTick] = useState(0);
const [tip, setTip] = useState(0);
useEffect(() => {
const timer = window.setInterval(() => setTick((value) => value + 1), 3200);
return () => window.clearInterval(timer);
}, []);
useEffect(() => {
setTip(Math.floor(Math.random() * GENERATION_TIPS.length));
}, []);
const activeIndex = progress.stage === "vision" ? 0 : 1;
const keys = STAGE_STEPS[activeIndex].keys;
const message = progress.message && tick === 0 ? progress.message : t(keys[tick % keys.length]);
return (
<div className="relative flex min-h-[70dvh] flex-col items-center justify-center gap-6 text-center">
<motion.div
aria-hidden
animate={{ rotate: [0, 8, -8, 0], scale: [1, 1.08, 1] }}
transition={{ duration: 3.2, repeat: Infinity, ease: "easeInOut" }}
className="relative grid size-28 place-items-center rounded-[1.75rem] bg-accent-600 text-white shadow-lift"
>
<SnowflakeIcon className="size-16" />
</motion.div>
<div className="relative w-full max-w-md space-y-3">
<p className="text-xl font-extrabold text-[var(--text)]" aria-live="polite">
{message}
</p>
<ProgressBar value={activeIndex + 1} max={2} label={t("upload.generationProgress")} />
<ol className="space-y-1 text-left text-[14.5px] leading-relaxed text-[var(--text-muted)]">
{STAGE_STEPS.map((step, index) => (
<li key={step.stage} className="flex items-center gap-2">
<span aria-hidden>
{index < activeIndex ? "✅" : index === activeIndex ? "🌀" : "⬜"}
</span>
{t(step.keys[0])}
</li>
))}
</ol>
<p className="pt-2 text-[12.5px] text-[var(--text-subtle)]">{t(GENERATION_TIPS[tip])}</p>
<Button variant="ghost" size="sm" onClick={onCancel}>
{t("upload.cancel")}
</Button>
</div>
</div>
);
}