freezmo/components/SettingsDialog.tsx
Martin Dvorak 4372423167 Bring-your-own keys for cloud providers (OpenAI-compatible)
Settings -> Providers takes keys for OpenAI, Anthropic (their official
OpenAI-compatibility layer), DeepSeek, Groq, OpenRouter and any custom
OpenAI-compatible endpoint. One adapter (lib/openai-compat) covers
listing, streaming chat, JSON generation and vision, and every model
flow can run on a provider: the chat picker lists each enabled
provider's models (labelled, remembered), and a "Study generation"
choice moves decks, homework, grading and attachment transcription onto
a provider model.

Keys stay in this browser exactly like the Ollama one
(freezmo.providers.v1): never stored server-side, never in a backup or
the sync payload, forwarded only with the request that needs them. The
provider test asserts the key reaches the provider while the routing
header stays on the app side.

New suite tests/providers-test.mjs (13 checks) stubs both protocols:
model listing, chat streaming, study generation and the Settings flow.
Privacy policy updated (en+cs). Contrast 0 findings, axe clean, mobile
126/126, chat 102/102, wizard 95/95, bubbles 34/34, regression 44/44,
i18n 13/13, native 33/33. Version 0.4.7, SW -> freezmo-v72.
2026-10-14 06:29:39 +00:00

1114 lines
52 KiB
TypeScript

"use client";
import Link from "next/link";
import { AnimatePresence, motion, useDragControls, useReducedMotion } from "framer-motion";
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import { LOCALES, useI18n, type Locale } from "@/lib/i18n";
import { useVersion } from "@/lib/use-version";
import { haptic } from "@/lib/haptics";
import { modelErrorCopy } from "@/lib/model-errors";
import { springSheet } from "@/lib/motion";
import { ModelErrorActions } from "@/components/ModelErrorActions";
import { DataSection } from "@/components/DataSection";
import { LearningSection } from "@/components/LearningSection";
import { ThemeSection } from "@/components/ThemeSection";
import { useReducedMotionPref } from "@/lib/motion-pref";
import { ProvidersSection } from "@/components/ProvidersSection";
import { SyncSection } from "@/components/SyncSection";
import { clearApiKey, saveSettings, settingsStore } from "@/lib/settings";
import { useBackClose } from "@/lib/use-back-close";
import { cn } from "@/lib/utils";
import { openInstallSheet, useInstallAvailable } from "@/lib/install";
import { useHealth } from "@/lib/use-health";
import {
AlertTriangleIcon,
CheckCircleIcon,
ChevronDownIcon,
ChevronRightIcon,
DownloadIcon,
EyeIcon,
EyeOffIcon,
RefreshIcon,
XCircleIcon,
} from "@/components/icons";
import { Button, Spinner } from "@/components/ui";
import { SegmentedPill } from "@/components/SegmentedPill";
/** “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,
focusField,
}: {
open: boolean;
onClose: () => void;
/** Set by openSettings("key") — focuses the key input once the sheet opens. */
focusField?: { field: "key"; nonce: number } | null;
}) {
const { health, loading, error, refresh, apiKey, checkedAt } = useHealth();
const { reduced: reducedMotion, setReduced: setReducedMotion } = useReducedMotionPref();
const installAvailable = useInstallAvailable();
const { locale, setLocale, t, plural } = useI18n();
const version = useVersion();
const keyInputRef = useRef<HTMLInputElement>(null);
const settings = useSyncExternalStore(
settingsStore.subscribe,
settingsStore.getSnapshot,
settingsStore.getServerSnapshot,
);
/* The system Back gesture closes the sheet instead of leaving the page. */
useBackClose(open, onClose);
/* "Open Settings" from a usage-limit error lands right on the key field. */
useEffect(() => {
if (!open || focusField?.field !== "key") return;
const timer = window.setTimeout(() => {
keyInputRef.current?.scrollIntoView({ block: "center", behavior: "smooth" });
keyInputRef.current?.focus({ preventScroll: true });
}, 380);
return () => window.clearTimeout(timer);
}, [open, focusField]);
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();
};
window.addEventListener("keydown", onKey);
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);
window.setTimeout(() => setSaved(false), 2500);
};
const forget = () => {
clearApiKey();
setKeyDraft("");
setConfirmRemove(false);
};
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 ? (
<motion.div
className="fixed inset-0 z-50 flex items-end justify-center bg-frost-950/35 p-0 backdrop-blur-md sm:items-center sm:p-6"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
>
<motion.div
role="dialog"
aria-modal="true"
aria-labelledby="settings-title"
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 ? springSheet : { type: "spring", stiffness: 340, damping: 32 }}
onClick={(event) => event.stopPropagation()}
className="glass-panel glass-sheet relative flex max-h-[90dvh] w-full max-w-lg flex-col overflow-hidden rounded-t-[1.6rem] shadow-2xl sm:rounded-[1.35rem]"
>
{/* 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>
{!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"
size="sm"
className="touch-target -mr-1 shrink-0"
onClick={onClose}
aria-label={t("settings.close")}
>
✕
</Button>
</div>
</div>
{/* 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}>
{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={() => {
haptic("toggle");
setLocale(entry.id);
}}
className={cn("relative", phone && "seg-slide")}
>
{phone && locale === entry.id ? <SegmentedPill layoutId="language-pill" /> : null}
<span className="seg-label relative z-10">{entry.label}</span>
</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>
{/* monthly usage used up / short-term throttle */}
{health?.kind === "usage_limit" || health?.kind === "throttled" ? (
<div className="rounded-xl border border-amber-300 bg-amber-50 p-2.5 text-[12px] leading-relaxed text-amber-900 dark:border-amber-400/40 dark:bg-amber-400/10 dark:text-amber-100">
<p className="font-bold">{modelErrorCopy(health.kind)?.title}</p>
<p className="mt-0.5">{modelErrorCopy(health.kind)?.body}</p>
<div className="mt-2 flex flex-wrap gap-2">
<ModelErrorActions kind={health.kind} />
</div>
</div>
) : null}
<div className="relative">
<label className="sr-only" htmlFor="ollama-api-key-phone">
{t("settings.apiKey")}
</label>
<input
id="ollama-api-key-phone"
ref={keyInputRef}
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>
{/* haptics: best-effort vibration feedback */}
<div className="flex items-center justify-between gap-3 border-t border-[var(--hairline-soft)] pt-3">
<span className="min-w-0">
<span className="block text-[13px] font-semibold text-[var(--text)]">
{t("settings.haptics")}
</span>
<span className="block text-[11.5px] leading-snug text-[var(--text-subtle)]">
{t("settings.hapticsNote")}
</span>
</span>
<button
type="button"
role="switch"
aria-checked={settings.haptics}
aria-label={t("settings.haptics")}
onClick={() => {
saveSettings({ haptics: !settings.haptics });
haptic("toggle");
}}
className={cn(
"touch-target relative h-7 w-12 shrink-0 rounded-full border transition-colors",
settings.haptics
? "grad-ice border-transparent"
: "border-[var(--hairline)] bg-[var(--surface-sunken)]",
)}
>
<span
aria-hidden
className={cn(
"absolute top-1/2 size-5 -translate-y-1/2 rounded-full bg-white shadow transition-[left] duration-200",
settings.haptics ? "left-6" : "left-0.5",
)}
/>
</button>
</div>
{/* chat grounding: may chat read the notes text? */}
<ToggleRow
label={t("settings.chatNotes")}
note={t("settings.chatNotesNote")}
checked={settings.chatNotes}
onChange={() => {
saveSettings({ chatNotes: !settings.chatNotes });
haptic("toggle");
}}
/>
</section>
{/* ------------------------------------------- theme */}
<ThemeSection />
{/* --------------------------------------- motion */}
<section className="mb-4 space-y-2 rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
<ToggleRow
label={t("settings.reduceMotion")}
note={t("settings.reduceMotionNote")}
checked={reducedMotion}
onChange={() => {
setReducedMotion(!reducedMotion);
haptic("toggle");
}}
/>
</section>
{/* --------------------------------------- providers */}
<ProvidersSection />
{/* ----------------------------------------- learning */}
<LearningSection level={3} />
{/* ------------------------------------------- data & sync */}
<DataSection />
<SyncSection />
{/* ----------------------------------------------- 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)}
/>
{version ? (
<DetailRow
label={t("settings.version")}
value={[
version.version ? `v${version.version}` : "",
version.short,
version.builtAt ? new Date(version.builtAt).toLocaleString() : "",
]
.filter(Boolean)
.join(" · ")}
mono
/>
) : null}
</dl>
{/* Legal: the same three documents the cookie banner
links to, always reachable from Settings. */}
<div className="pt-3">
<p className="text-[11px] font-semibold uppercase tracking-wider text-[var(--text-subtle)]">
{t("settings.legal")}
</p>
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-1 text-[12.5px] font-semibold">
<Link href="/privacy" className="text-[var(--text)] underline decoration-dotted">
{t("legal.privacy")}
</Link>
<Link href="/terms" className="text-[var(--text)] underline decoration-dotted">
{t("legal.terms")}
</Link>
<Link href="/cookies" className="text-[var(--text)] underline decoration-dotted">
{t("legal.cookies")}
</Link>
</div>
</div>
{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">
{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-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>
{/* 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"
ref={keyInputRef}
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>
<div className="space-y-2">
<p className="text-[11px] leading-relaxed text-[var(--text-subtle)]">
{t("settings.keyNote")}
</p>
<details className="group">
<summary className="touch-target -mt-1 flex cursor-pointer list-none items-center gap-1.5 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-0.5 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>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<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}
{apiKey && !confirmRemove ? (
<Button
size="sm"
variant="ghost"
className="touch-target -ml-2.5 text-rose-700 hover:bg-rose-500/10 dark:text-rose-300"
onClick={() => setConfirmRemove(true)}
>
{t("settings.removeKey")}
</Button>
) : 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-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>
) : null}
</section>
{/* ------------------------------------------- chat grounding */}
<section className="mb-4 rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
<ToggleRow
label={t("settings.chatNotes")}
note={t("settings.chatNotesNote")}
checked={settings.chatNotes}
onChange={() => saveSettings({ chatNotes: !settings.chatNotes })}
/>
</section>
{/* ------------------------------------------- theme */}
<ThemeSection />
{/* --------------------------------------- motion */}
<section className="mb-4 space-y-2 rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
<ToggleRow
label={t("settings.reduceMotion")}
note={t("settings.reduceMotionNote")}
checked={reducedMotion}
onChange={() => {
setReducedMotion(!reducedMotion);
haptic("toggle");
}}
/>
</section>
{/* --------------------------------------- providers */}
<ProvidersSection />
{/* ----------------------------------------- learning */}
<LearningSection level={3} />
{/* ------------------------------------------- data & sync */}
<DataSection />
<SyncSection />
{/* ------------------------------------------------ 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="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}
{version ? (
<p className="mt-2 text-[11px] text-[var(--text-subtle)]">
{t("settings.version")}:{" "}
<span className="font-mono">
{version.version ? `v${version.version} · ` : ""}
{version.short}
</span>
{version.builtAt ? ` · ${new Date(version.builtAt).toLocaleString()}` : ""}
</p>
) : null}
{/* Legal: the same three documents the cookie banner links to. */}
<div className="mt-3 flex flex-wrap gap-x-3 gap-y-1 text-[12px] font-semibold">
<Link href="/privacy" className="text-[var(--text-muted)] underline decoration-dotted">
{t("legal.privacy")}
</Link>
<Link href="/terms" className="text-[var(--text-muted)] underline decoration-dotted">
{t("legal.terms")}
</Link>
<Link href="/cookies" className="text-[var(--text-muted)] underline decoration-dotted">
{t("legal.cookies")}
</Link>
</div>
{/* keeps real content out of the 36px fade strip at the bottom */}
<div className="h-9" aria-hidden />
</>
)}
</div>
</div>
{/* scroll cue: a bare chevron inside the fade strip, so it never
covers text (the .sheet-scroll mask fades what is underneath) */}
<div
aria-hidden
className={cn(
"pointer-events-none absolute inset-x-0 bottom-0 flex h-9 items-end justify-center pb-1.5 transition-opacity duration-200",
canScroll ? "opacity-100" : "opacity-0",
)}
>
<ChevronDownIcon className="size-4 text-[var(--text-subtle)]" />
</div>
</motion.div>
</motion.div>
) : null}
</AnimatePresence>
);
}
function HealthPanel({ health }: { health: ReturnType<typeof useHealth>["health"] }) {
const { t } = useI18n();
if (!health) return null;
const tone = health.ok ? "emerald" : health.needsKey ? "amber" : health.reachable ? "amber" : "rose";
const toneClasses: Record<string, string> = {
emerald: "bg-emerald-50 text-emerald-800 dark:bg-emerald-500/15 dark:text-emerald-200",
amber: "bg-amber-50 text-amber-800 dark:bg-amber-500/15 dark:text-amber-200",
rose: "bg-rose-50 text-rose-800 dark:bg-rose-500/15 dark:text-rose-200",
};
return (
<div className="space-y-3">
<div className={cn("flex items-start gap-2 rounded-2xl p-3 text-sm font-medium", toneClasses[tone])}>
<span aria-hidden className="mt-0.5 shrink-0">
{tone === "emerald" ? (
<CheckCircleIcon className="size-5" />
) : tone === "amber" ? (
<AlertTriangleIcon className="size-5" />
) : (
<XCircleIcon className="size-5" />
)}
</span>
<span>
{health.ok
? t("health.ok")
: health.needsKey
? t("health.needsKey")
: health.keyAccepted === false
? t("health.rejected")
: health.reachable
? t("health.modelMissing")
: t("health.unreachable")}
{health.error ? <p className="mt-1 font-normal">{health.error}</p> : null}
{health.kind === "usage_limit" || health.kind === "throttled" ? (
<span className="mt-2 flex flex-wrap gap-2">
<ModelErrorActions kind={health.kind} />
</span>
) : null}
</span>
</div>
<dl className="grid grid-cols-2 gap-2 text-sm">
<Info
label={t("health.auth")}
value={
health.authMode === "user-key"
? t("health.authUserKey")
: health.authMode === "server"
? t("health.authServer")
: t("health.authLocal")
}
/>
<Info label={t("health.endpoint")} value={health.baseUrl} mono />
<Info
label={t("health.mode")}
value={health.mode === "direct-cloud" ? t("health.modeCloud") : t("health.modeLocal")}
/>
<Info
label={t("health.key")}
value={
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")
}
/>
</dl>
<div className="space-y-2">
<ModelRow title={t("health.visionModel")} model={health.vision} />
<ModelRow title={t("health.textModel")} model={health.text} />
</div>
{health.mode === "direct-cloud" && health.needsKey ? (
<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
</a>{" "}
{t("health.createKey.after")} <code>ollama signin</code> {t("health.createKey.end")}{" "}
<code>OLLAMA_AUTH=local</code>.
</p>
) : null}
</div>
);
}
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">
<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>
);
}
function ModelRow({
title,
model,
}: {
title: string;
model: { configured: string; resolved: string; available: boolean | null; note?: string };
}) {
const { t } = useI18n();
const badge =
model.available === true
? { 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-[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-[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-[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>
);
}
/** Boolean toggle row used by the chat-grounding setting. */
function ToggleRow({
label,
note,
checked,
onChange,
}: {
label: string;
note: string;
checked: boolean;
onChange: () => void;
}) {
return (
<div className="flex items-center justify-between gap-3 border-t border-[var(--hairline-soft)] pt-3">
<span className="min-w-0">
<span className="block text-[13px] font-semibold text-[var(--text)]">{label}</span>
<span className="block text-[11.5px] leading-snug text-[var(--text-subtle)]">{note}</span>
</span>
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={label}
onClick={onChange}
className={cn(
"touch-target relative h-7 w-12 shrink-0 rounded-full border transition-colors",
checked ? "grad-ice border-transparent" : "border-[var(--hairline)] bg-[var(--surface-sunken)]",
)}
>
<span
aria-hidden
className={cn(
"absolute top-1/2 size-5 -translate-y-1/2 rounded-full bg-white shadow transition-[left] duration-200",
checked ? "left-6" : "left-0.5",
)}
/>
</button>
</div>
);
}