freezmo/components/ApiKeyGate.tsx
Martin Dvorak 52cce42cde Frosted ice glass restyle
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.
2026-10-08 10:11:55 +00:00

457 lines
17 KiB
TypeScript

"use client";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
import { useTheme } from "@/components/ThemeToggle";
import {
CheckCircleIcon,
ExternalLinkIcon,
EyeIcon,
EyeOffIcon,
MoonIcon,
QuestionIcon,
SunIcon,
XIcon,
} from "@/components/icons";
import { Spinner } from "@/components/ui";
import { verifyOllamaKey } from "@/lib/client-api";
import { saveSettings, settingsStore } from "@/lib/settings";
import { useHealth } from "@/lib/use-health";
import { cn } from "@/lib/utils";
const KEYS_URL = "https://ollama.com/settings/keys";
type GateError = { tone: "invalid" | "offline"; message: string } | null;
/**
* Full-screen gate shown when this browser has no Ollama API key yet (and the
* server runs in `user-key` mode). It replaces the app shell entirely: no
* sidebar, no header. The key is validated with a real one-token probe before
* it is saved, and it is still stored only in this browser.
*/
export function ApiKeyGate({ children }: { children: React.ReactNode }) {
const storedKey = useSyncExternalStore(
settingsStore.subscribe,
() => settingsStore.getSnapshot().apiKey,
() => "",
);
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const { health } = useHealth();
// Server-key and local-daemon installs never need a browser key.
const needsKeyMode = health?.authMode !== "local" && health?.authMode !== "server";
// Until hydration finishes, render the page as usual so a key holder never
// sees the gate flash. After that, a missing key replaces the app.
if (!mounted || storedKey || !needsKeyMode) return <>{children}</>;
return <GateScreen />;
}
/** Ghost icon button used for the two top-right controls. */
function CornerButton({
label,
title,
onClick,
children,
}: {
label: string;
title: string;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
aria-label={label}
title={title}
className="grid size-10 place-items-center rounded-full border border-[var(--field-border)] bg-[var(--gate-card)] text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
>
{children}
</button>
);
}
function GateScreen() {
const reduceMotion = useReducedMotion();
const { theme, toggle } = useTheme();
const [draft, setDraft] = useState("");
const [reveal, setReveal] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<GateError>(null);
const [helpOpen, setHelpOpen] = useState(false);
const [keyboardOpen, setKeyboardOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const rootRef = useRef<HTMLDivElement>(null);
const wrapRef = useRef<HTMLDivElement>(null);
const submitRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
// Don't force the on-screen keyboard open on touch devices — let the user
// tap the field when they're ready.
if (window.matchMedia("(pointer: fine)").matches) inputRef.current?.focus();
}, []);
// Keep the field and the Continue button in view while the on-screen keyboard
// is up (the card itself is locked, so this is done by scrolling the wrapper
// programmatically rather than by the user).
const alignKeyboard = useCallback(() => {
const wrap = wrapRef.current;
const submit = submitRef.current;
if (!wrap || !submit) return;
const wrapBox = wrap.getBoundingClientRect();
const btnBox = submit.getBoundingClientRect();
const below = btnBox.bottom + 12 - wrapBox.bottom;
const above = wrapBox.top + 12 - btnBox.top;
if (below > 0) wrap.scrollTop += below;
else if (above > 0) wrap.scrollTop -= above;
}, []);
// Pin the gate to the *visual* viewport so the browser never has to pan the
// page to reveal the focused field. While the on-screen keyboard is up the
// card is locked (not user-scrollable).
useEffect(() => {
const vv = window.visualViewport;
const root = rootRef.current;
if (!vv || !root) return;
const baseline = window.innerHeight;
let frame = 0;
const sync = () => {
if (frame) cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
root.style.height = `${vv.height}px`;
root.style.top = `${vv.offsetTop}px`;
const open = baseline - vv.height > 120;
setKeyboardOpen(open);
if (open) alignKeyboard();
});
};
sync();
vv.addEventListener("resize", sync);
vv.addEventListener("scroll", sync);
return () => {
if (frame) cancelAnimationFrame(frame);
vv.removeEventListener("resize", sync);
vv.removeEventListener("scroll", sync);
};
}, [alignKeyboard]);
// Re-align when the card grows/shrinks (e.g. an error message appears) while
// the keyboard is open.
useEffect(() => {
if (!keyboardOpen) return;
const frame = requestAnimationFrame(alignKeyboard);
return () => cancelAnimationFrame(frame);
}, [keyboardOpen, error, busy, alignKeyboard]);
useEffect(() => {
if (!helpOpen) return;
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") setHelpOpen(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [helpOpen]);
const submit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const key = draft.trim();
if (!key || busy) return;
setBusy(true);
setError(null);
const check = await verifyOllamaKey(key);
if (check.result === "valid") {
// Saving flips the gate off and drops the user into the library.
saveSettings({ apiKey: key });
return;
}
setError({ tone: check.result === "invalid" ? "invalid" : "offline", message: check.message });
setBusy(false);
};
return (
<div
ref={rootRef}
className="fixed inset-0 h-dvh overflow-hidden overscroll-none"
>
{/* corner controls: theme + help ---------------------------------- */}
<div
className="absolute z-20 flex items-center gap-2"
style={{
top: "max(env(safe-area-inset-top), 1.25rem)",
right: "max(env(safe-area-inset-right), 1.25rem)",
}}
>
<CornerButton
label={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
title={theme === "dark" ? "Light mode" : "Dark mode"}
onClick={toggle}
>
{theme === "dark" ? <SunIcon className="size-[18px]" /> : <MoonIcon className="size-[18px]" />}
</CornerButton>
<div className="group relative">
<CornerButton
label="How do I get an Ollama API key?"
title="How do I get a key?"
onClick={() => setHelpOpen(true)}
>
<QuestionIcon className="size-[18px]" />
</CornerButton>
<span
role="tooltip"
className="pointer-events-none absolute right-0 top-full mt-2 whitespace-nowrap rounded-lg border border-[var(--hairline)] bg-[var(--gate-card)] px-2.5 py-1.5 text-[11.5px] text-[var(--text-muted)] opacity-0 shadow-[var(--shadow-card)] transition group-hover:opacity-100 group-focus-within:opacity-100"
>
How do I get a key?
</span>
</div>
</div>
{/* card ----------------------------------------------------------- */}
<div
ref={wrapRef}
className={cn(
"relative z-10 flex h-full justify-center px-5",
keyboardOpen
? "items-start touch-none overflow-hidden py-3"
: "items-center overflow-y-auto overscroll-contain py-10",
)}
>
<motion.div
initial={reduceMotion ? false : { opacity: 0, y: 14 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.45, ease: [0.22, 1, 0.36, 1] }}
className="gate-card my-auto w-full max-w-[420px] p-6 px-7 text-center sm:p-8"
>
{/* eslint-disable-next-line @next/next/no-img-element -- static app icon, no optimisation needed */}
<img
src="/icons/icon-192.png"
alt=""
aria-hidden
className="mx-auto size-14 rounded-[16px] shadow-[var(--shadow-card)]"
/>
<h1 className="mt-6 text-[24px] font-semibold leading-tight tracking-[-0.02em] text-[var(--text)] sm:text-[28px]">
Connect your Ollama key
</h1>
<p className="mx-auto mt-1.5 max-w-[300px] text-balance text-[15px] leading-relaxed text-[var(--text-subtle)]">
One step to freeze your first deck.
</p>
<form className="mt-6" onSubmit={submit} noValidate>
<div className="relative">
<label className="sr-only" htmlFor="gate-api-key">
Ollama API key
</label>
<input
ref={inputRef}
id="gate-api-key"
type={reveal ? "text" : "password"}
value={draft}
onChange={(event) => {
setDraft(event.target.value);
if (error) setError(null);
}}
placeholder="Paste your Ollama API key"
autoComplete="off"
autoCapitalize="off"
spellCheck={false}
disabled={busy}
aria-invalid={error?.tone === "invalid" || undefined}
aria-describedby={error ? "gate-api-key-error" : undefined}
className="h-12 w-full rounded-[13px] border border-[var(--field-border)] bg-[var(--field)] pl-3.5 pr-12 text-left text-[14px] text-[var(--text)] outline-none transition placeholder:text-[var(--text-subtle)] focus:border-[var(--text-subtle)] disabled:opacity-60"
/>
<button
type="button"
onClick={() => setReveal((value) => !value)}
aria-label={reveal ? "Hide API key" : "Show API key"}
aria-pressed={reveal}
className="absolute right-1.5 top-1/2 grid size-9 -translate-y-1/2 place-items-center rounded-[10px] text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
>
{reveal ? <EyeOffIcon className="size-[18px]" /> : <EyeIcon className="size-[18px]" />}
</button>
</div>
{error ? (
<p
id="gate-api-key-error"
role="alert"
className={cn(
"mt-2.5 text-[12.5px] leading-relaxed",
error.tone === "invalid"
? "text-rose-700 dark:text-rose-300"
: "text-amber-700 dark:text-amber-300",
)}
>
{error.message}
</p>
) : null}
<button
type="submit"
ref={submitRef}
disabled={!draft.trim() || busy}
className={cn(
"btn-primary mt-3 h-12 w-full rounded-[13px] text-[14.5px] font-medium",
!draft.trim() && "cursor-not-allowed opacity-45",
)}
>
{busy ? (
<>
<Spinner className="size-4" /> Checking…
</>
) : (
"Continue"
)}
</button>
</form>
<div className="mt-4 text-center">
<button
type="button"
onClick={() => setHelpOpen(true)}
className="inline-flex items-center gap-1.5 text-[12.5px] font-medium text-[var(--text-muted)] transition hover:text-[var(--text)] hover:underline"
>
<QuestionIcon className="size-3.5" />
How do I get a key?
</button>
</div>
<p className="mx-auto mt-4 max-w-[320px] text-balance text-[12px] leading-relaxed text-[var(--text-subtle)]">
Your key stays in this browser. Our server never stores it.
</p>
</motion.div>
</div>
<HelpDialog open={helpOpen} onClose={() => setHelpOpen(false)} />
</div>
);
}
/* ------------------------------------------------------------------ */
/* “How do I get a key?” */
/* ------------------------------------------------------------------ */
function HelpDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
const reduceMotion = useReducedMotion();
return (
<AnimatePresence>
{open ? (
<motion.div
className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 backdrop-blur-sm 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="gate-help-title"
initial={reduceMotion ? false : { y: 40, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={reduceMotion ? { opacity: 0 } : { y: 24, opacity: 0 }}
transition={{ type: "spring", stiffness: 340, damping: 32 }}
onClick={(event) => event.stopPropagation()}
className="gate-card thin-scroll max-h-[90dvh] w-full max-w-[480px] overflow-y-auto p-6 pb-safe sm:pb-6"
>
<div className="flex items-center justify-between gap-4">
<h2
id="gate-help-title"
className="text-[19px] font-semibold leading-snug tracking-[-0.02em] text-[var(--text)]"
>
How to get your Ollama API key
</h2>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="grid size-9 shrink-0 place-items-center rounded-[10px] text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
>
<XIcon className="size-4" />
</button>
</div>
<div className="mt-4 flex items-start gap-2.5 rounded-xl border border-[var(--gate-callout-border)] bg-[var(--gate-callout-bg)] px-3.5 py-3">
<CheckCircleIcon className="mt-px size-[18px] shrink-0 text-[var(--text-muted)]" />
<p className="text-[12.5px] leading-relaxed text-[var(--text-muted)]">
You only need the{" "}
<strong className="font-semibold text-[var(--text)]">free cloud tier</strong>. No
payment and no credit card required.
</p>
</div>
<ol className="mt-5 space-y-4">
<Step index={1}>
Go to{" "}
<ExternalLink href="https://ollama.com">ollama.com</ExternalLink> and sign in, or
create a free account.
</Step>
<Step index={2}>
Open Settings → API keys at{" "}
<ExternalLink href={KEYS_URL}>ollama.com/settings/keys</ExternalLink>.
</Step>
<Step index={3}>
Click{" "}
<span className="font-medium text-[var(--text-muted)]">&ldquo;Create new key&rdquo;</span>,
give it any name, and copy it.
</Step>
<Step index={4}>Paste it into Freezmo and press Continue.</Step>
</ol>
<p className="mt-5 text-[12px] leading-relaxed text-[var(--text-subtle)]">
The free tier is enough for generating decks. You can swap or remove your key anytime in
Settings.
</p>
<button
type="button"
onClick={onClose}
className="btn-primary mt-5 h-11 w-full rounded-[13px] text-[14px] font-medium"
>
Got it
</button>
</motion.div>
</motion.div>
) : null}
</AnimatePresence>
);
}
function ExternalLink({ href, children }: { href: string; children: React.ReactNode }) {
return (
<a
href={href}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 font-medium text-[var(--text)] underline underline-offset-2"
>
{children}
<ExternalLinkIcon className="size-3.5 opacity-60" />
</a>
);
}
function Step({ index, children }: { index: number; children: React.ReactNode }) {
return (
<li className="flex items-start gap-3">
<span
aria-hidden
className="grid size-6 shrink-0 place-items-center rounded-full border border-[var(--hairline)] text-[11.5px] font-medium tabular-nums text-[var(--text-subtle)]"
>
{index}
</span>
<span className="text-pretty text-[13px] leading-6 text-[var(--text-muted)]">{children}</span>
</li>
);
}