Safety net and errors, mobile-first, desktop unchanged in behaviour. Backup and persistence - Settings -> Data & backup: 'Export all data' writes one zod-validated JSON (decks, folders, homework, progress, safe settings; the API key is not part of the shape and never appears in the file). - 'Import backup' validates, shows a preview with added/replaced counts and offers merge or replace; invalid files get a friendly error. Storage persistence (navigator.storage.persist) is shown and requestable, with the result reported. - A dismissible nudge appears after 5 new decks or 14 days without a backup and stays snoozed for 14 days. Opt-in encrypted sync (off by default) - New lib/sync.ts: PBKDF2-SHA256 (310k) -> AES-GCM-256, derived key kept as a non-extractable CryptoKey in IndexedDB (lib/sync-key.ts); the passphrase and key never leave the browser. - New POST/GET/DELETE /api/sync following the route pattern (locale, cross-origin guard, body cap, rate limit RATE_LIMIT_SYNC, auth before body parsing, zod validation). One atomic JSON file per random 128-bit sync ID under SYNC_DIR; server-stamped updatedAt is the conflict clock; SYNC_MAX_BYTES and SYNC_TOTAL_BYTES enforced (413 / 507). The server only ever sees an opaque envelope. - Settings -> Sync: enable with a passphrase, copy the sync ID, sync now, last-synced time, explicit keep-this-device / use-synced-copy conflict choice, join from another device (ID + passphrase, wrong passphrase rejected), unlock after the key is gone, delete the synced copy (works even on a locked device). Every data store marks sync dirty on write; pulled payloads are applied without marking dirty again. Monthly usage vs short-term throttling - lib/ollama.ts classifies the real 429 body (monthly/quota/upgrade vs burst) into new 'usage_limit' and 'throttled' kinds, mapped in every route and in the health probe. - The client renders the same localized message in the deck flow, homework, grading and the Settings banner: the required upgrade/new-key copy with 'Open Settings' (jumps to and focuses the key field) and 'Open Ollama'. Input and wizard drafts are kept so Retry works right after swapping the key. No model fallback. Verified: new phase-1 suite 38/38 (backup content + no key, import preview/merge/replace, junk file, persistence row, nudge + snooze, sync enable/upload/opaque blob/push/join/wrong passphrase/delete, server validation 400/401, usage-limit copy and key focus, and the real route mapping against a stub Ollama for both 429 variants) plus regression 74/74, mobile 52/52, wizard 80/80, declutter 116/116, animation 8/8, native 35/35, i18n 25/25. Key parity 638/638. Lint + build clean. SW freezmo-v25.
946 lines
44 KiB
TypeScript
946 lines
44 KiB
TypeScript
"use client";
|
|
|
|
import { AnimatePresence, motion, useDragControls, useReducedMotion } from "framer-motion";
|
|
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
|
|
import { LOCALES, useI18n, type Locale } from "@/lib/i18n";
|
|
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 { 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 installAvailable = useInstallAvailable();
|
|
const { locale, setLocale, t, plural } = useI18n();
|
|
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 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>
|
|
</section>
|
|
|
|
{/* ------------------------------------------- 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)}
|
|
/>
|
|
</dl>
|
|
{health?.mode === "direct-cloud" && health.needsKey ? (
|
|
<p className="pt-2 text-[11px] leading-relaxed text-[var(--text-subtle)]">
|
|
{t("health.createKey.before")}{" "}
|
|
<a
|
|
className="font-semibold underline underline-offset-2"
|
|
href="https://ollama.com/settings/keys"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
>
|
|
ollama.com/settings/keys
|
|
</a>{" "}
|
|
{t("health.createKey.after")} <code>ollama signin</code> {t("health.createKey.end")}{" "}
|
|
<code>OLLAMA_AUTH=local</code>.
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
</details>
|
|
|
|
{/* ------------------------------- remove key (very bottom) */}
|
|
{apiKey ? (
|
|
confirmRemove ? (
|
|
<div className="rounded-xl border border-rose-300/70 bg-rose-50 p-3 dark:border-rose-400/25 dark:bg-rose-500/10">
|
|
<p className="text-[12px] leading-relaxed text-rose-900 dark:text-rose-100">
|
|
{t("settings.removeKeyConfirm")}
|
|
</p>
|
|
<div className="mt-1.5 flex flex-wrap gap-2">
|
|
<Button size="sm" variant="danger" className="touch-target" onClick={forget}>
|
|
{t("settings.removeKeyYes")}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="touch-target"
|
|
onClick={() => setConfirmRemove(false)}
|
|
>
|
|
{t("common.cancel")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={() => setConfirmRemove(true)}
|
|
className="touch-target mx-auto block px-3 text-center text-[12.5px] font-medium text-rose-700 underline-offset-2 hover:underline dark:text-rose-300"
|
|
>
|
|
{t("settings.removeKey")}
|
|
</button>
|
|
)
|
|
) : null}
|
|
|
|
<div className="h-9" aria-hidden />
|
|
</>
|
|
) : (
|
|
<>
|
|
{/* --------------------------------------------- language */}
|
|
<section className="mb-4 space-y-2 rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
|
|
<h3 className="text-sm font-semibold text-frost-950 dark:text-frost-50">
|
|
{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>
|
|
|
|
{/* ------------------------------------------- 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}
|
|
|
|
{/* 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>
|
|
);
|
|
}
|