Rebuilds the look around the spec: an ice scene, one shared glass recipe, icy accents and glass on every large surface. Visual only — layout, behaviour, copy and functionality are untouched. Background - .ice-scene: fixed, z-index -1, pointer-events none. Navy base (#070b16 to #0c1426) in dark, pale (#f4f9ff to #e6f1fb) in light, four drifting glacier blobs (#38bdf8/#60a5fa/#818cf8, 30-60s transform-only loops), five glossy ice orbs (40-220px radial gradients with a highlight at 30% 25% and a blue shadow) placed so the glass blurs them, and an SVG feTurbulence grain at 4-5%. - prefers-reduced-motion stops the drift; phones get two blobs and two orbs fewer. Glass - One shared recipe (.glass-panel, .card, .sidebar-surface, .study-chrome, .tabbar, .glass, .gate-card): rgba(255,255,255,.06) dark / .55 light, blur(24px) saturate(140%) (16px on phones), a bright 1px rim, an inset top highlight and a blue-tinted outer shadow. - Nested glass (chips, inputs, secondary buttons) is more opaque and never adds a second blur layer. @supports fallback for no backdrop-filter. Radii: 24-28px cards/sheets, 14px inputs/buttons, pills for chips. Cards hover brighter + cyan rim + 2px lift, 180ms. Colour and type - Accent is glacier cyan (#38bdf8) to ice blue (#0ea5e9); progress bars and selected states use the gradient; the active sidebar item is a frosted highlight with a cyan rim. - Primary CTA: icy gradient — white to #bfe3ff with ink text in dark, deep blue in light (biased so the label sits on the darker half and keeps 5.6:1). - Leftover slate/black greys replaced with navy-tinted frost tokens. - Headings are semibold with tighter tracking. Screens - Dashboard: the sidebar is one tall inset rounded glass panel; deck cards are glass. - Gate: one centred glass card over the scene, orbs visible behind it. - Sheets/modals: glass over the dimmed, lightly blurred backdrop. - Study view: question card more opaque (.glass-solid); top bar and pager are slim glass pills. Perf: measured on a phone viewport with 4x CPU throttling. The animated blobs' filter: blur() was the whole cost (150ms vs 33ms per frame), so they are soft radial gradients with no filter now; idle 60fps, ~17ms median while scrolling. Tests updated for the new tokens: contrast 18/18 AA, deck chrome 15/15, key gate 49/49, install sheet 64/64, deck fixes 15/15, retry 14/14, zoom 6/6, final ux 12/12, security 8/8, pwa 16/16.
321 lines
14 KiB
TypeScript
321 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import { AnimatePresence, motion } from "framer-motion";
|
|
import { useEffect, useState } from "react";
|
|
|
|
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, ChevronRightIcon, DownloadIcon, XCircleIcon } from "@/components/icons";
|
|
import { Button, Spinner } from "@/components/ui";
|
|
|
|
export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
|
|
const { health, loading, error, refresh, apiKey } = useHealth();
|
|
const installAvailable = useInstallAvailable();
|
|
const [keyDraft, setKeyDraft] = useState(apiKey);
|
|
const [reveal, setReveal] = useState(false);
|
|
const [saved, setSaved] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
setKeyDraft(apiKey);
|
|
setSaved(false);
|
|
const onKey = (event: KeyboardEvent) => {
|
|
if (event.key === "Escape") onClose();
|
|
};
|
|
window.addEventListener("keydown", onKey);
|
|
return () => window.removeEventListener("keydown", onKey);
|
|
}, [open, apiKey, onClose]);
|
|
|
|
const save = () => {
|
|
saveSettings({ apiKey: keyDraft.trim() });
|
|
setSaved(true);
|
|
window.setTimeout(() => setSaved(false), 2500);
|
|
};
|
|
|
|
const forget = () => {
|
|
clearApiKey();
|
|
setKeyDraft("");
|
|
};
|
|
|
|
const needsKeyMode = health?.authMode === "user-key";
|
|
|
|
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"
|
|
initial={{ y: 48, opacity: 0 }}
|
|
animate={{ y: 0, opacity: 1 }}
|
|
exit={{ y: 32, opacity: 0 }}
|
|
transition={{ type: "spring", stiffness: 340, damping: 32 }}
|
|
onClick={(event) => event.stopPropagation()}
|
|
className="glass-panel thin-scroll max-h-[90dvh] w-full max-w-lg overflow-y-auto rounded-t-[1.6rem] p-5 pb-safe shadow-2xl sm:rounded-[1.35rem] sm:pb-5"
|
|
>
|
|
<div className="sheet-grabber sm:hidden" aria-hidden />
|
|
<div className="mb-4 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">
|
|
Settings
|
|
</h2>
|
|
<p className="text-sm text-frost-600 dark:text-frost-300">
|
|
Models come from <code className="rounded bg-frost-100 px-1 dark:bg-white/10">.env.local</code> on
|
|
the server; your API key stays in this browser.
|
|
</p>
|
|
</div>
|
|
<Button variant="ghost" size="sm" onClick={onClose} aria-label="Close settings">
|
|
✕
|
|
</Button>
|
|
</div>
|
|
|
|
{/* ------------------------------------------------ API key */}
|
|
<section className="mb-4 space-y-2 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">Ollama API key</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">
|
|
saved in this browser
|
|
</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">
|
|
required
|
|
</span>
|
|
) : (
|
|
<span className="rounded-full bg-[var(--surface-sunken)] px-2 py-0.5 text-[11px] font-semibold text-frost-600 dark:text-frost-300">
|
|
not needed
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<label className="sr-only" htmlFor="ollama-api-key">
|
|
Ollama API key
|
|
</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="paste your key from ollama.com/settings/keys"
|
|
autoComplete="off"
|
|
spellCheck={false}
|
|
className="min-w-40 flex-1 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-frost-400 focus:border-accent-500 dark:text-frost-50"
|
|
/>
|
|
<Button size="sm" variant="secondary" onClick={() => setReveal((value) => !value)}>
|
|
{reveal ? "Hide" : "Show"}
|
|
</Button>
|
|
<Button size="sm" onClick={save} disabled={keyDraft.trim() === apiKey}>
|
|
Save
|
|
</Button>
|
|
</div>
|
|
|
|
<p className="text-[11px] leading-relaxed text-frost-500 dark:text-frost-400">
|
|
Stored only in this browser's local storage and sent with each request so the server can talk to
|
|
Ollama for you. It is never written to the server, never logged and never put in a cookie. Prefer the
|
|
local daemon? Set <code>OLLAMA_AUTH=local</code> in <code>.env.local</code> and use{" "}
|
|
<code>ollama signin</code> instead.
|
|
</p>
|
|
|
|
<div className="flex flex-wrap items-center gap-2 pt-1">
|
|
<Button size="sm" variant="secondary" onClick={() => void refresh()} loading={loading}>
|
|
Test connection
|
|
</Button>
|
|
{apiKey ? (
|
|
<Button size="sm" variant="ghost" onClick={forget}>
|
|
Remove key from this browser
|
|
</Button>
|
|
) : null}
|
|
{saved ? (
|
|
<span className="text-xs font-semibold text-emerald-700 dark:text-emerald-300">
|
|
Saved — testing…
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
</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">
|
|
Install app
|
|
</span>
|
|
<span className="block text-[12px] text-[var(--text-subtle)]">
|
|
Add Freezmo to your home screen
|
|
</span>
|
|
</span>
|
|
<ChevronRightIcon className="size-4 shrink-0 text-[var(--text-subtle)]" />
|
|
</button>
|
|
</section>
|
|
) : null}
|
|
|
|
{loading && !health ? (
|
|
<div className="flex items-center gap-3 py-6 text-sm text-frost-600 dark:text-frost-300">
|
|
<Spinner className="size-5" /> Checking Ollama…
|
|
</div>
|
|
) : 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}
|
|
|
|
{health ? <HealthPanel health={health} /> : null}
|
|
</motion.div>
|
|
</motion.div>
|
|
) : null}
|
|
</AnimatePresence>
|
|
);
|
|
}
|
|
|
|
function HealthPanel({ health }: { health: ReturnType<typeof useHealth>["health"] }) {
|
|
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
|
|
? "Ollama is reachable and both models are available."
|
|
: health.needsKey
|
|
? "Waiting for your API key."
|
|
: health.keyAccepted === false
|
|
? "Ollama rejected your API key."
|
|
: health.reachable
|
|
? "Ollama is reachable, but a configured model is missing."
|
|
: "Ollama isn't reachable."}
|
|
{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="Auth"
|
|
value={
|
|
health.authMode === "user-key"
|
|
? "Your own key"
|
|
: health.authMode === "server"
|
|
? "Server key (.env)"
|
|
: "Local daemon sign-in"
|
|
}
|
|
/>
|
|
<Info label="Endpoint" value={health.baseUrl} mono />
|
|
<Info label="Mode" value={health.mode === "direct-cloud" ? "Direct cloud API" : "Local daemon"} />
|
|
<Info
|
|
label="Key"
|
|
value={
|
|
health.needsKey
|
|
? "missing"
|
|
: health.keyAccepted === true
|
|
? "accepted"
|
|
: health.keyAccepted === false
|
|
? "rejected"
|
|
: health.authMode === "local"
|
|
? "not needed"
|
|
: "unknown"
|
|
}
|
|
/>
|
|
</dl>
|
|
|
|
<div className="space-y-2">
|
|
<ModelRow title="Vision model (reads photos)" model={health.vision} />
|
|
<ModelRow title="Text model (builds & grades)" model={health.text} />
|
|
</div>
|
|
|
|
{health.mode === "direct-cloud" && health.needsKey ? (
|
|
<p className="rounded-2xl bg-[var(--surface-sunken)] p-3 text-xs text-frost-700 dark:text-frost-200">
|
|
Create a key at{" "}
|
|
<a className="font-semibold underline underline-offset-2" href="https://ollama.com/settings/keys" target="_blank" rel="noreferrer">
|
|
ollama.com/settings/keys
|
|
</a>{" "}
|
|
and paste it above. Tip: with the local daemon you can run <code>ollama signin</code> instead and set{" "}
|
|
<code>OLLAMA_AUTH=local</code>.
|
|
</p>
|
|
) : null}
|
|
</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-frost-500 dark:text-frost-400">{label}</dt>
|
|
<dd className={cn("mt-0.5 truncate 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 badge =
|
|
model.available === true
|
|
? { text: "available", className: "bg-emerald-100 text-emerald-900 dark:bg-emerald-400/20 dark:text-emerald-200" }
|
|
: model.available === false
|
|
? { text: "not found", className: "bg-rose-50 text-rose-800 dark:bg-rose-500/15 dark:text-rose-200" }
|
|
: { text: "unknown", className: "bg-[var(--surface-sunken)] text-frost-600 dark:text-frost-300" };
|
|
|
|
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-frost-500 dark:text-frost-400">{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-frost-500 dark:text-frost-400">configured as {model.configured}</p>
|
|
) : null}
|
|
{model.note ? <p className="mt-1 text-xs text-amber-700 dark:text-amber-300">{model.note}</p> : null}
|
|
</div>
|
|
);
|
|
}
|