Mobile UI pass: sheet swipe, ice-scene layering, contrast, touch targets
Phones (<=639px) only for layout/gestures; the theme-level fixes apply
everywhere, as they are token/design changes.
Settings sheet
- Swipe-to-dismiss reusing the InstallSheet gesture: the sheet follows the
finger, closes past ~90px or on a fast flick, snaps back otherwise. Drag
starts only from the handle/header and only while the body is scrolled to
the top. Backdrop tap still closes.
- Fixed header + scrollable body: pb-safe + spacer, so nothing is clipped;
a scroll cue (chevron) fades in while more content is below and out at
the end.
- Show/Save stack under the field at 44px on phones; close, Test connection
and Remove key are 44px too. Remove key is now a rose destructive action
with a confirmation that the full-screen key setup comes back.
- API key note split into a short privacy line plus a collapsible
'Using the local daemon?' tip (OLLAMA_AUTH=local / .env.local / signin).
- Fixed the missing space in the English tip ('ollama signin instead.').
- Health: kept the auto-check and banner; added a loading row and a
'Checked just now' / 'Checked at HH:MM' freshness label (also for errors).
Ice scene / readability
- Verified the orbs were never above content (fixed z-index:-1 layer,
pointer-events:none); they were just shining through the glass. Phones get
lower blob/orb opacity and orbs tucked to the edges; the bottom-right orb
is moved out from under the tab bar and the tab bar paints a more opaque
surface so its labels hold contrast.
Contrast / themes
- Dark --text-subtle raised to #9db1d0 (8.6:1 page, 7.2:1 glass); helper
text now uses the tokens.
- Disabled buttons are a visible muted chip (.btn-disabled) instead of a
40%-opacity ghost.
- One ice primary gradient in both themes; danger/success now actually show
their colour (bg-none strips the primary background-image that used to
paint over !bg-rose-600).
- Light mode: cool card rims instead of white-on-white, stronger shadow.
Redundancy / touch
- Removed only the header gear; the hamburger drawer (folders, Settings,
theme) and the tab-bar Settings stay.
- Theme toggle is a bordered chip with an action aria-label (drawer row too).
- Empty library hides the folder pills, demotes Import to a quiet link and
keeps a single primary CTA. 44px targets for nav rows, segmented control
and folder pills on phones.
- Fixed a pre-existing 16px horizontal overflow (header -mx-4 vs a
padding-less parent) and let info values wrap instead of truncating.
Verified: mobile suite 47/47 (EN+CS, 360/390px), pixel-sampled contrast
audit 0 runs below WCAG AA (8 runs, both themes/locales; axe marks the glass
'incomplete'), i18n 27/27, desktop regression 72/72, lint + build clean.
Service worker cache bumped to freezmo-v5.
This commit is contained in:
parent
618ad5fb9c
commit
9fb728d735
12 changed files with 519 additions and 235 deletions
|
|
@ -1,28 +1,62 @@
|
|||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useEffect, useState } from "react";
|
||||
import { AnimatePresence, motion, useDragControls, useReducedMotion } from "framer-motion";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { LOCALES, useI18n } from "@/lib/i18n";
|
||||
import { LOCALES, useI18n, type Locale } from "@/lib/i18n";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { openInstallSheet, useInstallAvailable } from "@/lib/install";
|
||||
import { clearApiKey, saveSettings } from "@/lib/settings";
|
||||
import { useHealth } from "@/lib/use-health";
|
||||
import { AlertTriangleIcon, CheckCircleIcon, ChevronRightIcon, DownloadIcon, XCircleIcon } from "@/components/icons";
|
||||
import {
|
||||
AlertTriangleIcon,
|
||||
CheckCircleIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
DownloadIcon,
|
||||
XCircleIcon,
|
||||
} from "@/components/icons";
|
||||
import { Button, Spinner } from "@/components/ui";
|
||||
|
||||
/** “Checked just now”, or the clock time once the result is older. */
|
||||
function freshnessLabel(checkedAt: number, locale: Locale, t: (key: string, vars?: Record<string, string>) => string) {
|
||||
if (Date.now() - checkedAt < 90_000) return t("health.checkedNow");
|
||||
const time = new Date(checkedAt).toLocaleTimeString(locale === "cs" ? "cs-CZ" : "en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
return t("health.checkedAt", { time });
|
||||
}
|
||||
|
||||
export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const { health, loading, error, refresh, apiKey } = useHealth();
|
||||
const { health, loading, error, refresh, apiKey, checkedAt } = useHealth();
|
||||
const installAvailable = useInstallAvailable();
|
||||
const { locale, setLocale, t } = useI18n();
|
||||
const [keyDraft, setKeyDraft] = useState(apiKey);
|
||||
const [reveal, setReveal] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [confirmRemove, setConfirmRemove] = useState(false);
|
||||
const [canScroll, setCanScroll] = useState(false);
|
||||
const [phone, setPhone] = useState(false);
|
||||
const dragControls = useDragControls();
|
||||
const reduceMotion = useReducedMotion();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Swipe-to-dismiss is a phone gesture; the desktop dialog is unchanged.
|
||||
useEffect(() => {
|
||||
const query = window.matchMedia("(max-width: 639px)");
|
||||
const sync = () => setPhone(query.matches);
|
||||
sync();
|
||||
query.addEventListener("change", sync);
|
||||
return () => query.removeEventListener("change", sync);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setKeyDraft(apiKey);
|
||||
setSaved(false);
|
||||
setConfirmRemove(false);
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") onClose();
|
||||
};
|
||||
|
|
@ -30,6 +64,34 @@ export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () =
|
|||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open, apiKey, onClose]);
|
||||
|
||||
// The scroll cue: a small chevron that fades in whenever more content is
|
||||
// below the fold, and out once the bottom is reached.
|
||||
const updateCue = () => {
|
||||
const element = scrollRef.current;
|
||||
if (!element) return;
|
||||
setCanScroll(element.scrollHeight - element.scrollTop - element.clientHeight > 24);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const element = scrollRef.current;
|
||||
const content = contentRef.current;
|
||||
if (!element) return;
|
||||
updateCue();
|
||||
const observer = new ResizeObserver(updateCue);
|
||||
if (content) observer.observe(content);
|
||||
return () => observer.disconnect();
|
||||
}, [open]);
|
||||
|
||||
// Drag only from the handle/header, and only while the body is at the top.
|
||||
const startSheetDrag = (event: React.PointerEvent) => {
|
||||
if (!phone) return;
|
||||
if ((event.target as HTMLElement).closest("button, a, input, summary")) return;
|
||||
const element = scrollRef.current;
|
||||
if (element && element.scrollTop > 2) return;
|
||||
dragControls.start(event);
|
||||
};
|
||||
|
||||
const save = () => {
|
||||
saveSettings({ apiKey: keyDraft.trim() });
|
||||
setSaved(true);
|
||||
|
|
@ -39,6 +101,7 @@ export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () =
|
|||
const forget = () => {
|
||||
clearApiKey();
|
||||
setKeyDraft("");
|
||||
setConfirmRemove(false);
|
||||
};
|
||||
|
||||
const needsKeyMode = health?.authMode === "user-key";
|
||||
|
|
@ -57,163 +120,274 @@ export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () =
|
|||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="settings-title"
|
||||
initial={{ y: 48, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: 32, opacity: 0 }}
|
||||
transition={{ type: "spring", stiffness: 340, damping: 32 }}
|
||||
drag={phone ? "y" : false}
|
||||
dragControls={dragControls}
|
||||
dragListener={false}
|
||||
dragMomentum={false}
|
||||
dragConstraints={{ top: 0, bottom: 0 }}
|
||||
dragElastic={{ top: 0, bottom: 0.55 }}
|
||||
onDragEnd={(_, gesture) => {
|
||||
if (gesture.offset.y > 90 || gesture.delta.y > 140 || gesture.velocity.y > 700) onClose();
|
||||
}}
|
||||
initial={reduceMotion ? { opacity: 0 } : phone ? { y: "100%", opacity: 0 } : { y: 48, opacity: 0 }}
|
||||
animate={reduceMotion ? { opacity: 1 } : { y: 0, opacity: 1 }}
|
||||
exit={reduceMotion ? { opacity: 0 } : phone ? { y: "100%", opacity: 0 } : { y: 32, opacity: 0 }}
|
||||
transition={phone ? { duration: 0.34, ease: [0.22, 1, 0.36, 1] } : { type: "spring", stiffness: 340, damping: 32 }}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
className="glass-panel thin-scroll max-h-[90dvh] w-full max-w-lg overflow-y-auto rounded-t-[1.6rem] p-5 pb-safe shadow-2xl sm:rounded-[1.35rem] sm:pb-5"
|
||||
className="glass-panel relative flex max-h-[90dvh] w-full max-w-lg flex-col overflow-hidden rounded-t-[1.6rem] shadow-2xl sm:rounded-[1.35rem]"
|
||||
>
|
||||
<div className="sheet-grabber sm:hidden" aria-hidden />
|
||||
<div className="mb-4 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<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>
|
||||
{/* handle + header: the drag strip. Fixed, so the body scrolls. */}
|
||||
<div
|
||||
onPointerDown={startSheetDrag}
|
||||
className={cn("shrink-0 px-5 pt-2", phone && "cursor-grab touch-none active:cursor-grabbing")}
|
||||
>
|
||||
<div aria-hidden className="mx-auto flex h-6 w-full items-center justify-center">
|
||||
<span className="h-1 w-9 rounded-full bg-[var(--text-subtle)] opacity-40" />
|
||||
</div>
|
||||
<div className="mb-3 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="touch-target -mr-1 shrink-0"
|
||||
onClick={onClose}
|
||||
aria-label={t("settings.close")}
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={onClose} aria-label={t("settings.close")}>
|
||||
✕
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* --------------------------------------------- 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>
|
||||
<p className="text-[11px] leading-relaxed text-frost-500 dark:text-frost-400">
|
||||
{t("language.note")}
|
||||
</p>
|
||||
</section>
|
||||
{/* scrollable body: the safe area comes from pb-safe + a spacer */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={updateCue}
|
||||
className="sheet-scroll thin-scroll min-h-0 flex-1 overflow-y-auto px-5 pb-safe"
|
||||
>
|
||||
<div ref={contentRef}>
|
||||
{/* --------------------------------------------- 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>
|
||||
<p className="text-[11px] leading-relaxed text-[var(--text-subtle)]">
|
||||
{t("language.note")}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* ------------------------------------------------ API key */}
|
||||
<section className="mb-4 space-y-2 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.apiKey")}</h3>
|
||||
{apiKey ? (
|
||||
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-bold text-emerald-900 dark:bg-emerald-400/20 dark:text-emerald-200">
|
||||
{t("settings.savedInBrowser")}
|
||||
</span>
|
||||
) : needsKeyMode ? (
|
||||
<span className="rounded-full bg-amber-50 px-2 py-0.5 text-[11px] font-semibold text-amber-800 dark:bg-amber-500/15 dark:text-amber-200">
|
||||
{t("settings.required")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded-full bg-[var(--surface-sunken)] px-2 py-0.5 text-[11px] font-semibold text-frost-600 dark:text-frost-300">
|
||||
{t("settings.notNeeded")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* ------------------------------------------------ API key */}
|
||||
<section className="mb-4 space-y-3 rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold text-frost-950 dark:text-frost-50">
|
||||
{t("settings.apiKey")}
|
||||
</h3>
|
||||
{apiKey ? (
|
||||
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-bold text-emerald-900 dark:bg-emerald-400/20 dark:text-emerald-200">
|
||||
{t("settings.savedInBrowser")}
|
||||
</span>
|
||||
) : needsKeyMode ? (
|
||||
<span className="rounded-full bg-amber-50 px-2 py-0.5 text-[11px] font-semibold text-amber-800 dark:bg-amber-500/15 dark:text-amber-200">
|
||||
{t("settings.required")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded-full bg-[var(--surface-sunken)] px-2 py-0.5 text-[11px] font-semibold text-[var(--text-muted)]">
|
||||
{t("settings.notNeeded")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<label className="sr-only" htmlFor="ollama-api-key">
|
||||
{t("settings.apiKey")}
|
||||
</label>
|
||||
<input
|
||||
id="ollama-api-key"
|
||||
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="min-w-40 flex-1 rounded-xl border border-[var(--hairline)] bg-[var(--surface-strong)] px-3 py-2 font-mono text-xs text-frost-950 outline-none transition placeholder:font-sans placeholder:text-frost-400 focus:border-accent-500 dark:text-frost-50"
|
||||
/>
|
||||
<Button size="sm" variant="secondary" onClick={() => setReveal((value) => !value)}>
|
||||
{reveal ? t("settings.hide") : t("settings.show")}
|
||||
</Button>
|
||||
<Button size="sm" onClick={save} disabled={keyDraft.trim() === apiKey}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
{/* Phones: the field gets its own row, the two actions stack
|
||||
underneath at full touch-target size. */}
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
|
||||
<label className="sr-only" htmlFor="ollama-api-key">
|
||||
{t("settings.apiKey")}
|
||||
</label>
|
||||
<input
|
||||
id="ollama-api-key"
|
||||
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 min-w-0 rounded-xl border border-[var(--hairline)] bg-[var(--surface-strong)] px-3 py-2 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 sm:min-w-40 sm:flex-1"
|
||||
/>
|
||||
<div className="flex items-center gap-2 sm:contents">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="touch-target flex-1 sm:flex-none"
|
||||
onClick={() => setReveal((value) => !value)}
|
||||
>
|
||||
{reveal ? t("settings.hide") : t("settings.show")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="touch-target flex-1 sm:flex-none"
|
||||
onClick={save}
|
||||
disabled={keyDraft.trim() === apiKey}
|
||||
>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] leading-relaxed text-frost-500 dark:text-frost-400">
|
||||
{t("settings.keyNote")} <code>OLLAMA_AUTH=local</code> {t("settings.keyNoteAfter")}{" "}
|
||||
<code>.env.local</code> {t("settings.keyNoteEnd")} <code>ollama signin</code>
|
||||
{t("settings.keyNoteEnd2")}
|
||||
</p>
|
||||
<p className="text-[11px] leading-relaxed text-[var(--text-subtle)]">
|
||||
{t("settings.keyNote")}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 pt-1">
|
||||
<Button size="sm" variant="secondary" onClick={() => void refresh()} loading={loading}>
|
||||
{t("settings.testConnection")}
|
||||
</Button>
|
||||
{apiKey ? (
|
||||
<Button size="sm" variant="ghost" onClick={forget}>
|
||||
{t("settings.removeKey")}
|
||||
</Button>
|
||||
<details className="group">
|
||||
<summary className="touch-target flex cursor-pointer list-none items-center gap-1.5 py-1 text-[11px] font-medium text-[var(--text-muted)] transition hover:text-[var(--text)] [&::-webkit-details-marker]:hidden">
|
||||
<ChevronDownIcon className="size-3.5 shrink-0 transition-transform group-open:rotate-180" />
|
||||
{t("settings.localTipTitle")}
|
||||
</summary>
|
||||
<p className="pt-1 text-[11px] leading-relaxed text-[var(--text-subtle)]">
|
||||
{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>
|
||||
</details>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 pt-0.5">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="touch-target"
|
||||
onClick={() => void refresh()}
|
||||
loading={loading}
|
||||
>
|
||||
{t("settings.testConnection")}
|
||||
</Button>
|
||||
{saved ? (
|
||||
<span className="text-xs font-semibold text-emerald-700 dark:text-emerald-300">
|
||||
{t("settings.savedTesting")}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{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-2 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
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="touch-target -ml-3 text-rose-700 hover:bg-rose-500/10 dark:text-rose-300"
|
||||
onClick={() => setConfirmRemove(true)}
|
||||
>
|
||||
{t("settings.removeKey")}
|
||||
</Button>
|
||||
)
|
||||
) : null}
|
||||
</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}
|
||||
{saved ? (
|
||||
<span className="text-xs font-semibold text-emerald-700 dark:text-emerald-300">
|
||||
{t("settings.savedTesting")}
|
||||
</span>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-2xl bg-rose-100 p-3 text-sm text-rose-900 dark:bg-rose-500/15 dark:text-rose-200">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-3 py-2 text-sm text-[var(--text-muted)]">
|
||||
<Spinner className="size-4" /> {t("settings.checking")}
|
||||
</div>
|
||||
) : checkedAt ? (
|
||||
<p className="py-1 text-[11px] font-medium text-[var(--text-subtle)]">
|
||||
{freshnessLabel(checkedAt, locale, t)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{health ? <HealthPanel health={health} /> : null}
|
||||
|
||||
{/* breathing room for the scroll cue + safe area */}
|
||||
<div className="h-5" aria-hidden />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* ------------------------------------------------ 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}
|
||||
|
||||
{loading && !health ? (
|
||||
<div className="flex items-center gap-3 py-6 text-sm text-frost-600 dark:text-frost-300">
|
||||
<Spinner className="size-5" /> {t("settings.checking")}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-2xl bg-rose-100 p-3 text-sm text-rose-900 dark:bg-rose-500/15 dark:text-rose-200">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{health ? <HealthPanel health={health} /> : null}
|
||||
{/* visible scroll cue */}
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-x-0 bottom-2 flex justify-center transition-opacity duration-200",
|
||||
canScroll ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
>
|
||||
<span className="grid size-7 place-items-center rounded-full border border-[var(--hairline)] bg-[var(--surface-strong)] text-[var(--text-muted)] shadow-[0_4px_12px_-6px_rgb(14_21_40/0.5)] backdrop-blur-md">
|
||||
<ChevronDownIcon className="size-3.5" />
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
) : null}
|
||||
|
|
@ -295,7 +469,7 @@ function HealthPanel({ health }: { health: ReturnType<typeof useHealth>["health"
|
|||
</div>
|
||||
|
||||
{health.mode === "direct-cloud" && health.needsKey ? (
|
||||
<p className="rounded-2xl bg-[var(--surface-sunken)] p-3 text-xs text-frost-700 dark:text-frost-200">
|
||||
<p className="rounded-2xl bg-[var(--surface-sunken)] p-3 text-xs text-[var(--text-muted)]">
|
||||
{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
|
||||
|
|
@ -311,8 +485,8 @@ function HealthPanel({ health }: { health: ReturnType<typeof useHealth>["health"
|
|||
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">
|
||||
<dt className="text-xs uppercase tracking-wide text-frost-500 dark:text-frost-400">{label}</dt>
|
||||
<dd className={cn("mt-0.5 truncate font-semibold", mono && "font-mono text-xs")}>{value}</dd>
|
||||
<dt className="text-xs uppercase tracking-wide text-[var(--text-subtle)]">{label}</dt>
|
||||
<dd className={cn("mt-0.5 break-words font-semibold", mono && "font-mono text-xs")}>{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -330,19 +504,21 @@ function ModelRow({
|
|||
? { text: t("health.available"), className: "bg-emerald-100 text-emerald-900 dark:bg-emerald-400/20 dark:text-emerald-200" }
|
||||
: model.available === false
|
||||
? { text: t("health.notFound"), className: "bg-rose-50 text-rose-800 dark:bg-rose-500/15 dark:text-rose-200" }
|
||||
: { text: t("health.unknown"), className: "bg-[var(--surface-sunken)] text-frost-600 dark:text-frost-300" };
|
||||
: { text: t("health.unknown"), className: "bg-[var(--surface-sunken)] text-[var(--text-muted)]" };
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs uppercase tracking-wide text-frost-500 dark:text-frost-400">{title}</p>
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--text-subtle)]">{title}</p>
|
||||
<span className={cn("rounded-full px-2 py-0.5 text-[11px] font-bold", badge.className)}>
|
||||
{badge.text}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 font-mono text-sm font-semibold">{model.resolved}</p>
|
||||
{model.configured !== model.resolved ? (
|
||||
<p className="text-xs text-frost-500 dark:text-frost-400">{t("health.configuredAs", { model: model.configured })}</p>
|
||||
<p className="text-xs text-[var(--text-subtle)]">
|
||||
{t("health.configuredAs", { model: model.configured })}
|
||||
</p>
|
||||
) : null}
|
||||
{model.note ? <p className="mt-1 text-xs text-amber-700 dark:text-amber-300">{model.note}</p> : null}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue