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:
parent
532b5a18fe
commit
dfa7b7502a
13 changed files with 505 additions and 94 deletions
29
README.md
29
README.md
|
|
@ -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 |
|
| `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 |
|
| `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**
|
**Library & folders**
|
||||||
|
|
||||||
- Decks saved locally with title, subject, folder, activity count, date, play count and best score
|
- 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
|
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,
|
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
|
`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.
|
`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 |
|
| `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 |
|
| `server` | `OLLAMA_API_KEY` in `.env.local` | a single-user install |
|
||||||
| `local` | nothing — a daemon signed in with `ollama signin` | you run Ollama locally |
|
| `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
|
TEXT_MODEL=gpt-oss:120b-cloud
|
||||||
```
|
```
|
||||||
|
|
||||||
1. Open the app → a banner asks for a key → click **Add API key** (or the ⚙️ button).
|
1. Open the app — the **key gate** asks for a key on first visit.
|
||||||
2. Create a key at <https://ollama.com/settings/keys> and paste it in.
|
2. Create one at <https://ollama.com/settings/keys>, paste it in and press **Continue**.
|
||||||
3. **Test connection** shows whether Ollama accepted it, and which models are available.
|
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:
|
Where the key lives:
|
||||||
|
|
||||||
|
|
@ -530,7 +539,7 @@ components/
|
||||||
activities/ one component per activity type + shared shell
|
activities/ one component per activity type + shared shell
|
||||||
Flashcard.tsx MultipleChoice.tsx TrueFalse.tsx FillBlank.tsx
|
Flashcard.tsx MultipleChoice.tsx TrueFalse.tsx FillBlank.tsx
|
||||||
Matching.tsx Ordering.tsx ShortAnswer.tsx shared.tsx index.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
|
TabBar.tsx InstallHint.tsx ServiceWorkerRegistrar.tsx FolderBar.tsx
|
||||||
LibraryScreen.tsx SettingsDialog.tsx AppHeader.tsx ThemeToggle.tsx icons.tsx ui.tsx
|
LibraryScreen.tsx SettingsDialog.tsx AppHeader.tsx ThemeToggle.tsx icons.tsx ui.tsx
|
||||||
lib/
|
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. |
|
| 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. |
|
| 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`. |
|
| 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. |
|
| “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>. |
|
| “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`. |
|
| “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
|
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
|
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).
|
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
|
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`).
|
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
|
Text-only inputs — pasted text, PDFs with a text layer, DOCX/PPTX/ODT — never touch the vision
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { ApiKeyGate } from "@/components/ApiKeyGate";
|
||||||
import { AppHeader } from "@/components/AppHeader";
|
import { AppHeader } from "@/components/AppHeader";
|
||||||
import { MobileSidebar, Sidebar, SidebarOffset } from "@/components/Sidebar";
|
import { MobileSidebar, Sidebar, SidebarOffset } from "@/components/Sidebar";
|
||||||
import { TabBar } from "@/components/TabBar";
|
import { TabBar } from "@/components/TabBar";
|
||||||
|
|
@ -9,6 +10,7 @@ export const metadata = {
|
||||||
|
|
||||||
export default function CreatePage() {
|
export default function CreatePage() {
|
||||||
return (
|
return (
|
||||||
|
<ApiKeyGate>
|
||||||
<div className="flex min-h-dvh bg-[var(--page)]">
|
<div className="flex min-h-dvh bg-[var(--page)]">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<SidebarOffset />
|
<SidebarOffset />
|
||||||
|
|
@ -21,5 +23,6 @@ export default function CreatePage() {
|
||||||
<TabBar />
|
<TabBar />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
</ApiKeyGate>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import Link from "next/link";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { ApiKeyGate } from "@/components/ApiKeyGate";
|
||||||
import { StudyFeed } from "@/components/StudyFeed";
|
import { StudyFeed } from "@/components/StudyFeed";
|
||||||
import { Button, Spinner } from "@/components/ui";
|
import { Button, Spinner } from "@/components/ui";
|
||||||
import type { Deck } from "@/lib/schemas";
|
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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,13 @@
|
||||||
--sidebar-top: #fbfbfc;
|
--sidebar-top: #fbfbfc;
|
||||||
--sidebar-bottom: #f6f6f7;
|
--sidebar-bottom: #f6f6f7;
|
||||||
--sidebar-edge: 0 1px 0 var(--hairline), 6px 0 16px -14px rgb(13 13 13 / 0.28);
|
--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: 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);
|
--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-top: #1a1a1a;
|
||||||
--sidebar-bottom: #151515;
|
--sidebar-bottom: #151515;
|
||||||
--sidebar-edge: 0 1px 0 var(--hairline), 6px 0 16px -14px rgb(0 0 0 / 0.75);
|
--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: 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);
|
--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;
|
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 {
|
.app-backdrop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { ApiKeyGate } from "@/components/ApiKeyGate";
|
||||||
import { AppHeader } from "@/components/AppHeader";
|
import { AppHeader } from "@/components/AppHeader";
|
||||||
import { InstallHint } from "@/components/InstallHint";
|
import { InstallHint } from "@/components/InstallHint";
|
||||||
import { LibraryScreen } from "@/components/LibraryScreen";
|
import { LibraryScreen } from "@/components/LibraryScreen";
|
||||||
|
|
@ -6,6 +7,7 @@ import { TabBar } from "@/components/TabBar";
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
return (
|
return (
|
||||||
|
<ApiKeyGate>
|
||||||
<div className="flex min-h-dvh bg-[var(--page)]">
|
<div className="flex min-h-dvh bg-[var(--page)]">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<SidebarOffset />
|
<SidebarOffset />
|
||||||
|
|
@ -21,5 +23,6 @@ export default function HomePage() {
|
||||||
<TabBar />
|
<TabBar />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
</ApiKeyGate>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
321
components/ApiKeyGate.tsx
Normal 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’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’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)]">“Create new key”</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useMemo, useRef, useState, useSyncExternalStore } from "react";
|
import { useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||||
|
|
||||||
import { ApiKeyBanner } from "@/components/ApiKeyBanner";
|
|
||||||
import { DeckCard } from "@/components/DeckCard";
|
import { DeckCard } from "@/components/DeckCard";
|
||||||
import { FolderBar } from "@/components/FolderBar";
|
import { FolderBar } from "@/components/FolderBar";
|
||||||
import { FolderIcon, UploadIcon } from "@/components/icons";
|
import { FolderIcon, UploadIcon } from "@/components/icons";
|
||||||
|
|
@ -168,7 +167,6 @@ export function LibraryScreen() {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ApiKeyBanner />
|
|
||||||
|
|
||||||
{message ? (
|
{message ? (
|
||||||
<p
|
<p
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,6 @@ import { UNORGANIZED_ID, type Difficulty } from "@/lib/schemas";
|
||||||
import { upsertDeck } from "@/lib/storage";
|
import { upsertDeck } from "@/lib/storage";
|
||||||
import { openSettings } from "@/lib/ui-events";
|
import { openSettings } from "@/lib/ui-events";
|
||||||
import { cn, uid } from "@/lib/utils";
|
import { cn, uid } from "@/lib/utils";
|
||||||
import { ApiKeyBanner } from "@/components/ApiKeyBanner";
|
|
||||||
import { CameraIcon, FolderIcon, IceCubeIcon, PlusIcon, SnowflakeIcon, SunIcon } from "@/components/icons";
|
import { CameraIcon, FolderIcon, IceCubeIcon, PlusIcon, SnowflakeIcon, SunIcon } from "@/components/icons";
|
||||||
import { Button, ProgressBar } from "@/components/ui";
|
import { Button, ProgressBar } from "@/components/ui";
|
||||||
|
|
||||||
|
|
@ -325,7 +324,6 @@ export function UploadScreen() {
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<ApiKeyBanner />
|
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -237,3 +237,41 @@ export function RefreshIcon({ className }: IconProps) {
|
||||||
</Svg>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { Activity, Difficulty } from "./schemas";
|
import type { Activity, Difficulty } from "./schemas";
|
||||||
import { authHeaders } from "./settings";
|
import { API_KEY_HEADER, authHeaders } from "./settings";
|
||||||
|
|
||||||
export type GenerateInput = {
|
export type GenerateInput = {
|
||||||
images?: string[];
|
images?: string[];
|
||||||
|
|
@ -207,3 +207,59 @@ export async function fetchHealth(): Promise<Health> {
|
||||||
throw new ApiError("Could not reach the Freezmo server.", "network");
|
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" };
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,9 @@
|
||||||
|
|
||||||
const STORAGE_KEY = "freezmo.settings.v1";
|
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 = {
|
export type BrowserSettings = {
|
||||||
/**
|
/**
|
||||||
* The user's own Ollama API key. Nothing else is stored here — the endpoint
|
* 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. */
|
/** Header used to hand the key to our own API routes for one request. */
|
||||||
export function authHeaders(): Record<string, string> {
|
export function authHeaders(): Record<string, string> {
|
||||||
const { apiKey } = settingsStore.getSnapshot();
|
const { apiKey } = settingsStore.getSnapshot();
|
||||||
return apiKey ? { "x-ollama-key": apiKey } : {};
|
return apiKey ? { [API_KEY_HEADER]: apiKey } : {};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { settingsStore } from "./settings";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Health status for the current browser (re-checks whenever the API key
|
* 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() {
|
export function useHealth() {
|
||||||
const apiKey = useSyncExternalStore(
|
const apiKey = useSyncExternalStore(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue