Safety net and errors, mobile-first, desktop unchanged in behaviour. Backup and persistence - Settings -> Data & backup: 'Export all data' writes one zod-validated JSON (decks, folders, homework, progress, safe settings; the API key is not part of the shape and never appears in the file). - 'Import backup' validates, shows a preview with added/replaced counts and offers merge or replace; invalid files get a friendly error. Storage persistence (navigator.storage.persist) is shown and requestable, with the result reported. - A dismissible nudge appears after 5 new decks or 14 days without a backup and stays snoozed for 14 days. Opt-in encrypted sync (off by default) - New lib/sync.ts: PBKDF2-SHA256 (310k) -> AES-GCM-256, derived key kept as a non-extractable CryptoKey in IndexedDB (lib/sync-key.ts); the passphrase and key never leave the browser. - New POST/GET/DELETE /api/sync following the route pattern (locale, cross-origin guard, body cap, rate limit RATE_LIMIT_SYNC, auth before body parsing, zod validation). One atomic JSON file per random 128-bit sync ID under SYNC_DIR; server-stamped updatedAt is the conflict clock; SYNC_MAX_BYTES and SYNC_TOTAL_BYTES enforced (413 / 507). The server only ever sees an opaque envelope. - Settings -> Sync: enable with a passphrase, copy the sync ID, sync now, last-synced time, explicit keep-this-device / use-synced-copy conflict choice, join from another device (ID + passphrase, wrong passphrase rejected), unlock after the key is gone, delete the synced copy (works even on a locked device). Every data store marks sync dirty on write; pulled payloads are applied without marking dirty again. Monthly usage vs short-term throttling - lib/ollama.ts classifies the real 429 body (monthly/quota/upgrade vs burst) into new 'usage_limit' and 'throttled' kinds, mapped in every route and in the health probe. - The client renders the same localized message in the deck flow, homework, grading and the Settings banner: the required upgrade/new-key copy with 'Open Settings' (jumps to and focuses the key field) and 'Open Ollama'. Input and wizard drafts are kept so Retry works right after swapping the key. No model fallback. Verified: new phase-1 suite 38/38 (backup content + no key, import preview/merge/replace, junk file, persistence row, nudge + snooze, sync enable/upload/opaque blob/push/join/wrong passphrase/delete, server validation 400/401, usage-limit copy and key focus, and the real route mapping against a stub Ollama for both 429 variants) plus regression 74/74, mobile 52/52, wizard 80/80, declutter 116/116, animation 8/8, native 35/35, i18n 25/25. Key parity 638/638. Lint + build clean. SW freezmo-v25.
1391 lines
57 KiB
TypeScript
1391 lines
57 KiB
TypeScript
"use client";
|
|
|
|
import { motion, useReducedMotion } 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 { 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 { 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 { cn, uid } from "@/lib/utils";
|
|
import { CameraIcon, ChevronDownIcon, FolderIcon, IceCubeIcon, PlusIcon, SnowflakeIcon, SparklesIcon, SunIcon } from "@/components/icons";
|
|
import { SegmentedPill } from "@/components/SegmentedPill";
|
|
import { Button, ProgressBar } from "@/components/ui";
|
|
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;
|
|
|
|
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: "" });
|
|
/** 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]);
|
|
|
|
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]);
|
|
|
|
/* 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 backdrop-blur-xl 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 backdrop-blur-xl 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 progress={progress} 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"
|
|
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"
|
|
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(--surface-sunken)]"
|
|
>
|
|
<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 backdrop-blur-xl transition placeholder:text-frost-400 focus:border-accent-400 dark:border-white/12 dark:bg-white/[0.07] dark:text-frost-50"
|
|
/>
|
|
) : null}
|
|
|
|
{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 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-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 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>
|
|
</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>
|
|
</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 library tab, the
|
|
drawer or the bottom bar — no mode picker here. */}
|
|
|
|
|
|
{errorCard}
|
|
|
|
{/* ---------------------------------------------------------- */}
|
|
<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>
|
|
{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>
|
|
</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 reduceMotion = useReducedMotion();
|
|
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">
|
|
{/* The app icon, not the bare snowflake — same logo as the header,
|
|
sidebar and empty states. */}
|
|
<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 size-28"
|
|
>
|
|
{/* eslint-disable-next-line @next/next/no-img-element -- static app icon, no optimisation needed */}
|
|
<img
|
|
src="/icons/icon-512.png"
|
|
alt=""
|
|
className="size-28 rounded-[1.75rem] shadow-lift"
|
|
/>
|
|
</motion.div>
|
|
|
|
<div className="relative w-full max-w-md space-y-3">
|
|
<motion.p
|
|
key={message}
|
|
initial={reduceMotion ? false : { opacity: 0, y: 8 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.22, ease: "easeOut" }}
|
|
className="text-xl font-extrabold text-[var(--text)]"
|
|
aria-live="polite"
|
|
>
|
|
{message}
|
|
</motion.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>
|
|
);
|
|
}
|