"use client"; import { AnimatePresence, motion, useDragControls, useReducedMotion } from "framer-motion"; import { useEffect, useRef, useState } from "react"; 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, 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) { 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, 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(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"; 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 ? { duration: 0.34, ease: [0.22, 1, 0.36, 1] } : { 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. */}

{t("settings.title")}

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

{/* scrollable body: the safe area comes from pb-safe + a spacer */}
{/* --------------------------------------------- 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}
{/* ------------------------------------------------ install */} {installAvailable ? (
) : null} {error ? (
{error}
) : null} {loading ? (
{t("settings.checking")}
) : checkedAt ? (

{freshnessLabel(checkedAt, locale, t)}

) : null} {health ? : null} {/* 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.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 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}
); }