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
17
README.md
17
README.md
|
|
@ -853,6 +853,23 @@ Things I chose while building, with the reasoning:
|
|||
only for event handlers and non-React helpers (documents, folders, API errors). The notes' own
|
||||
language is untouched by this — decks still come back in the language of your notes.
|
||||
|
||||
34. **The mobile pass was a layering *perception* fix plus a real contrast debt.** The ice orbs were
|
||||
never above the content — the scene is a fixed `pointer-events:none` layer at `z-index:-1`
|
||||
(verified with `elementFromPoint`), but it shines through the translucent page and glass. Phones
|
||||
now get a thinner scene (lower blob/orb opacity, orbs tucked to the edges, the bottom-right orb
|
||||
moved out from under the tab bar) and the tab bar paints `color-mix(page 84%)` so its labels keep
|
||||
their contrast. Helper text moved onto the tokens (`--text-subtle` is #9db1d0 in dark: 8.6:1 on
|
||||
the page, 7.2:1 on dark glass) and disabled buttons are a visible muted chip (`.btn-disabled`)
|
||||
instead of `opacity-40`. Buttons share one ice gradient in both themes — which also exposed that
|
||||
the danger/success variants never showed their colour, because the primary `background-image`
|
||||
painted over `!bg-rose-600` (`bg-none` now strips it). Light mode got a cool card rim instead of
|
||||
white-on-white. The settings sheet swipes away like the install sheet (drag only while the body
|
||||
is scrolled to the top), its body scrolls to the true bottom with a fading scroll cue, and the
|
||||
API-key note is split into a short privacy line plus a collapsible local-daemon tip. axe-core
|
||||
reports every glass surface as “incomplete”, so contrast is verified by a pixel-sampled script
|
||||
(hide the text → screenshot → worst-case background luminance per glyph rect): 0 runs below AA
|
||||
across library and settings, both themes, both languages.
|
||||
|
||||
14. **`gpt-oss:120b` does all content work — but it cannot read images** (`this model does not
|
||||
support image input`), so `VISION_MODEL` has to stay a multimodal model (`gemma4:31b-cloud`).
|
||||
Text-only inputs — pasted text, PDFs with a text layer, DOCX/PPTX/ODT — never touch the vision
|
||||
|
|
|
|||
|
|
@ -95,13 +95,15 @@
|
|||
--glass-tint: rgb(255 255 255 / 0.55);
|
||||
--text: #0b1830; /* 16.6:1 on #eef5fd */
|
||||
--text-muted: #46586f; /* 6.6:1 on light glass */
|
||||
--text-subtle: #566a84; /* 5.0:1 on light glass */
|
||||
--text-subtle: #566a84; /* 5.6:1 on light glass */
|
||||
--hover: rgb(12 32 62 / 0.05);
|
||||
--focus: #0ea5e9;
|
||||
|
||||
/* Frosted ice glass — one shared recipe for every large surface. */
|
||||
--glass-bg: rgb(255 255 255 / 0.55);
|
||||
--glass-border: rgb(255 255 255 / 0.8);
|
||||
/* A cool rim instead of near-white: white-on-white made light-mode card
|
||||
edges invisible against the pale page. */
|
||||
--glass-border: rgb(143 174 212 / 0.55);
|
||||
--glass-highlight: rgb(255 255 255 / 0.85);
|
||||
--glass-shadow: 0 20px 50px -20px rgb(30 64 175 / 0.35);
|
||||
--glass-solid-bg: rgb(255 255 255 / 0.8); /* question card: more opaque */
|
||||
|
|
@ -121,7 +123,7 @@
|
|||
--install-chip: rgb(255 255 255 / 0.55);
|
||||
--install-border: rgb(255 255 255 / 0.8);
|
||||
--shadow-card: 0 1px 2px rgb(14 21 40 / 0.06), 0 1px 3px rgb(14 21 40 / 0.04);
|
||||
--card-shadow: 0 1px 2px rgb(14 21 40 / 0.05), 0 5px 14px -6px rgb(14 21 40 / 0.12);
|
||||
--card-shadow: 0 1px 2px rgb(14 21 40 / 0.06), 0 9px 22px -8px rgb(14 21 40 / 0.16);
|
||||
--shadow-card-hover: 0 24px 60px -24px rgb(30 64 175 / 0.45), 0 2px 6px rgb(30 64 175 / 0.12);
|
||||
}
|
||||
|
||||
|
|
@ -141,7 +143,7 @@
|
|||
--glass-tint: rgb(255 255 255 / 0.06);
|
||||
--text: #e8f0ff; /* 16.3:1 on #0a1120 */
|
||||
--text-muted: #b3c4e0; /* 10.1:1 */
|
||||
--text-subtle: #90a4c6; /* 6.9:1 */
|
||||
--text-subtle: #9db1d0; /* 8.6:1 on page, 7.2:1 on dark glass */
|
||||
--hover: rgb(255 255 255 / 0.07);
|
||||
--focus: #38bdf8;
|
||||
|
||||
|
|
@ -323,6 +325,25 @@ body {
|
|||
.ice-orb-3 { width: 150px; height: 150px; }
|
||||
}
|
||||
|
||||
/* Phones: the decoration is already *behind* the content (the scene is a
|
||||
fixed, pointer-events:none layer at z-index -1), but it shines through the
|
||||
translucent page and glass, so it reads as overlap. Thin it right down and
|
||||
tuck the orbs to the edges, away from the text column and the tab bar. */
|
||||
@media (max-width: 639px) {
|
||||
:root {
|
||||
--ice-blob-opacity: 0.1;
|
||||
--ice-orb-opacity: 0.32;
|
||||
}
|
||||
.dark {
|
||||
--ice-blob-opacity: 0.2;
|
||||
--ice-orb-opacity: 0.42;
|
||||
}
|
||||
.ice-orb-1 { width: 96px; height: 96px; top: 8%; left: -26px; }
|
||||
.ice-orb-2 { width: 72px; height: 72px; left: -32px; }
|
||||
/* Was sitting right behind the bottom-nav labels. */
|
||||
.ice-orb-3 { width: 116px; height: 116px; bottom: 15vh; right: -46px; }
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Frosted ice glass ------------------------------------------------------
|
||||
One shared recipe for the large surfaces only: translucent tint, 24px
|
||||
|
|
@ -487,6 +508,13 @@ body {
|
|||
.tabbar {
|
||||
border-top: 1px solid var(--glass-border);
|
||||
}
|
||||
/* Phones: the tab bar sits over the bottom of the ice scene, so give the
|
||||
labels a calmer surface than the barely-there shared glass tint. */
|
||||
@media (max-width: 639px) {
|
||||
.tabbar {
|
||||
background: color-mix(in oklab, var(--page) 84%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
/* Soft tinted chip for activity types (a hint of colour, not a rainbow) -- */
|
||||
.tint-sky { background: linear-gradient(140deg, rgb(156 205 245 / 0.34), rgb(156 205 245 / 0.14)); color: #1c4e72 }
|
||||
|
|
@ -531,32 +559,38 @@ body {
|
|||
box-shadow: inset 0 0 0 1px var(--nav-active-border), inset 0 1px 0 var(--glass-highlight);
|
||||
}
|
||||
|
||||
/* Shared CTA language: the icon's blue, with a deeper fill in light mode. */
|
||||
/* Shared CTA language: one ice gradient in *both* themes (it mirrors the
|
||||
segmented control, the progress bars and the tab-bar plus). #06111f on
|
||||
#38bdf8 is ~8.8:1, so the dark label clears AA everywhere on the ramp. */
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
border-radius: 14px;
|
||||
background-image: linear-gradient(135deg, #1d4ed8 0%, #1a5fd0 55%, #0ea5e9 100%);
|
||||
color: #ffffff;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 10px 24px -12px rgb(14 165 233 / 0.7);
|
||||
background-image: linear-gradient(135deg, #38bdf8 0%, #60a5fa 100%);
|
||||
color: #06111f;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 10px 24px -14px rgb(14 165 233 / 0.75);
|
||||
transition: box-shadow 180ms ease, filter 180ms ease, transform 160ms ease;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
filter: brightness(1.07);
|
||||
box-shadow: 0 14px 32px -12px rgb(56 189 248 / 0.85);
|
||||
filter: brightness(1.06);
|
||||
box-shadow: 0 14px 32px -14px rgb(56 189 248 / 0.9);
|
||||
}
|
||||
.btn-primary:active { transform: scale(0.985); }
|
||||
.dark .btn-primary {
|
||||
background-image: linear-gradient(135deg, #ffffff 0%, #bfe3ff 100%);
|
||||
color: #0b1220;
|
||||
box-shadow: 0 10px 26px -14px rgb(56 189 248 / 0.6);
|
||||
}
|
||||
.dark .btn-primary:hover {
|
||||
filter: brightness(1.03);
|
||||
box-shadow: 0 14px 34px -14px rgb(56 189 248 / 0.95);
|
||||
|
||||
/* Disabled: a real, visible chip instead of a 40%-opacity ghost. Important
|
||||
declarations deliberately live in this (earlier) layer so they beat the
|
||||
important rose/emerald overrides of the danger/success variants. */
|
||||
.btn-disabled {
|
||||
background-image: none !important;
|
||||
background-color: var(--surface-sunken) !important;
|
||||
color: var(--text-subtle) !important;
|
||||
box-shadow: none !important;
|
||||
filter: none !important;
|
||||
transform: none !important;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
|
|
@ -653,6 +687,22 @@ body {
|
|||
@media (display-mode: standalone) {
|
||||
.standalone-hide { display: none }
|
||||
}
|
||||
|
||||
/* Phones: Apple's 44px minimum for touch targets. The class is a no-op on
|
||||
desktop (and on tablets), so components can carry it unconditionally. */
|
||||
@media (max-width: 639px) {
|
||||
.touch-target { min-height: 44px; min-width: 44px; }
|
||||
.nav-row { min-height: 44px; }
|
||||
.segmented > button { min-height: 44px; }
|
||||
.folder-pill { min-height: 44px; }
|
||||
}
|
||||
|
||||
/* Settings sheet: contained momentum scrolling; the safe-area padding comes
|
||||
from the pb-safe utility plus a trailing spacer. */
|
||||
.sheet-scroll {
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile form fields ----------------------------------------------------- */
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { GearIcon, MenuIcon } from "@/components/icons";
|
||||
import { MenuIcon } from "@/components/icons";
|
||||
import { SettingsDialog } from "@/components/SettingsDialog";
|
||||
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
|
|
@ -35,7 +35,10 @@ export function AppHeader({ className, compact = false }: { className?: string;
|
|||
<>
|
||||
<header
|
||||
className={cn(
|
||||
"glass sticky top-0 z-40 -mx-4 mb-6 px-4 lg:hidden sm:-mx-6 sm:px-6",
|
||||
// No negative margins here: the header is a direct child of <main>,
|
||||
// which has no horizontal padding — the -mx-4 made it 16px wider
|
||||
// than the viewport and caused a horizontal scroll on phones.
|
||||
"glass sticky top-0 z-40 mb-6 px-4 lg:hidden sm:px-6",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
|
@ -44,7 +47,7 @@ export function AppHeader({ className, compact = false }: { className?: string;
|
|||
type="button"
|
||||
onClick={() => sidebarOpen.toggle()}
|
||||
aria-label={t("header.openMenu")}
|
||||
className="mr-1 grid size-9 shrink-0 place-items-center rounded-full text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
||||
className="mr-1 grid size-9 shrink-0 touch-target place-items-center rounded-full text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
||||
>
|
||||
<MenuIcon className="size-5" />
|
||||
</button>
|
||||
|
|
@ -69,15 +72,8 @@ export function AppHeader({ className, compact = false }: { className?: string;
|
|||
</Link>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
aria-label={t("header.openSettings")}
|
||||
title={t("common.settings")}
|
||||
className="inline-flex size-9 items-center justify-center rounded-full text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
||||
>
|
||||
<GearIcon className="size-[18px]" />
|
||||
</button>
|
||||
{/* Settings lives in the tab bar and the drawer; the old header
|
||||
gear was the same control a third time. */}
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ export function FolderBar({
|
|||
|
||||
const pill = (active: boolean) =>
|
||||
cn(
|
||||
"inline-flex shrink-0 items-center gap-2 rounded-full px-3.5 py-1.5 text-[13px] font-medium transition",
|
||||
"folder-pill inline-flex shrink-0 items-center gap-2 rounded-full px-3.5 py-1.5 text-[13px] font-medium transition",
|
||||
active
|
||||
? "bg-[var(--surface-sunken)] text-[var(--text)]"
|
||||
: "text-[var(--text-muted)] hover:bg-[var(--hover)] hover:text-[var(--text)]",
|
||||
|
|
@ -116,7 +116,7 @@ export function FolderBar({
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => setCreating(true)}
|
||||
className="inline-flex shrink-0 items-center gap-2 rounded-full px-3.5 py-1.5 text-[13px] font-medium text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
||||
className="folder-pill inline-flex shrink-0 items-center gap-2 rounded-full px-3.5 py-1.5 text-[13px] font-medium text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
{t("common.newFolder")}
|
||||
|
|
|
|||
|
|
@ -119,23 +119,38 @@ export function LibraryScreen() {
|
|||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
className="lg:hidden"
|
||||
onClick={() => importRef.current?.click()}
|
||||
>
|
||||
<UploadIcon className="size-4" />
|
||||
{t("library.import")}
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => importRef.current?.click()}
|
||||
className="hidden items-center gap-1.5 text-[13px] font-medium text-[var(--text-muted)] transition hover:text-[var(--text)] hover:underline lg:inline-flex"
|
||||
>
|
||||
<UploadIcon className="size-4" />
|
||||
{t("library.import")}
|
||||
</button>
|
||||
{/* With nothing in the library, Import drops to a quiet text link
|
||||
so the single primary action is “Make your first deck”. */}
|
||||
{isEmpty ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => importRef.current?.click()}
|
||||
className="inline-flex items-center gap-1.5 text-[13px] font-medium text-[var(--text-muted)] transition hover:text-[var(--text)] hover:underline"
|
||||
>
|
||||
<UploadIcon className="size-4" />
|
||||
{t("library.import")}
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
className="touch-target lg:hidden"
|
||||
onClick={() => importRef.current?.click()}
|
||||
>
|
||||
<UploadIcon className="size-4" />
|
||||
{t("library.import")}
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => importRef.current?.click()}
|
||||
className="hidden items-center gap-1.5 text-[13px] font-medium text-[var(--text-muted)] transition hover:text-[var(--text)] hover:underline lg:inline-flex"
|
||||
>
|
||||
<UploadIcon className="size-4" />
|
||||
{t("library.import")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<input
|
||||
ref={importRef}
|
||||
type="file"
|
||||
|
|
@ -167,23 +182,28 @@ export function LibraryScreen() {
|
|||
</p>
|
||||
) : null}
|
||||
|
||||
{/* Mobile folder switcher; the sidebar covers desktop. */}
|
||||
<FolderBar className="lg:hidden"
|
||||
folders={folderList}
|
||||
counts={counts}
|
||||
selected={selected}
|
||||
onSelect={(id) => {
|
||||
setSelected(id);
|
||||
setConfirmDelete(null);
|
||||
}}
|
||||
onCreated={(id) => {
|
||||
setSelected(id);
|
||||
setMessage({
|
||||
tone: "ok",
|
||||
text: t("library.folderCreated"),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{/* Mobile folder switcher; the sidebar covers desktop. With an empty
|
||||
library there is nothing to filter, and the pills would compete
|
||||
with the empty state's single call to action. */}
|
||||
{!isEmpty ? (
|
||||
<FolderBar
|
||||
className="lg:hidden"
|
||||
folders={folderList}
|
||||
counts={counts}
|
||||
selected={selected}
|
||||
onSelect={(id) => {
|
||||
setSelected(id);
|
||||
setConfirmDelete(null);
|
||||
}}
|
||||
onCreated={(id) => {
|
||||
setSelected(id);
|
||||
setMessage({
|
||||
tone: "ok",
|
||||
text: t("library.folderCreated"),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
|
||||
{message ? (
|
||||
|
|
@ -204,7 +224,7 @@ export function LibraryScreen() {
|
|||
<EmptyState
|
||||
title={t("library.noDecks")}
|
||||
action={
|
||||
<Link href="/create" className="btn-primary h-10 px-4 text-sm">
|
||||
<Link href="/create" className="btn-primary touch-target h-10 px-4 text-sm">
|
||||
{t("library.makeFirst")}
|
||||
</Link>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -291,6 +291,7 @@ function NavContent({
|
|||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
aria-label={theme === "dark" ? t("common.switchToLight") : t("common.switchToDark")}
|
||||
title={theme === "dark" ? t("common.lightMode") : t("common.darkMode")}
|
||||
className={cn("nav-row", collapsed && "justify-center px-2")}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -44,13 +44,13 @@ export function ThemeToggle({ className }: { className?: string }) {
|
|||
aria-label={dark ? t("common.switchToLight") : t("common.switchToDark")}
|
||||
title={dark ? t("common.lightMode") : t("common.darkMode")}
|
||||
className={cn(
|
||||
"inline-flex size-9 items-center justify-center rounded-full text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]",
|
||||
// A bordered chip with a press affordance: the bare sun/moon glyph was
|
||||
// easy to read as decoration rather than a control.
|
||||
"touch-target inline-flex size-9 items-center justify-center rounded-full border border-[var(--hairline)] bg-[var(--surface-strong)] text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)] active:scale-95",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span aria-hidden>
|
||||
{dark ? <SunIcon className="size-5" /> : <MoonIcon className="size-5" />}
|
||||
</span>
|
||||
<span aria-hidden>{dark ? <SunIcon className="size-5" /> : <MoonIcon className="size-5" />}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,8 +15,12 @@ const VARIANTS: Record<ButtonVariant, string> = {
|
|||
primary: "btn-primary",
|
||||
secondary: "btn-secondary",
|
||||
ghost: "btn-ghost",
|
||||
danger: "btn-primary !bg-rose-600 hover:!bg-rose-700 dark:!bg-rose-500 dark:!text-white dark:hover:!bg-rose-600",
|
||||
success: "btn-primary !bg-emerald-600 hover:!bg-emerald-700 dark:!bg-emerald-500 dark:!text-white dark:hover:!bg-emerald-600",
|
||||
// bg-none strips the primary gradient, which otherwise paints over the
|
||||
// rose/emerald background-color and wins visually.
|
||||
danger:
|
||||
"btn-primary bg-none !bg-rose-600 hover:!bg-rose-700 dark:!bg-rose-500 dark:!text-white dark:hover:!bg-rose-600",
|
||||
success:
|
||||
"btn-primary bg-none !bg-emerald-600 hover:!bg-emerald-700 dark:!bg-emerald-500 dark:!text-white dark:hover:!bg-emerald-600",
|
||||
};
|
||||
|
||||
const SIZES: Record<ButtonSize, string> = {
|
||||
|
|
@ -40,9 +44,12 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
|||
ref={ref}
|
||||
disabled={disabled || loading}
|
||||
className={cn(
|
||||
"inline-flex select-none items-center justify-center whitespace-nowrap font-medium transition disabled:cursor-not-allowed disabled:opacity-40",
|
||||
"inline-flex select-none items-center justify-center whitespace-nowrap font-medium transition disabled:cursor-not-allowed",
|
||||
VARIANTS[variant],
|
||||
SIZES[size],
|
||||
// A visible, muted chip rather than a ghosted primary. Only for an
|
||||
// explicit `disabled`; a `loading` button stays vivid with its spinner.
|
||||
disabled && "btn-disabled",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -119,10 +119,15 @@ const en: Record<string, string> = {
|
|||
"settings.hide": "Hide",
|
||||
"settings.show": "Show",
|
||||
"settings.keyNote":
|
||||
"Stored only in this browser's local storage and sent with each request so the server can talk to Ollama for you. It is never written to the server, never logged and never put in a cookie. Prefer the local daemon? Set",
|
||||
"Stored only in this browser's local storage and sent with each request so the server can talk to Ollama for you. It is never written to the server, never logged and never put in a cookie.",
|
||||
"settings.localTipTitle": "Using the local daemon?",
|
||||
"settings.localTipSet": "Set",
|
||||
"settings.keyNoteAfter": "in",
|
||||
"settings.keyNoteEnd": "and use",
|
||||
"settings.keyNoteEnd2": "instead.",
|
||||
"settings.keyNoteEnd2": " instead.",
|
||||
"settings.removeKeyConfirm":
|
||||
"Remove the key? The full-screen key setup comes back until you add one.",
|
||||
"settings.removeKeyYes": "Yes, remove it",
|
||||
"settings.testConnection": "Test connection",
|
||||
"settings.removeKey": "Remove key from this browser",
|
||||
"settings.savedTesting": "Saved — testing…",
|
||||
|
|
@ -156,6 +161,8 @@ const en: Record<string, string> = {
|
|||
"health.notFound": "not found",
|
||||
"health.unknown": "unknown",
|
||||
"health.configuredAs": "configured as {model}",
|
||||
"health.checkedNow": "Checked just now",
|
||||
"health.checkedAt": "Checked at {time}",
|
||||
"health.createKey.before": "Create a key at",
|
||||
"health.createKey.after": "and paste it above. Tip: with the local daemon you can run",
|
||||
"health.createKey.end": "instead and set",
|
||||
|
|
@ -633,10 +640,15 @@ const cs: Record<string, string> = {
|
|||
"settings.hide": "Skrýt",
|
||||
"settings.show": "Zobrazit",
|
||||
"settings.keyNote":
|
||||
"Ukládá se jen do místního úložiště tohoto prohlížeče a posílá se s každým požadavkem, aby server mohl komunikovat s Ollamou. Nikdy se neukládá na server, nezapisuje do logů ani do cookies. Radši místní daemon? Nastav",
|
||||
"Ukládá se jen do místního úložiště tohoto prohlížeče a posílá se s každým požadavkem, aby server mohl komunikovat s Ollamou. Nikdy se neukládá na server, nezapisuje do logů ani do cookies.",
|
||||
"settings.localTipTitle": "Používáš místní daemon?",
|
||||
"settings.localTipSet": "Nastav",
|
||||
"settings.keyNoteAfter": "v",
|
||||
"settings.keyNoteEnd": "a použij",
|
||||
"settings.keyNoteEnd2": ".",
|
||||
"settings.removeKeyConfirm":
|
||||
"Odstranit klíč? Dokud klíč znovu nepřidáš, vrátí se celoobrazovková obrazovka pro nastavení klíče.",
|
||||
"settings.removeKeyYes": "Ano, odstranit",
|
||||
"settings.testConnection": "Otestovat připojení",
|
||||
"settings.removeKey": "Odstranit klíč z prohlížeče",
|
||||
"settings.savedTesting": "Uloženo — testuji…",
|
||||
|
|
@ -670,6 +682,8 @@ const cs: Record<string, string> = {
|
|||
"health.notFound": "nenalezen",
|
||||
"health.unknown": "neznámý",
|
||||
"health.configuredAs": "nakonfigurováno jako {model}",
|
||||
"health.checkedNow": "Zkontrolováno právě teď",
|
||||
"health.checkedAt": "Zkontrolováno v {time}",
|
||||
"health.createKey.before": "Vytvoř klíč na",
|
||||
"health.createKey.after": "a vlož ho výše. Tip: s místním daemonem můžeš spustit",
|
||||
"health.createKey.end": "a nastavit",
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ export function useHealth() {
|
|||
const [health, setHealth] = useState<Health | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [checkedAt, setCheckedAt] = useState<number | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
|
|
@ -28,6 +29,8 @@ export function useHealth() {
|
|||
} catch (loadError) {
|
||||
setError(loadError instanceof Error ? loadError.message : t("api.unreachableShort"));
|
||||
} finally {
|
||||
// Even a failed check is a check: the panel can say when it last ran.
|
||||
setCheckedAt(Date.now());
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
|
@ -36,5 +39,5 @@ export function useHealth() {
|
|||
void refresh();
|
||||
}, [refresh, apiKey]);
|
||||
|
||||
return { health, loading, error, refresh, apiKey };
|
||||
return { health, loading, error, refresh, apiKey, checkedAt };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
*
|
||||
* Bumping CACHE_VERSION is how a new deploy invalidates old assets.
|
||||
*/
|
||||
const CACHE_VERSION = "freezmo-v4";
|
||||
const CACHE_VERSION = "freezmo-v5";
|
||||
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
|
||||
const ASSET_CACHE = `${CACHE_VERSION}-assets`;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue