Mobile declutter pass: step flows, compact cards, simplified settings
Phones only (<=639px); desktop markup is untouched.
- New deck is a 3-step flow: progress (1/3 + bar) and a fixed Back/Next
bar above the tab bar. Step 1 = Add photos or files / Camera / Paste
text instead (no drop zone or paste copy on touch), step 2 = folder,
difficulty and count with defaults plus one collapsed focus/notes row,
step 3 = one summary line (photos/docs/text · difficulty · count ·
folder) with Freeze. Going Back keeps every input; step transitions
respect prefers-reduced-motion.
- Make my homework is a 2-step flow sharing the same pattern; tapping a
mode card starts it (no separate CTA on phones), with a short preview
of the assignment on step 2. The hero is now 'Make my homework' /
'Udělej mi úkol' everywhere ('Solve your homework' is gone) and the
intro is one line.
- Library: stats line removed, folder row and 'Unorganized' heading
hidden until folders exist (New folder lives in the drawer), Import is
a small icon button beside the tabs. Deck and homework cards are
compact on phones: title, one meta line (activities · difficulty ·
best), a one-line description that expands on tap, the whole card
tappable and a small Study/Open pill instead of the full-width button.
- Settings sheet on phones: no .env.local subtitle; Language stays first;
one Connection card (status dot + 'Connected · 2 models ready', key
field with the eye inside, Save only when edited, auto re-check on
save, refresh icon instead of Test connection); privacy one-liner with
a More expander (daemon tip inside); raw health rows behind one
collapsed Details list; Remove key as a small danger button at the very
bottom (same confirmation).
- Decorative orbs on phones moved further off the screen edges so they
never glow under cards or primary buttons.
- Shared markup for folder/difficulty/count and mode cards keeps one
source of state; new lib/use-phone.ts gates the layouts.
Verified: new declutter suite 116/116 (360 + 390, EN + CS, photo + PDF +
pasted text, Back keeps input, summaries, settings flow), regression
74/74, mobile 52/52, i18n 25/25. Lint + build clean. SW freezmo-v20.
This commit is contained in:
parent
0ee9e930f0
commit
6564e88138
12 changed files with 1345 additions and 309 deletions
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { motion, useReducedMotion } from "framer-motion";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||
|
||||
|
|
@ -31,8 +31,9 @@ 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 { useIsPhone } from "@/lib/use-phone";
|
||||
import { cn, uid } from "@/lib/utils";
|
||||
import { CameraIcon, FolderIcon, IceCubeIcon, PlusIcon, SnowflakeIcon, SparklesIcon, SunIcon } from "@/components/icons";
|
||||
import { CameraIcon, ChevronDownIcon, FolderIcon, IceCubeIcon, PlusIcon, SnowflakeIcon, SparklesIcon, SunIcon } from "@/components/icons";
|
||||
import { Button, ProgressBar } from "@/components/ui";
|
||||
|
||||
type Phase = "idle" | "preparing" | "loading" | "error";
|
||||
|
|
@ -100,6 +101,12 @@ export function UploadScreen() {
|
|||
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);
|
||||
/** Phones only: the “paste text instead” field on step 1. */
|
||||
const [pasteOpen, setPasteOpen] = useState(false);
|
||||
const isPhone = useIsPhone();
|
||||
const reduceMotion = useReducedMotion();
|
||||
|
||||
const folders = useSyncExternalStore(
|
||||
folderStore.subscribe,
|
||||
|
|
@ -303,6 +310,452 @@ export function UploadScreen() {
|
|||
}
|
||||
};
|
||||
|
||||
/* 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={() => 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>
|
||||
);
|
||||
|
||||
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={() => 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>
|
||||
);
|
||||
|
||||
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={() => 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>
|
||||
);
|
||||
|
||||
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">
|
||||
{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;
|
||||
|
||||
const phoneSummary = [
|
||||
text.trim() ? t("upload.summaryText") : "",
|
||||
images.length > 0 ? plural(images.length, "upload.photoCount") : "",
|
||||
documents.length > 0 ? plural(documents.length, "upload.docSummary") : "",
|
||||
t(difficulty === "easy" ? "upload.easy" : difficulty === "hard" ? "upload.hard" : "upload.medium"),
|
||||
plural(count, "deck.activities"),
|
||||
folderId === UNORGANIZED_ID
|
||||
? t("common.unorganized")
|
||||
: (sortedFolders.find((folder) => folder.id === folderId)?.name ?? t("common.unorganized")),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
const phoneHelper = documentsBusy
|
||||
? t("upload.readingDocs")
|
||||
: hasContent
|
||||
? t("upload.ready")
|
||||
: t("upload.addToStart");
|
||||
|
||||
/* 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 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 pb-24">
|
||||
<section className="space-y-1.5">
|
||||
<h1 className="text-[24px] font-semibold leading-tight tracking-[-0.02em] text-[var(--text)]">
|
||||
{t("upload.title")}
|
||||
</h1>
|
||||
<p className="text-[14px] text-frost-600 dark:text-frost-300">{t("upload.introShort")}</p>
|
||||
</section>
|
||||
|
||||
{errorCard}
|
||||
|
||||
{/* step indicator */}
|
||||
<div className="mt-5 space-y-2">
|
||||
<div className="flex items-center justify-between gap-3 text-[12px] font-semibold text-[var(--text-subtle)]">
|
||||
<h2 className="min-w-0 truncate">{stepLabel}</h2>
|
||||
<span className="shrink-0 tabular-nums">{t("common.stepOf", { n: step, total: 3 })}</span>
|
||||
</div>
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-label={stepLabel}
|
||||
aria-valuemin={1}
|
||||
aria-valuemax={3}
|
||||
aria-valuenow={step}
|
||||
className="h-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>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
key={step}
|
||||
initial={reduceMotion ? false : { opacity: 0, x: 18 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.24, ease: [0.22, 1, 0.36, 1] }}
|
||||
className="mt-5"
|
||||
>
|
||||
{step === 1 ? (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button variant="secondary" size="lg" className="touch-target" onClick={() => fileInputRef.current?.click()}>
|
||||
<FolderIcon className="size-4" /> {t("upload.addPhotosFiles")}
|
||||
</Button>
|
||||
<Button variant="secondary" size="lg" className="touch-target" onClick={() => cameraInputRef.current?.click()}>
|
||||
<CameraIcon className="size-4" /> {t("upload.camera")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPasteOpen((value) => !value)}
|
||||
aria-expanded={pasteOpen}
|
||||
className="text-[13px] font-semibold text-accent-700 dark:text-accent-200"
|
||||
>
|
||||
{t("upload.pasteInstead")}
|
||||
</button>
|
||||
{pasteOpen ? (
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(event) => setText(event.target.value)}
|
||||
rows={5}
|
||||
placeholder={t("upload.pastedPlaceholder")}
|
||||
aria-label={t("upload.pastedNotes")}
|
||||
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={() => removeImage(image.id)}
|
||||
aria-label={t("upload.remove", { name: image.name })}
|
||||
className="absolute -right-1.5 -top-1.5 grid size-6 place-items-center rounded-full bg-frost-950/80 text-[11px] font-bold text-white"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</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={() => removeDocument(entry.id)}
|
||||
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>
|
||||
) : null}
|
||||
|
||||
{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-4">
|
||||
{folderPicker}
|
||||
{difficultyPicker}
|
||||
{countPicker}
|
||||
<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")}
|
||||
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-2">
|
||||
<p className="text-[16px] font-semibold leading-relaxed text-[var(--text)]">{phoneSummary}</p>
|
||||
<p className="text-[12.5px] text-[var(--text-subtle)]">{phoneHelper}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</motion.div>
|
||||
|
||||
{/* Fixed step bar, floating just above the tab bar. */}
|
||||
<div className="fixed inset-x-0 bottom-[calc(env(safe-area-inset-bottom,0px)+4.6rem)] z-20 px-4">
|
||||
<div className="glass-panel mx-auto flex max-w-3xl items-center gap-2 p-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
className="touch-target flex-1"
|
||||
onClick={() => setStep((value) => Math.max(1, value - 1))}
|
||||
disabled={step === 1}
|
||||
>
|
||||
{t("common.back")}
|
||||
</Button>
|
||||
{step < 3 ? (
|
||||
<Button
|
||||
size="lg"
|
||||
className="touch-target flex-[1.5]"
|
||||
onClick={() => setStep((value) => Math.min(3, value + 1))}
|
||||
>
|
||||
{t("common.next")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="lg" className="touch-target flex-[1.5]" onClick={() => void generate()} disabled={!canGenerate}>
|
||||
<SparklesIcon className="size-5" /> {t("upload.freezeDeck")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (phase === "loading") {
|
||||
return <LoadingState progress={progress} onCancel={() => abortRef.current?.abort()} />;
|
||||
}
|
||||
|
|
@ -325,33 +778,7 @@ export function UploadScreen() {
|
|||
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}
|
||||
{errorCard}
|
||||
|
||||
{/* ---------------------------------------------------------- */}
|
||||
<section
|
||||
|
|
@ -587,136 +1014,9 @@ export function UploadScreen() {
|
|||
{/* ---------------------------------------------------------- */}
|
||||
<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>
|
||||
{folderPicker}
|
||||
{difficultyPicker}
|
||||
{countPicker}
|
||||
</section>
|
||||
|
||||
{/* Same CTA block as the homework flow: full-width button with the
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue