"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 { 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) { 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 version = useVersion(); const keyInputRef = useRef(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(null); const contentRef = useRef(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 ( {open ? ( { 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. */}

{t("settings.title")}

{!phone ? (

{t("settings.subtitle.before")}{" "} .env.local{" "} {t("settings.subtitle.after")}

) : null}
{/* scrollable body: the safe area comes from pb-safe + a spacer */}
{phone ? ( <> {/* --------------------------------------------- language */}

{t("language.label")}

{LOCALES.map((entry) => ( ))}
{/* ------------------------------------------- connection */}

{t("settings.connection")}

{statusText}
{/* monthly usage used up / short-term throttle */} {health?.kind === "usage_limit" || health?.kind === "throttled" ? (

{modelErrorCopy(health.kind)?.title}

{modelErrorCopy(health.kind)?.body}

) : null}
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" />
{keyDraft.trim() !== apiKey ? ( ) : null}
{t("settings.privacyLine")} {t("common.more")}

{t("settings.keyNote")}

{t("settings.localTipTitle")} {t("settings.localTipSet")}{" "} OLLAMA_AUTH=local {t("settings.keyNoteAfter")} .env.local{" "} {t("settings.keyNoteEnd")} ollama signin {t("settings.keyNoteEnd2")}

{/* haptics: best-effort vibration feedback */}
{t("settings.haptics")} {t("settings.hapticsNote")}
{/* chat grounding: may chat read the notes text? */} { saveSettings({ chatNotes: !settings.chatNotes }); haptic("toggle"); }} />
{/* ------------------------------------------- theme */} {/* ----------------------------------------- learning */} {/* ------------------------------------------- data & sync */} {/* ----------------------------------------------- install */} {installAvailable ? (
) : null} {error ? (
{error}
) : null} {/* ----------------------------------------------- details */}
{t("settings.details")}
{loading ? (

{t("settings.checking")}

) : checkedAt ? (

{freshnessLabel(checkedAt, locale, t)}

) : null}
{version ? ( ) : null}
{/* Legal: the same three documents the cookie banner links to, always reachable from Settings. */}

{t("settings.legal")}

{t("legal.privacy")} {t("legal.terms")} {t("legal.cookies")}
{health?.mode === "direct-cloud" && health.needsKey ? (

{t("health.createKey.before")}{" "} ollama.com/settings/keys {" "} {t("health.createKey.after")} ollama signin {t("health.createKey.end")}{" "} OLLAMA_AUTH=local.

) : null}
{/* ------------------------------- remove key (very bottom) */} {apiKey ? ( confirmRemove ? (

{t("settings.removeKeyConfirm")}

) : ( ) ) : null}
) : ( <> {/* --------------------------------------------- language */}

{t("language.label")}

{LOCALES.map((entry) => ( ))}

{t("language.note")}

{/* ------------------------------------------------ API key */}

{t("settings.apiKey")}

{apiKey ? ( {t("settings.savedInBrowser")} ) : needsKeyMode ? ( {t("settings.required")} ) : ( {t("settings.notNeeded")} )}
{/* Phones: the field gets its own row, the two actions stack underneath at full touch-target size. */}
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" />

{t("settings.keyNote")}

{t("settings.localTipTitle")}

{t("settings.localTipSet")} OLLAMA_AUTH=local{" "} {t("settings.keyNoteAfter")} .env.local {t("settings.keyNoteEnd")}{" "} ollama signin {t("settings.keyNoteEnd2")}

{saved ? ( {t("settings.savedTesting")} ) : null} {apiKey && !confirmRemove ? ( ) : null}
{apiKey && confirmRemove ? (

{t("settings.removeKeyConfirm")}

) : null}
{/* ------------------------------------------- chat grounding */}
saveSettings({ chatNotes: !settings.chatNotes })} />
{/* ------------------------------------------- theme */} {/* ----------------------------------------- learning */} {/* ------------------------------------------- data & sync */} {/* ------------------------------------------------ install */} {installAvailable ? (
) : null} {error ? (
{error}
) : null} {loading ? (
{t("settings.checking")}
) : checkedAt ? (

{freshnessLabel(checkedAt, locale, t)}

) : null} {health ? : null} {version ? (

{t("settings.version")}:{" "} {version.version ? `v${version.version} · ` : ""} {version.short} {version.builtAt ? ` · ${new Date(version.builtAt).toLocaleString()}` : ""}

) : null} {/* Legal: the same three documents the cookie banner links to. */}
{t("legal.privacy")} {t("legal.terms")} {t("legal.cookies")}
{/* keeps real content out of the 36px fade strip at the bottom */}
)}
{/* scroll cue: a bare chevron inside the fade strip, so it never covers text (the .sheet-scroll mask fades what is underneath) */}
) : null} ); } function HealthPanel({ health }: { health: ReturnType["health"] }) { const { t } = useI18n(); if (!health) return null; const tone = health.ok ? "emerald" : health.needsKey ? "amber" : health.reachable ? "amber" : "rose"; const toneClasses: Record = { 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 (
{tone === "emerald" ? ( ) : tone === "amber" ? ( ) : ( )} {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 ?

{health.error}

: null} {health.kind === "usage_limit" || health.kind === "throttled" ? ( ) : null}
{health.mode === "direct-cloud" && health.needsKey ? (

{t("health.createKey.before")}{" "} ollama.com/settings/keys {" "} {t("health.createKey.after")} ollama signin {t("health.createKey.end")}{" "} OLLAMA_AUTH=local.

) : null}
); } function DetailRow({ label, value, mono, badge, }: { label: string; value: string; mono?: boolean; badge?: { text: string; className: string }; }) { return (
{label}
{value} {badge ? ( {badge.text} ) : null}
); } function Info({ label, value, mono }: { label: string; value: string; mono?: boolean }) { return (
{label}
{value}
); } 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 (

{title}

{badge.text}

{model.resolved}

{model.configured !== model.resolved ? (

{t("health.configuredAs", { model: model.configured })}

) : null} {model.note ?

{model.note}

: null}
); } /** Boolean toggle row used by the chat-grounding setting. */ function ToggleRow({ label, note, checked, onChange, }: { label: string; note: string; checked: boolean; onChange: () => void; }) { return (
{label} {note}
); }