Mobile only (<=639px / coarse pointers); desktop keeps its behaviour. Foundation - lib/motion.ts owns the spring presets (snappy/gentle/sheet), durations, easings and variants (screens, tabs, wizard steps, list stagger). - Native-feel CSS: press feedback scales every button, card, chip, tab, row and summary to 0.96 with a spring-like cubic-bezier on transform (scoped to phones/coarse pointers), tap-highlight off, touch-action manipulation, chrome text non-selectable, reduced motion drops it. - App shell fades in once on launch; the study feed fades in on phones. Applied - Tab bar: sliding active pill (shared layoutId) + icon bounce. - Study Decks/Homework, language toggle, difficulty and size pickers: the same gliding highlight, gated so desktop keeps the old styling. - Route content enters direction-aware (deeper = from the right, back = from the left, same-depth = crossfade) via RouteTransition on the library/create/homework content columns; library tab switches crossfade; wizard steps slide forward/backward (reduced motion safe). - Library: cards fade up in a capped stagger on first load, glide with layout animations and shrink away on delete (AnimatePresence). - Study feed: correct feedback pulses, wrong shakes, the progress bar settles with a spring curve, score and XP count up on the results. - Settings sheet uses the sheet spring; the drawer now follows the finger and closes on release velocity, like the sheet. - Generation progress: the stage message fades/slides between stages; the icon keeps its gentle wobble; the feed fades in on hand-off. Skipped, on purpose: LazyMotion (the sliding highlights need layout animations = domMax, same features the current import already has); route exit animations (the App Router unmounts the outgoing page — exits are handled by in-page AnimatePresence); replacing the study feed's native scroll-snap with finger-follow drag (would fight the platform scroller); finger-follow height animation for native <details> expanders. Verified: animation suite 8/8 (reduced motion + 4x-CPU-throttled trace: no long tasks, CLS 0.0000), regression 74/74, mobile 52/52, declutter 116/116, i18n 25/25. Lint + build clean. SW freezmo-v21.
848 lines
40 KiB
TypeScript
848 lines
40 KiB
TypeScript
"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 { springSheet } from "@/lib/motion";
|
|
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,
|
|
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 }: { open: boolean; onClose: () => void }) {
|
|
const { health, loading, error, refresh, apiKey, checkedAt } = useHealth();
|
|
const installAvailable = useInstallAvailable();
|
|
const { locale, setLocale, t, plural } = useI18n();
|
|
const [keyDraft, setKeyDraft] = useState(apiKey);
|
|
const [reveal, setReveal] = useState(false);
|
|
const [saved, setSaved] = useState(false);
|
|
const [confirmRemove, setConfirmRemove] = useState(false);
|
|
const [canScroll, setCanScroll] = useState(false);
|
|
const [phone, setPhone] = useState(false);
|
|
const dragControls = useDragControls();
|
|
const reduceMotion = useReducedMotion();
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
const contentRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Swipe-to-dismiss is a phone gesture; the desktop dialog is unchanged.
|
|
useEffect(() => {
|
|
const query = window.matchMedia("(max-width: 639px)");
|
|
const sync = () => setPhone(query.matches);
|
|
sync();
|
|
query.addEventListener("change", sync);
|
|
return () => query.removeEventListener("change", sync);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
setKeyDraft(apiKey);
|
|
setSaved(false);
|
|
setConfirmRemove(false);
|
|
const onKey = (event: KeyboardEvent) => {
|
|
if (event.key === "Escape") onClose();
|
|
};
|
|
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={() => 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>
|
|
|
|
<div className="relative">
|
|
<label className="sr-only" htmlFor="ollama-api-key-phone">
|
|
{t("settings.apiKey")}
|
|
</label>
|
|
<input
|
|
id="ollama-api-key-phone"
|
|
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>
|
|
</section>
|
|
|
|
{/* ----------------------------------------------- install */}
|
|
{installAvailable ? (
|
|
<section className="mb-4 rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-1.5">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
onClose();
|
|
openInstallSheet();
|
|
}}
|
|
className="flex w-full items-center gap-3 rounded-xl px-2.5 py-2 text-left transition hover:bg-[var(--hover)]"
|
|
>
|
|
<span
|
|
aria-hidden
|
|
className="grid size-9 shrink-0 place-items-center rounded-xl border border-[var(--hairline)] bg-[var(--surface)] text-[var(--text-muted)]"
|
|
>
|
|
<DownloadIcon className="size-4" />
|
|
</span>
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block text-sm font-semibold text-frost-950 dark:text-frost-50">
|
|
{t("settings.installApp")}
|
|
</span>
|
|
<span className="block text-[12px] text-[var(--text-subtle)]">
|
|
{t("settings.addToHome")}
|
|
</span>
|
|
</span>
|
|
<ChevronRightIcon className="size-4 shrink-0 text-[var(--text-subtle)]" />
|
|
</button>
|
|
</section>
|
|
) : null}
|
|
|
|
{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"
|
|
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>
|
|
|
|
{/* ------------------------------------------------ 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}
|
|
</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>
|
|
);
|
|
}
|