Add full-screen API-key gate

In user-key mode a browser with no stored Ollama key now gets a polished
gate instead of the app shell: one masked input with a show/hide toggle,
a single Continue button, inline validation errors, and a "?" help sheet
with the steps to a free key.

- Validates the draft key through /api/health (real 1-token probe) before
  saving; invalid-key and network failures read differently.
- Key still lives only in this browser's localStorage; nothing server-side.
- Gate wraps /, /create and deck study; server-key and local-daemon modes
  skip it; removing the key in Settings brings it back.
- Removes the now-redundant dashboard/create API-key banner.
- Monochrome corner glows (dark near-black / light near-white), 24px card,
  48px field and button, gentle fade/slide-in, reduced-motion aware.
- Help modal: centred dialog on desktop, bottom sheet on mobile, closes via
  X, Escape or backdrop.
- README and browser suites updated (34 gate checks, 15 key-flow checks,
  12 shipped-default checks; ui/folders/docs/pwa/security/contrast green).
This commit is contained in:
Martin Dvořák 2026-10-06 21:20:22 +00:00
commit dfa7b7502a
13 changed files with 505 additions and 94 deletions

View file

@ -84,6 +84,14 @@ no database, no data leaving your machine except the requests you send to Ollama
| `ordering` | Drag with the mouse/finger, or use ▲▼ buttons, then check |
| `short_answer` | Typed answer graded by the AI via `/api/grade`, with a self-check fallback |
**First run**
- A full-screen **API-key gate** replaces the app until a key is saved (in `user-key` mode): one
masked input with a show/hide toggle, a single **Continue**, and a "?" sheet with the four steps
to a free key. The key is validated with a real 1-token probe before it is accepted — invalid
keys and network trouble get different inline messages. Removing the key in Settings brings the
gate back; `server` and `local` installs skip it entirely.
**Library & folders**
- Decks saved locally with title, subject, folder, activity count, date, play count and best score
@ -144,7 +152,7 @@ build too.
Open <http://localhost:3207>. The app runs without any Ollama configuration — you'll just need a
key (or a signed-in daemon) before it can generate a deck. Complete one of the Ollama setups below,
then paste the key in **Settings**.
then paste the key on the gate (or later in **Settings**).
`npm run dev` and `npm run build` also copy the pdf.js worker into `public/` automatically (see
`scripts/copy-pdf-worker.mjs`), which is what lets the browser parse PDFs.
@ -172,7 +180,7 @@ Who provides the API key is controlled by **`OLLAMA_AUTH`**:
| `OLLAMA_AUTH` | Key comes from | Use it when |
| --- | --- | --- |
| **`user-key`** (default) | each browser, pasted in the settings dialog | several people/devices share the app, or you don't want a key on the server |
| **`user-key`** (default) | each browser, pasted on the key gate (or in Settings) | several people/devices share the app, or you don't want a key on the server |
| `server` | `OLLAMA_API_KEY` in `.env.local` | a single-user install |
| `local` | nothing — a daemon signed in with `ollama signin` | you run Ollama locally |
@ -185,9 +193,10 @@ VISION_MODEL=gemma4:31b-cloud
TEXT_MODEL=gpt-oss:120b-cloud
```
1. Open the app → a banner asks for a key → click **Add API key** (or the ⚙️ button).
2. Create a key at <https://ollama.com/settings/keys> and paste it in.
3. **Test connection** shows whether Ollama accepted it, and which models are available.
1. Open the app — the **key gate** asks for a key on first visit.
2. Create one at <https://ollama.com/settings/keys>, paste it in and press **Continue**.
3. The gate validates it with a real 1-token request, then drops you into the icebox.
**Settings → Test connection** re-checks any time and shows the model list.
Where the key lives:
@ -530,7 +539,7 @@ components/
activities/ one component per activity type + shared shell
Flashcard.tsx MultipleChoice.tsx TrueFalse.tsx FillBlank.tsx
Matching.tsx Ordering.tsx ShortAnswer.tsx shared.tsx index.tsx
UploadScreen.tsx StudyFeed.tsx ResultsScreen.tsx DeckCard.tsx ApiKeyBanner.tsx
UploadScreen.tsx StudyFeed.tsx ResultsScreen.tsx DeckCard.tsx ApiKeyGate.tsx
TabBar.tsx InstallHint.tsx ServiceWorkerRegistrar.tsx FolderBar.tsx
LibraryScreen.tsx SettingsDialog.tsx AppHeader.tsx ThemeToggle.tsx icons.tsx ui.tsx
lib/
@ -627,7 +636,7 @@ regeneration and “add more”.
| A document card is stuck on “Reading page …” | The pdf.js worker may be missing: run `npm run dev`/`npm run build` (they copy it to `public/pdf.worker.min.mjs`) or `node scripts/copy-pdf-worker.mjs` manually. |
| PDF uploaded but no activities about its content | The PDF is probably image-only (a scan). Freezmo renders such pages and sends them to the vision model — check the card note (“N scanned pages were turned into images”); if it says “No readable text”, the scan may be too low quality. |
| Deck came back in English even though the notes are not | Make sure both model calls succeed (a failed vision step falls back to raw text). The prompts force the notes' language; if you still see translation, switch `TEXT_MODEL` to a stronger instruction-follower such as `gpt-oss:120b-cloud`. |
| “Add your Ollama API key first” | Bring-your-own-key mode is active. Paste a key from <https://ollama.com/settings/keys> in the ⚙️ dialog — it is stored only in your browser. |
| “Add your Ollama API key first” | Bring-your-own-key mode is active. Paste a key from <https://ollama.com/settings/keys> on the key gate (or in the ⚙️ dialog) — it is stored only in your browser. |
| “Ollama rejected this API key” | The key is invalid or revoked. Create a new one and paste it again; the check uses a real 1-token request, so a green tick means it truly works. |
| “this model is not included in your free usage” | The key works, but the model is outside your plan. Use one of the ✅ models in the table above, or add credits at <https://ollama.com/settings>. |
| “Too many requests — try again in Ns” | You hit a rate limit (12 decks / 40 grades per 5 min by default). Wait, or raise `RATE_LIMIT_*` in `.env.local`. |
@ -745,6 +754,12 @@ Things I chose while building, with the reasoning:
density) flags a mismatch, the text model rewrites the JSON back into the original language
before generation. Verified both ways: it fires on a synthetic English-but-declared-Czech
transcription and never fires on real Czech notes (no extra calls, no added latency).
28. **The first screen is the API-key gate.** In `user-key` mode a browser without a key gets a
full-screen, monochrome gate instead of the dashboard: one masked input, one Continue button,
and a "?" sheet with the steps to a free key. The gate reuses the cached 1-token probe through
`/api/health`, so an invalid key and an unreachable Ollama are worded differently. The old
dashboard banner is gone; key management stays in Settings, and removing the key brings the
gate back. The gate covers `/`, `/create` and deck study, but never `/offline`.
14. **`gpt-oss:120b` does all content work — but it cannot read images** (`this model does not
support image input`), so `VISION_MODEL` has to stay a multimodal model (`gemma4:31b-cloud`).
Text-only inputs — pasted text, PDFs with a text layer, DOCX/PPTX/ODT — never touch the vision

View file

@ -1,3 +1,4 @@
import { ApiKeyGate } from "@/components/ApiKeyGate";
import { AppHeader } from "@/components/AppHeader";
import { MobileSidebar, Sidebar, SidebarOffset } from "@/components/Sidebar";
import { TabBar } from "@/components/TabBar";
@ -9,17 +10,19 @@ export const metadata = {
export default function CreatePage() {
return (
<div className="flex min-h-dvh bg-[var(--page)]">
<Sidebar />
<SidebarOffset />
<MobileSidebar />
<main className="min-w-0 flex-1">
<AppHeader />
<div className="mx-auto w-full max-w-3xl px-4 pb-cta lg:px-10 lg:pb-20">
<UploadScreen />
</div>
<TabBar />
</main>
</div>
<ApiKeyGate>
<div className="flex min-h-dvh bg-[var(--page)]">
<Sidebar />
<SidebarOffset />
<MobileSidebar />
<main className="min-w-0 flex-1">
<AppHeader />
<div className="mx-auto w-full max-w-3xl px-4 pb-cta lg:px-10 lg:pb-20">
<UploadScreen />
</div>
<TabBar />
</main>
</div>
</ApiKeyGate>
);
}

View file

@ -4,6 +4,7 @@ import Link from "next/link";
import { useParams } from "next/navigation";
import { useEffect, useState } from "react";
import { ApiKeyGate } from "@/components/ApiKeyGate";
import { StudyFeed } from "@/components/StudyFeed";
import { Button, Spinner } from "@/components/ui";
import type { Deck } from "@/lib/schemas";
@ -62,5 +63,9 @@ export default function DeckPage() {
);
}
return <StudyFeed key={deck.id} deck={deck} />;
return (
<ApiKeyGate>
<StudyFeed key={deck.id} deck={deck} />
</ApiKeyGate>
);
}

View file

@ -102,6 +102,13 @@
--sidebar-top: #fbfbfc;
--sidebar-bottom: #f6f6f7;
--sidebar-edge: 0 1px 0 var(--hairline), 6px 0 16px -14px rgb(13 13 13 / 0.28);
/* API-key gate: near-white stage with soft blank glows, tinted input. */
--gate-base: #f7f7f8;
--gate-glow-a: rgb(64 74 90 / 0.1);
--gate-glow-b: rgb(96 108 124 / 0.07);
--field: #f3f3f4;
--field-border: #e2e2e4;
--shadow-gate: 0 28px 70px -36px rgb(13 13 13 / 0.35), 0 1px 2px rgb(13 13 13 / 0.05);
--shadow-card: 0 1px 2px rgb(13 13 13 / 0.06), 0 1px 3px rgb(13 13 13 / 0.04);
--shadow-card-hover: 0 10px 28px -14px rgb(13 13 13 / 0.22), 0 1px 2px rgb(13 13 13 / 0.06);
}
@ -123,6 +130,13 @@
--sidebar-top: #1a1a1a;
--sidebar-bottom: #151515;
--sidebar-edge: 0 1px 0 var(--hairline), 6px 0 16px -14px rgb(0 0 0 / 0.75);
/* API-key gate: near-black stage, monochrome glows, raised input. */
--gate-base: #0d0d0e;
--gate-glow-a: rgb(255 255 255 / 0.075);
--gate-glow-b: rgb(255 255 255 / 0.05);
--field: #383838;
--field-border: rgb(255 255 255 / 0.12);
--shadow-gate: 0 30px 80px -40px rgb(0 0 0 / 0.9), 0 1px 2px rgb(0 0 0 / 0.4);
--shadow-card: 0 1px 2px rgb(0 0 0 / 0.25);
--shadow-card-hover: 0 10px 28px -16px rgb(0 0 0 / 0.55), 0 1px 2px rgb(0 0 0 / 0.3);
}
@ -172,6 +186,16 @@ body {
background: transparent;
}
/* API-key gate stage: monochrome radial glows in opposite corners over a
near-black (dark) or near-white (light) base. Radial gradients are the
softest "blurred glow" available without extra layers. */
.gate-bg {
background:
radial-gradient(64rem 46rem at 112% -12%, var(--gate-glow-a), transparent 68%),
radial-gradient(58rem 42rem at -12% 112%, var(--gate-glow-b), transparent 70%),
var(--gate-base);
}
.app-backdrop {
position: fixed;
inset: 0;

View file

@ -1,3 +1,4 @@
import { ApiKeyGate } from "@/components/ApiKeyGate";
import { AppHeader } from "@/components/AppHeader";
import { InstallHint } from "@/components/InstallHint";
import { LibraryScreen } from "@/components/LibraryScreen";
@ -6,20 +7,22 @@ import { TabBar } from "@/components/TabBar";
export default function HomePage() {
return (
<div className="flex min-h-dvh bg-[var(--page)]">
<Sidebar />
<SidebarOffset />
<MobileSidebar />
<main className="min-w-0 flex-1">
<AppHeader />
<div className="mx-auto w-full max-w-6xl px-4 pb-tabbar lg:px-10 lg:pb-16">
<div className="mb-6">
<InstallHint />
<ApiKeyGate>
<div className="flex min-h-dvh bg-[var(--page)]">
<Sidebar />
<SidebarOffset />
<MobileSidebar />
<main className="min-w-0 flex-1">
<AppHeader />
<div className="mx-auto w-full max-w-6xl px-4 pb-tabbar lg:px-10 lg:pb-16">
<div className="mb-6">
<InstallHint />
</div>
<LibraryScreen />
</div>
<LibraryScreen />
</div>
<TabBar />
</main>
</div>
<TabBar />
</main>
</div>
</ApiKeyGate>
);
}

View file

@ -1,53 +0,0 @@
"use client";
import { useHealth } from "@/lib/use-health";
import { openSettings } from "@/lib/ui-events";
import { Button } from "@/components/ui";
/**
* Tells the user when they need to add (or fix) their own Ollama API key.
* Renders nothing in local-daemon / server-key setups.
*/
export function ApiKeyBanner() {
const { health, loading } = useHealth();
if (!health || loading) return null;
if (health.authMode !== "user-key") return null;
const rejected = health.keyAccepted === false;
if (!health.needsKey && !rejected) return null;
return (
<div
role="status"
className={
rejected
? "glass-panel animate-rise flex flex-wrap items-center gap-3 rounded-[1.35rem] p-4 text-sm text-rose-900 dark:text-rose-100"
: "glass-panel animate-rise flex flex-wrap items-center gap-3 rounded-[1.35rem] p-4 text-sm text-frost-900 dark:text-frost-50"
}
>
<span aria-hidden className="text-xl">
🔑
</span>
<p className="min-w-48 flex-1">
<span className="font-bold">
{rejected ? "Your Ollama API key was rejected." : "Add your Ollama API key to generate decks."}
</span>{" "}
<span className="font-normal">
Create one 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 in settings. It stays in this browser — the server never stores it.
</span>
</p>
<Button size="sm" onClick={openSettings}>
Add API key
</Button>
</div>
);
}

321
components/ApiKeyGate.tsx Normal file
View file

@ -0,0 +1,321 @@
"use client";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import { EyeIcon, EyeOffIcon, QuestionIcon, SnowflakeIcon, 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 />;
}
function GateScreen() {
const reduceMotion = useReducedMotion();
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 inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
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 className="gate-bg relative min-h-dvh w-full overflow-hidden">
{/* help -------------------------------------------------------- */}
<div
className="group fixed z-20"
style={{
top: "max(env(safe-area-inset-top), 1rem)",
right: "max(env(safe-area-inset-right), 1rem)",
}}
>
<button
type="button"
onClick={() => setHelpOpen(true)}
aria-label="How do I get an Ollama API key?"
title="How do I get a key?"
className="grid size-8 place-items-center rounded-full border border-[var(--hairline)] bg-[var(--surface)] text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
>
<QuestionIcon className="size-4" />
</button>
<span
role="tooltip"
className="pointer-events-none absolute right-0 top-full mt-2 whitespace-nowrap rounded-lg border border-[var(--hairline)] bg-[var(--surface)] 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>
{/* card -------------------------------------------------------- */}
<div className="relative z-10 flex min-h-dvh items-center justify-center px-5 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="w-full max-w-[420px] rounded-[24px] border border-[var(--hairline)] bg-[var(--surface)] p-6 shadow-[var(--shadow-gate)] sm:p-8"
>
<span
aria-hidden
className="grid size-14 place-items-center rounded-[16px] bg-[var(--text)] text-[var(--page)]"
>
<SnowflakeIcon className="size-7" />
</span>
<h1 className="mt-6 text-[26px] font-medium leading-tight tracking-[-0.01em] text-[var(--text)] sm:text-[28px]">
Connect your Ollama key
</h1>
<p className="mt-1.5 text-[13.5px] leading-relaxed text-[var(--text-subtle)]">
One step and you&rsquo;re ready 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-[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"
disabled={!draft.trim() || busy}
className="btn-primary mt-3.5 h-12 w-full rounded-[13px] text-[14.5px] font-medium disabled:cursor-not-allowed disabled:opacity-40"
>
{busy ? (
<>
<Spinner className="size-4" /> Checking…
</>
) : (
"Continue"
)}
</button>
</form>
<p className="mt-4 text-[12px] leading-relaxed text-[var(--text-subtle)]">
Your key stays in this browser. Freezmo&rsquo;s 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="thin-scroll max-h-[90dvh] w-full max-w-md overflow-y-auto rounded-t-[24px] border border-[var(--hairline)] bg-[var(--surface)] p-6 pb-safe shadow-[var(--shadow-gate)] sm:rounded-[24px] sm:pb-6"
>
<div className="sheet-grabber sm:hidden" aria-hidden />
<div className="flex items-start justify-between gap-4">
<h2
id="gate-help-title"
className="text-[19px] font-medium leading-snug tracking-[-0.01em] text-[var(--text)]"
>
How to get your Ollama API key
</h2>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="grid size-8 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>
<p className="mt-4 rounded-xl border border-[var(--hairline)] bg-[var(--surface-sunken)] px-3.5 py-3 text-[12.5px] leading-relaxed text-[var(--text-muted)]">
You only need the free cloud tier. No payment and no credit card required.
</p>
<ol className="mt-5 space-y-4">
<Step index={1}>
Go to{" "}
<a
href="https://ollama.com"
target="_blank"
rel="noreferrer"
className="font-medium text-[var(--text)] underline underline-offset-2"
>
ollama.com
</a>{" "}
and sign in, or create a free account.
</Step>
<Step index={2}>
Open <span className="text-[var(--text)]">Settings → API keys</span> at{" "}
<a
href={KEYS_URL}
target="_blank"
rel="noreferrer"
className="font-medium text-[var(--text)] underline underline-offset-2"
>
ollama.com/settings/keys
</a>
.
</Step>
<Step index={3}>
Click <span className="text-[var(--text)]">&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 Step({ index, children }: { index: number; children: React.ReactNode }) {
return (
<li className="flex gap-3">
<span
aria-hidden
className="mt-0.5 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-[13px] leading-relaxed text-[var(--text-muted)]">{children}</span>
</li>
);
}

View file

@ -3,7 +3,6 @@
import Link from "next/link";
import { useMemo, useRef, useState, useSyncExternalStore } from "react";
import { ApiKeyBanner } from "@/components/ApiKeyBanner";
import { DeckCard } from "@/components/DeckCard";
import { FolderBar } from "@/components/FolderBar";
import { FolderIcon, UploadIcon } from "@/components/icons";
@ -168,7 +167,6 @@ export function LibraryScreen() {
}}
/>
<ApiKeyBanner />
{message ? (
<p

View file

@ -31,7 +31,6 @@ import { UNORGANIZED_ID, type Difficulty } from "@/lib/schemas";
import { upsertDeck } from "@/lib/storage";
import { openSettings } from "@/lib/ui-events";
import { cn, uid } from "@/lib/utils";
import { ApiKeyBanner } from "@/components/ApiKeyBanner";
import { CameraIcon, FolderIcon, IceCubeIcon, PlusIcon, SnowflakeIcon, SunIcon } from "@/components/icons";
import { Button, ProgressBar } from "@/components/ui";
@ -325,7 +324,6 @@ export function UploadScreen() {
</p>
</section>
<ApiKeyBanner />
{error ? (
<div

View file

@ -237,3 +237,41 @@ export function RefreshIcon({ className }: IconProps) {
</Svg>
);
}
export function EyeIcon({ className }: IconProps) {
return (
<Svg className={className}>
<path d="M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12Z" />
<circle cx="12" cy="12" r="3" />
</Svg>
);
}
export function EyeOffIcon({ className }: IconProps) {
return (
<Svg className={className}>
<path d="m3 3 18 18" />
<path d="M10.6 5.7c.5-.1.9-.2 1.4-.2 6 0 9.5 6.5 9.5 6.5a17.5 17.5 0 0 1-2.6 3.5" />
<path d="M6.6 6.8A16.6 16.6 0 0 0 2.5 12S6 18.5 12 18.5c1.4 0 2.7-.4 3.8-1" />
<path d="M9.9 9.9a3 3 0 0 0 4.2 4.2" />
</Svg>
);
}
export function QuestionIcon({ className }: IconProps) {
return (
<Svg className={className}>
<circle cx="12" cy="12" r="9" />
<path d="M9.7 9.4a2.5 2.5 0 0 1 4.9.7c0 1.7-2.5 2-2.5 3.5" />
<path d="M12 17h.01" />
</Svg>
);
}
export function XIcon({ className }: IconProps) {
return (
<Svg className={className}>
<path d="m6 6 12 12M18 6 6 18" />
</Svg>
);
}

View file

@ -1,7 +1,7 @@
"use client";
import type { Activity, Difficulty } from "./schemas";
import { authHeaders } from "./settings";
import { API_KEY_HEADER, authHeaders } from "./settings";
export type GenerateInput = {
images?: string[];
@ -207,3 +207,59 @@ export async function fetchHealth(): Promise<Health> {
throw new ApiError("Could not reach the Freezmo server.", "network");
}
}
export type KeyCheck =
| { result: "valid" }
| { result: "invalid"; message: string }
| { result: "offline"; message: string };
/**
* Checks a *draft* key before it is saved anywhere, by asking our own
* `/api/health` route (which performs a real one-token chat probe against
* Ollama — model listings alone prove nothing). The key travels in the
* request header for this single call and is never stored server-side.
*/
export async function verifyOllamaKey(apiKey: string): Promise<KeyCheck> {
let response: Response;
try {
response = await fetch("/api/health", {
cache: "no-store",
headers: { [API_KEY_HEADER]: apiKey },
});
} catch {
return {
result: "offline",
message: "Could not reach the Freezmo server. Is it still running?",
};
}
const body = (await response.json().catch(() => null)) as Health | null;
if (!response.ok || !body) {
return {
result: "offline",
message: "The Freezmo server answered unexpectedly. Try again in a moment.",
};
}
if (body.keyAccepted === false) {
return {
result: "invalid",
message:
body.error ??
"Ollama rejected this key. Check for typos, or create a new one at ollama.com/settings/keys.",
};
}
if (body.keyAccepted === true) return { result: "valid" };
if (!body.reachable) {
return {
result: "offline",
message:
body.error ?? "Couldn't reach Ollama just now. Check your connection and try again.",
};
}
// Reachable, but the probe was inconclusive (usually a model-availability
// matter rather than a bad key). Let the user in — Settings shows details.
return { result: "valid" };
}

View file

@ -11,6 +11,9 @@
const STORAGE_KEY = "freezmo.settings.v1";
/** Header that carries the browser's key to Freezmo's own API routes. */
export const API_KEY_HEADER = "x-ollama-key";
export type BrowserSettings = {
/**
* The user's own Ollama API key. Nothing else is stored here — the endpoint
@ -80,5 +83,5 @@ export function clearApiKey(): void {
/** Header used to hand the key to our own API routes for one request. */
export function authHeaders(): Record<string, string> {
const { apiKey } = settingsStore.getSnapshot();
return apiKey ? { "x-ollama-key": apiKey } : {};
return apiKey ? { [API_KEY_HEADER]: apiKey } : {};
}

View file

@ -7,7 +7,7 @@ import { settingsStore } from "./settings";
/**
* Health status for the current browser (re-checks whenever the API key
* changes). Used by the settings dialog and the "add your key" banner.
* changes). Used by the settings dialog and the API-key gate.
*/
export function useHealth() {
const apiKey = useSyncExternalStore(