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
|
|
@ -14,6 +14,9 @@ import {
|
|||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
DownloadIcon,
|
||||
EyeIcon,
|
||||
EyeOffIcon,
|
||||
RefreshIcon,
|
||||
XCircleIcon,
|
||||
} from "@/components/icons";
|
||||
import { Button, Spinner } from "@/components/ui";
|
||||
|
|
@ -31,7 +34,7 @@ function freshnessLabel(checkedAt: number, locale: Locale, t: (key: string, vars
|
|||
export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const { health, loading, error, refresh, apiKey, checkedAt } = useHealth();
|
||||
const installAvailable = useInstallAvailable();
|
||||
const { locale, setLocale, t } = useI18n();
|
||||
const { locale, setLocale, t, plural } = useI18n();
|
||||
const [keyDraft, setKeyDraft] = useState(apiKey);
|
||||
const [reveal, setReveal] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
|
@ -106,6 +109,33 @@ export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () =
|
|||
|
||||
const needsKeyMode = health?.authMode === "user-key";
|
||||
|
||||
/* Phones: one status row instead of the whole health panel. */
|
||||
const modelsReady = health ? [health.vision.available, health.text.available].filter((value) => value === true).length : 0;
|
||||
const statusText = !health
|
||||
? t("settings.checking")
|
||||
: health.ok
|
||||
? `${t("settings.connected")} · ${plural(modelsReady, "settings.modelsReady")}`
|
||||
: health.needsKey
|
||||
? t("health.needsKey")
|
||||
: health.keyAccepted === false
|
||||
? t("health.rejected")
|
||||
: health.reachable
|
||||
? t("health.modelMissing")
|
||||
: t("health.unreachable");
|
||||
const statusDot = !health
|
||||
? "bg-[var(--text-subtle)]"
|
||||
: health.ok
|
||||
? "bg-emerald-500"
|
||||
: health.needsKey || health.reachable
|
||||
? "bg-amber-500"
|
||||
: "bg-rose-500";
|
||||
const modelBadge = (model: { available: boolean | null } | undefined) =>
|
||||
!model || model.available === null
|
||||
? { text: t("health.unknown"), className: "bg-[var(--surface-sunken)] text-[var(--text-muted)]" }
|
||||
: model.available
|
||||
? { text: t("health.available"), className: "bg-emerald-100 text-emerald-900 dark:bg-emerald-400/20 dark:text-emerald-200" }
|
||||
: { text: t("health.notFound"), className: "bg-rose-50 text-rose-800 dark:bg-rose-500/15 dark:text-rose-200" };
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open ? (
|
||||
|
|
@ -149,11 +179,13 @@ export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () =
|
|||
<h2 id="settings-title" className="text-lg font-semibold tracking-[-0.02em] text-frost-950 dark:text-frost-50">
|
||||
{t("settings.title")}
|
||||
</h2>
|
||||
<p className="text-sm text-frost-600 dark:text-frost-300">
|
||||
{t("settings.subtitle.before")}{" "}
|
||||
<code className="rounded bg-frost-100 px-1 dark:bg-white/10">.env.local</code>{" "}
|
||||
{t("settings.subtitle.after")}
|
||||
</p>
|
||||
{!phone ? (
|
||||
<p className="text-sm text-frost-600 dark:text-frost-300">
|
||||
{t("settings.subtitle.before")}{" "}
|
||||
<code className="rounded bg-frost-100 px-1 dark:bg-white/10">.env.local</code>{" "}
|
||||
{t("settings.subtitle.after")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
|
@ -174,6 +206,263 @@ export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () =
|
|||
className="sheet-scroll thin-scroll min-h-0 flex-1 overflow-y-auto px-5 pb-safe"
|
||||
>
|
||||
<div ref={contentRef}>
|
||||
{phone ? (
|
||||
<>
|
||||
{/* --------------------------------------------- language */}
|
||||
<section className="mb-4 space-y-2 rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
|
||||
<h3 className="text-sm font-semibold text-frost-950 dark:text-frost-50">
|
||||
{t("language.label")}
|
||||
</h3>
|
||||
<div className="segmented" role="group" aria-label={t("language.label")}>
|
||||
{LOCALES.map((entry) => (
|
||||
<button
|
||||
key={entry.id}
|
||||
type="button"
|
||||
aria-pressed={locale === entry.id}
|
||||
onClick={() => setLocale(entry.id)}
|
||||
>
|
||||
{entry.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ------------------------------------------- connection */}
|
||||
<section className="mb-4 space-y-3 rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold text-frost-950 dark:text-frost-50">
|
||||
{t("settings.connection")}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void refresh()}
|
||||
aria-label={t("settings.recheck")}
|
||||
title={t("settings.recheck")}
|
||||
className="touch-target grid size-9 place-items-center rounded-full text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
||||
>
|
||||
<RefreshIcon className={cn("size-4", loading && "animate-spin")} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-[13px] font-medium" role="status">
|
||||
<span aria-hidden className={cn("size-2.5 shrink-0 rounded-full", statusDot)} />
|
||||
<span className="min-w-0 flex-1 text-[var(--text)]">{statusText}</span>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<label className="sr-only" htmlFor="ollama-api-key-phone">
|
||||
{t("settings.apiKey")}
|
||||
</label>
|
||||
<input
|
||||
id="ollama-api-key-phone"
|
||||
type={reveal ? "text" : "password"}
|
||||
value={keyDraft}
|
||||
onChange={(event) => setKeyDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation();
|
||||
if (event.key === "Enter") save();
|
||||
}}
|
||||
placeholder={t("settings.pasteKey")}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="w-full rounded-xl border border-[var(--hairline)] bg-[var(--surface-strong)] py-3 pl-3 pr-12 font-mono text-xs text-frost-950 outline-none transition placeholder:font-sans placeholder:text-[var(--text-subtle)] focus:border-accent-500 dark:text-frost-50"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReveal((value) => !value)}
|
||||
aria-label={reveal ? t("settings.hide") : t("settings.show")}
|
||||
title={reveal ? t("settings.hide") : t("settings.show")}
|
||||
className="absolute right-0.5 top-1/2 grid size-11 -translate-y-1/2 place-items-center rounded-lg text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
||||
>
|
||||
{reveal ? <EyeOffIcon className="size-4" /> : <EyeIcon className="size-4" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{keyDraft.trim() !== apiKey ? (
|
||||
<Button size="sm" className="touch-target w-full" onClick={save}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<details className="group">
|
||||
<summary className="touch-target flex cursor-pointer list-none flex-wrap items-center gap-1.5 text-[11px] font-medium text-[var(--text-muted)] [&::-webkit-details-marker]:hidden">
|
||||
{t("settings.privacyLine")}
|
||||
<span className="inline-flex items-center gap-0.5 font-semibold text-[var(--text)] underline underline-offset-2">
|
||||
{t("common.more")}
|
||||
<ChevronDownIcon className="size-3 transition-transform group-open:rotate-180" />
|
||||
</span>
|
||||
</summary>
|
||||
<div className="space-y-1.5 pt-1.5">
|
||||
<p className="text-[11px] leading-relaxed text-[var(--text-subtle)]">{t("settings.keyNote")}</p>
|
||||
<p className="text-[11px] leading-relaxed text-[var(--text-subtle)]">
|
||||
{t("settings.localTipTitle")} {t("settings.localTipSet")}{" "}
|
||||
<code>OLLAMA_AUTH=local</code> {t("settings.keyNoteAfter")} <code>.env.local</code>{" "}
|
||||
{t("settings.keyNoteEnd")} <code>ollama signin</code>
|
||||
{t("settings.keyNoteEnd2")}
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
{/* ----------------------------------------------- install */}
|
||||
{installAvailable ? (
|
||||
<section className="mb-4 rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
openInstallSheet();
|
||||
}}
|
||||
className="flex w-full items-center gap-3 rounded-xl px-2.5 py-2 text-left transition hover:bg-[var(--hover)]"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="grid size-9 shrink-0 place-items-center rounded-xl border border-[var(--hairline)] bg-[var(--surface)] text-[var(--text-muted)]"
|
||||
>
|
||||
<DownloadIcon className="size-4" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-semibold text-frost-950 dark:text-frost-50">
|
||||
{t("settings.installApp")}
|
||||
</span>
|
||||
<span className="block text-[12px] text-[var(--text-subtle)]">
|
||||
{t("settings.addToHome")}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronRightIcon className="size-4 shrink-0 text-[var(--text-subtle)]" />
|
||||
</button>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="mb-4 rounded-2xl bg-rose-100 p-3 text-sm text-rose-900 dark:bg-rose-500/15 dark:text-rose-200">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* ----------------------------------------------- details */}
|
||||
<details className="group mb-4 rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
|
||||
<summary className="touch-target flex cursor-pointer list-none items-center justify-between gap-2 text-sm font-semibold text-frost-950 dark:text-frost-50 [&::-webkit-details-marker]:hidden">
|
||||
{t("settings.details")}
|
||||
<ChevronDownIcon className="size-4 text-[var(--text-subtle)] transition-transform group-open:rotate-180" />
|
||||
</summary>
|
||||
<div className="pt-2">
|
||||
{loading ? (
|
||||
<p className="flex items-center gap-2 pb-2 text-[12px] text-[var(--text-muted)]">
|
||||
<Spinner className="size-3.5" /> {t("settings.checking")}
|
||||
</p>
|
||||
) : checkedAt ? (
|
||||
<p className="pb-2 text-[11px] font-medium text-[var(--text-subtle)]">
|
||||
{freshnessLabel(checkedAt, locale, t)}
|
||||
</p>
|
||||
) : null}
|
||||
<dl>
|
||||
<DetailRow
|
||||
label={t("health.auth")}
|
||||
value={
|
||||
health
|
||||
? health.authMode === "user-key"
|
||||
? t("health.authUserKey")
|
||||
: health.authMode === "server"
|
||||
? t("health.authServer")
|
||||
: t("health.authLocal")
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
<DetailRow label={t("health.endpoint")} value={health?.baseUrl ?? "—"} mono />
|
||||
<DetailRow
|
||||
label={t("health.mode")}
|
||||
value={
|
||||
health
|
||||
? health.mode === "direct-cloud"
|
||||
? t("health.modeCloud")
|
||||
: t("health.modeLocal")
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
<DetailRow
|
||||
label={t("health.key")}
|
||||
value={
|
||||
health
|
||||
? health.needsKey
|
||||
? t("health.keyMissing")
|
||||
: health.keyAccepted === true
|
||||
? t("health.keyAccepted")
|
||||
: health.keyAccepted === false
|
||||
? t("health.keyRejected")
|
||||
: health.authMode === "local"
|
||||
? t("health.keyNotNeeded")
|
||||
: t("health.keyUnknown")
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
<DetailRow
|
||||
label={t("health.visionModel")}
|
||||
value={health?.vision.resolved ?? "—"}
|
||||
mono
|
||||
badge={modelBadge(health?.vision)}
|
||||
/>
|
||||
<DetailRow
|
||||
label={t("health.textModel")}
|
||||
value={health?.text.resolved ?? "—"}
|
||||
mono
|
||||
badge={modelBadge(health?.text)}
|
||||
/>
|
||||
</dl>
|
||||
{health?.mode === "direct-cloud" && health.needsKey ? (
|
||||
<p className="pt-2 text-[11px] leading-relaxed text-[var(--text-subtle)]">
|
||||
{t("health.createKey.before")}{" "}
|
||||
<a
|
||||
className="font-semibold underline underline-offset-2"
|
||||
href="https://ollama.com/settings/keys"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
ollama.com/settings/keys
|
||||
</a>{" "}
|
||||
{t("health.createKey.after")} <code>ollama signin</code> {t("health.createKey.end")}{" "}
|
||||
<code>OLLAMA_AUTH=local</code>.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{/* ------------------------------- remove key (very bottom) */}
|
||||
{apiKey ? (
|
||||
confirmRemove ? (
|
||||
<div className="rounded-xl border border-rose-300/70 bg-rose-50 p-3 dark:border-rose-400/25 dark:bg-rose-500/10">
|
||||
<p className="text-[12px] leading-relaxed text-rose-900 dark:text-rose-100">
|
||||
{t("settings.removeKeyConfirm")}
|
||||
</p>
|
||||
<div className="mt-1.5 flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="danger" className="touch-target" onClick={forget}>
|
||||
{t("settings.removeKeyYes")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="touch-target"
|
||||
onClick={() => setConfirmRemove(false)}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmRemove(true)}
|
||||
className="touch-target mx-auto block px-3 text-center text-[12.5px] font-medium text-rose-700 underline-offset-2 hover:underline dark:text-rose-300"
|
||||
>
|
||||
{t("settings.removeKey")}
|
||||
</button>
|
||||
)
|
||||
) : null}
|
||||
|
||||
<div className="h-9" aria-hidden />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* --------------------------------------------- language */}
|
||||
<section className="mb-4 space-y-2 rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
|
||||
<h3 className="text-sm font-semibold text-frost-950 dark:text-frost-50">
|
||||
|
|
@ -375,6 +664,8 @@ export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () =
|
|||
|
||||
{/* keeps real content out of the 36px fade strip at the bottom */}
|
||||
<div className="h-9" aria-hidden />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -483,6 +774,32 @@ function HealthPanel({ health }: { health: ReturnType<typeof useHealth>["health"
|
|||
);
|
||||
}
|
||||
|
||||
function DetailRow({
|
||||
label,
|
||||
value,
|
||||
mono,
|
||||
badge,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
mono?: boolean;
|
||||
badge?: { text: string; className: string };
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 border-b border-[var(--hairline-soft)] py-1.5 last:border-0">
|
||||
<dt className="shrink-0 text-[12px] text-[var(--text-subtle)]">{label}</dt>
|
||||
<dd className={cn("flex min-w-0 items-center justify-end gap-1.5 text-right font-medium text-[var(--text)]", mono && "font-mono text-[11px]")}>
|
||||
<span className="truncate">{value}</span>
|
||||
{badge ? (
|
||||
<span className={cn("shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-bold", badge.className)}>
|
||||
{badge.text}
|
||||
</span>
|
||||
) : null}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Info({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue