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:
Martin Dvořák 2026-10-10 10:43:32 +00:00
commit 6564e88138
12 changed files with 1345 additions and 309 deletions

View file

@ -1,6 +1,7 @@
"use client";
import { useEffect, useRef } from "react";
import { motion, useReducedMotion } from "framer-motion";
import { useEffect, useRef, useState } from "react";
import { CameraIcon, FolderIcon, PlayIcon, PlusIcon, SparklesIcon } from "@/components/icons";
import { Button } from "@/components/ui";
@ -13,8 +14,9 @@ import type { HomeworkMode, HomeworkRecord } from "@/lib/schemas";
import { ACCEPTED_TYPES, formatBytes, imageFilesFromDataTransfer } from "@/lib/image";
import { useI18n } from "@/lib/i18n";
import type { useNotesIntake } from "@/lib/use-notes-intake";
import { useIsPhone } from "@/lib/use-phone";
import { openSettings } from "@/lib/ui-events";
import { cn } from "@/lib/utils";
import { cn, truncate } from "@/lib/utils";
import { HomeworkHistory } from "./HomeworkHistory";
@ -55,7 +57,8 @@ export function HomeworkInput({
text: string;
onTextChange: (text: string) => void;
intake: ReturnType<typeof useNotesIntake>;
onSubmit: () => void;
/** `modeOverride` lets the phone mode cards start the run they tapped. */
onSubmit: (modeOverride?: HomeworkMode) => void;
error: { message: string; kind?: string } | null;
history: HomeworkRecord[];
onOpen: (record: HomeworkRecord) => void;
@ -64,6 +67,10 @@ export function HomeworkInput({
const { t, plural } = useI18n();
const fileInputRef = useRef<HTMLInputElement>(null);
const cameraInputRef = useRef<HTMLInputElement>(null);
const [step, setStep] = useState(1);
const [pasteOpen, setPasteOpen] = useState(false);
const isPhone = useIsPhone();
const reduceMotion = useReducedMotion();
// Clipboard: screenshots become photos, long copied text becomes the assignment.
useEffect(() => {
@ -99,6 +106,312 @@ export function HomeworkInput({
const canSubmit = hasContent && !intake.busy && !documentsBusy;
const keyError = error?.kind === "needs_key" || error?.kind === "auth";
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">{keyError ? t("upload.errAuth") : t("homework.error.title")}</p>
<p className="mt-1">{error.message || t("homework.error.body")}</p>
<div className="mt-3 flex flex-wrap gap-2">
{keyError ? (
<Button size="sm" onClick={openSettings}>
{t("upload.openSettings")}
</Button>
) : null}
<Button size="sm" variant={keyError ? "secondary" : "primary"} onClick={() => onSubmit()}>
{t("common.retry")}
</Button>
</div>
</div>
) : null;
/* Shared mode card: desktop toggles the selection, the phone flow starts it. */
const modeCard = (option: (typeof MODES)[number], onPick: () => void) => {
const selected = mode === option.id;
return (
<button
key={option.id}
type="button"
aria-pressed={selected}
onClick={onPick}
className={cn(
"flex min-h-24 items-start gap-3 rounded-2xl border p-4 text-left transition",
selected
? "border-accent-400 bg-accent-50/70 ring-2 ring-accent-400/40 dark:bg-accent-500/15"
: "border-[var(--hairline)] bg-[var(--surface-strong)] hover:bg-[var(--hover)]",
)}
>
<span
aria-hidden
className={cn(
"grid size-10 shrink-0 place-items-center rounded-xl",
selected ? "grad-ice text-[#06111f]" : "bg-[var(--surface-sunken)] text-[var(--text-muted)]",
)}
>
{option.icon}
</span>
<span className="min-w-0">
<span className="block text-[15px] font-semibold leading-snug text-[var(--text)]">
{t(option.titleKey)}
</span>
<span className="mt-0.5 block text-[12.5px] leading-relaxed text-[var(--text-subtle)]">
{t(option.subtitleKey)}
</span>
</span>
</button>
);
};
const fileInput = (
<input
ref={fileInputRef}
type="file"
accept={`${ACCEPTED_TYPES.join(",")},${DOCUMENT_ACCEPT_ATTR}`}
multiple
className="sr-only"
onChange={(event) => {
void intake.addFiles(Array.from(event.target.files ?? []));
event.target.value = "";
}}
/>
);
const cameraInput = (
<input
ref={cameraInputRef}
type="file"
accept="image/*"
capture="environment"
className="sr-only"
onChange={(event) => {
void intake.addFiles(Array.from(event.target.files ?? []));
event.target.value = "";
}}
/>
);
/* Phones: the 2-step flow. Same intake and mode state as the desktop page. */
if (isPhone) {
const stepLabel = step === 1 ? t("homework.phone.assignment") : t("homework.phone.mode");
const fileSummary = [
intake.images.length > 0 ? plural(intake.images.length, "upload.photoCount") : "",
intake.documents.length > 0 ? plural(intake.documents.length, "upload.docSummary") : "",
]
.filter(Boolean)
.join(" · ");
const preview = text.trim()
? truncate(text.replace(/\s+/g, " ").trim(), 140)
: fileSummary || t("homework.source.need");
const showPaste = pasteOpen || text.trim().length > 0;
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("homeworkHeroTitle")}
</h1>
<p className="text-[14px] text-frost-600 dark:text-frost-300">{t("homework.subtitle")}</p>
</section>
{errorCard}
<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: 2 })}</span>
</div>
<div
role="progressbar"
aria-label={stepLabel}
aria-valuemin={1}
aria-valuemax={2}
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 / 2) * 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("homework.source.add")}
</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={showPaste}
className="text-[13px] font-semibold text-accent-700 dark:text-accent-200"
>
{t("upload.pasteInstead")}
</button>
{showPaste ? (
<textarea
id="homework-assignment-phone"
value={text}
onChange={(event) => onTextChange(event.target.value)}
onKeyDown={(event) => event.stopPropagation()}
rows={6}
placeholder={t("homework.source.placeholder")}
aria-label={t("homework.source.textLabel")}
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}
{intake.images.length > 0 ? (
<ul className="flex flex-wrap items-center gap-2">
{intake.images.map((image) => (
<li key={image.id} className="relative">
{/* eslint-disable-next-line @next/next/no-img-element -- local thumbnail, no optimisation needed */}
<img
src={image.dataUrl}
alt=""
className="size-14 rounded-xl border border-[var(--hairline)] object-cover"
/>
<button
type="button"
onClick={() => intake.removeImage(image.id)}
aria-label={t("upload.remove", { name: image.name })}
className="absolute -right-1.5 -top-1.5 grid size-6 place-items-center rounded-full bg-frost-950/80 text-[11px] font-bold text-white"
>
✕
</button>
</li>
))}
</ul>
) : null}
{intake.documents.length > 0 ? (
<ul className="flex flex-wrap items-center gap-2">
{intake.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)]">
{entry.name.split(".").pop()?.slice(0, 4) ?? "doc"}
</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={() => intake.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}
{intake.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>
))}
{intake.busy ? (
<p className="text-[12.5px] font-medium text-[var(--text-muted)]">{t("upload.compressing")}</p>
) : null}
{intake.fileError ? (
<p role="alert" className="text-xs font-semibold text-rose-600 dark:text-rose-300">
{intake.fileError}
</p>
) : null}
{fileInput}
{cameraInput}
</div>
) : null}
{step === 2 ? (
<div className="space-y-3">
<div className="rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
<p className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
{t("homework.phone.preview")}
</p>
<p className="mt-1 text-[13px] leading-relaxed text-[var(--text)]">{preview}</p>
</div>
<div className="grid gap-3">
{MODES.map((option) =>
modeCard(option, () => {
if (!canSubmit) return;
onModeChange(option.id);
onSubmit(option.id);
}),
)}
</div>
<p className="text-center text-[12px] text-[var(--text-subtle)]">{t("homework.phone.startHint")}</p>
</div>
) : null}
</motion.div>
<HomeworkHistory records={history} onOpen={onOpen} onDelete={onDelete} />
{/* 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(1)}
disabled={step === 1}
>
{t("common.back")}
</Button>
{step === 1 ? (
<Button size="lg" className="touch-target flex-[1.5]" onClick={() => setStep(2)} disabled={!canSubmit}>
{t("common.next")}
</Button>
) : null}
</div>
</div>
</div>
);
}
return (
<div className="relative space-y-6">
<section className="space-y-2">
@ -109,25 +422,7 @@ export function HomeworkInput({
<p className="max-w-xl text-[15px] text-frost-600 dark:text-frost-300">{t("homework.subtitle")}</p>
</section>
{error ? (
<div
role="alert"
className="animate-rise rounded-3xl border-2 border-rose-300 bg-rose-50/95 p-4 text-sm text-rose-900 dark:border-rose-400/40 dark:bg-rose-400/10 dark:text-rose-100"
>
<p className="font-bold">{keyError ? t("upload.errAuth") : t("homework.error.title")}</p>
<p className="mt-1">{error.message || t("homework.error.body")}</p>
<div className="mt-3 flex flex-wrap gap-2">
{keyError ? (
<Button size="sm" onClick={openSettings}>
{t("upload.openSettings")}
</Button>
) : null}
<Button size="sm" variant={keyError ? "secondary" : "primary"} onClick={onSubmit}>
{t("common.retry")}
</Button>
</div>
</div>
) : null}
{errorCard}
{/* ------------------------------------------------ the assignment */}
<section className="card p-4 sm:p-5">
@ -285,47 +580,13 @@ export function HomeworkInput({
<section className="space-y-3">
<h2 className="text-[15px] font-semibold tracking-[-0.01em] text-[var(--text)]">{t("homework.mode.title")}</h2>
<div className="grid gap-3 sm:grid-cols-2">
{MODES.map((option) => {
const selected = mode === option.id;
return (
<button
key={option.id}
type="button"
aria-pressed={selected}
onClick={() => onModeChange(option.id)}
className={cn(
"flex min-h-24 items-start gap-3 rounded-2xl border p-4 text-left transition",
selected
? "border-accent-400 bg-accent-50/70 ring-2 ring-accent-400/40 dark:bg-accent-500/15"
: "border-[var(--hairline)] bg-[var(--surface-strong)] hover:bg-[var(--hover)]",
)}
>
<span
aria-hidden
className={cn(
"grid size-10 shrink-0 place-items-center rounded-xl",
selected ? "grad-ice text-[#06111f]" : "bg-[var(--surface-sunken)] text-[var(--text-muted)]",
)}
>
{option.icon}
</span>
<span className="min-w-0">
<span className="block text-[15px] font-semibold leading-snug text-[var(--text)]">
{t(option.titleKey)}
</span>
<span className="mt-0.5 block text-[12.5px] leading-relaxed text-[var(--text-subtle)]">
{t(option.subtitleKey)}
</span>
</span>
</button>
);
})}
{MODES.map((option) => modeCard(option, () => onModeChange(option.id)))}
</div>
</section>
{/* -------------------------------------------------------- CTA */}
<div className="space-y-2">
<Button size="lg" className="touch-target w-full" onClick={onSubmit} disabled={!canSubmit} loading={intake.busy}>
<Button size="lg" className="touch-target w-full" onClick={() => onSubmit()} disabled={!canSubmit} loading={intake.busy}>
<SparklesIcon className="size-5" />
{mode === "make" ? t("homework.start.make") : t("homework.start.guide")}
</Button>