diff --git a/README.md b/README.md index ccfa380..8e52065 100644 --- a/README.md +++ b/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 | | `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 . 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 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 , 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 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 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 . | | “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 diff --git a/app/create/page.tsx b/app/create/page.tsx index c7592f6..313748c 100644 --- a/app/create/page.tsx +++ b/app/create/page.tsx @@ -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 ( -
- - - -
- -
- -
- -
-
+ +
+ + + +
+ +
+ +
+ +
+
+
); } diff --git a/app/deck/[id]/page.tsx b/app/deck/[id]/page.tsx index b7a3321..112f1c3 100644 --- a/app/deck/[id]/page.tsx +++ b/app/deck/[id]/page.tsx @@ -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 ; + return ( + + + + ); } diff --git a/app/globals.css b/app/globals.css index 02e33d4..fa6f070 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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; diff --git a/app/page.tsx b/app/page.tsx index d8b4d3a..e38e185 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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 ( -
- - - -
- -
-
- + +
+ + + +
+ +
+
+ +
+
- -
- -
-
+ + + + ); } diff --git a/components/ApiKeyBanner.tsx b/components/ApiKeyBanner.tsx deleted file mode 100644 index 509263f..0000000 --- a/components/ApiKeyBanner.tsx +++ /dev/null @@ -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 ( -
- - 🔑 - -

- - {rejected ? "Your Ollama API key was rejected." : "Add your Ollama API key to generate decks."} - {" "} - - Create one at{" "} - - ollama.com/settings/keys - {" "} - and paste it in settings. It stays in this browser — the server never stores it. - -

- -
- ); -} diff --git a/components/ApiKeyGate.tsx b/components/ApiKeyGate.tsx new file mode 100644 index 0000000..4c33c40 --- /dev/null +++ b/components/ApiKeyGate.tsx @@ -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 ; +} + +function GateScreen() { + const reduceMotion = useReducedMotion(); + const [draft, setDraft] = useState(""); + const [reveal, setReveal] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [helpOpen, setHelpOpen] = useState(false); + const inputRef = useRef(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) => { + 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 ( +
+ {/* help -------------------------------------------------------- */} +
+ + + How do I get a key? + +
+ + {/* card -------------------------------------------------------- */} +
+ + + + + +

+ Connect your Ollama key +

+

+ One step and you’re ready to freeze your first deck. +

+ +
+
+ + { + 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" + /> + +
+ + {error ? ( + + ) : null} + + +
+ +

+ Your key stays in this browser. Freezmo’s server never stores it. +

+
+
+ + setHelpOpen(false)} /> +
+ ); +} + +/* ------------------------------------------------------------------ */ +/* “How do I get a key?” */ +/* ------------------------------------------------------------------ */ + +function HelpDialog({ open, onClose }: { open: boolean; onClose: () => void }) { + const reduceMotion = useReducedMotion(); + + return ( + + {open ? ( + + 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" + > +
+ +
+

+ How to get your Ollama API key +

+ +
+ +

+ You only need the free cloud tier. No payment and no credit card required. +

+ +
    + + Go to{" "} + + ollama.com + {" "} + and sign in, or create a free account. + + + Open Settings → API keys at{" "} + + ollama.com/settings/keys + + . + + + Click “Create new key”, give it + any name, and copy it. + + Paste it into Freezmo and press Continue. +
+ +

+ The free tier is enough for generating decks. You can swap or remove your key anytime in + Settings. +

+ + + + + ) : null} + + ); +} + +function Step({ index, children }: { index: number; children: React.ReactNode }) { + return ( +
  • + + {index} + + {children} +
  • + ); +} diff --git a/components/LibraryScreen.tsx b/components/LibraryScreen.tsx index dcaa8c5..0a387ff 100644 --- a/components/LibraryScreen.tsx +++ b/components/LibraryScreen.tsx @@ -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() { }} /> - {message ? (

    - {error ? (

    ); } + +export function EyeIcon({ className }: IconProps) { + return ( + + + + + ); +} + +export function EyeOffIcon({ className }: IconProps) { + return ( + + + + + + + ); +} + +export function QuestionIcon({ className }: IconProps) { + return ( + + + + + + ); +} + +export function XIcon({ className }: IconProps) { + return ( + + + + ); +} diff --git a/lib/client-api.ts b/lib/client-api.ts index 1f887a5..599f64b 100644 --- a/lib/client-api.ts +++ b/lib/client-api.ts @@ -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 { 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 { + 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" }; +} diff --git a/lib/settings.ts b/lib/settings.ts index fdb0d20..470a156 100644 --- a/lib/settings.ts +++ b/lib/settings.ts @@ -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 { const { apiKey } = settingsStore.getSnapshot(); - return apiKey ? { "x-ollama-key": apiKey } : {}; + return apiKey ? { [API_KEY_HEADER]: apiKey } : {}; } diff --git a/lib/use-health.ts b/lib/use-health.ts index ce7d8b5..fd912e1 100644 --- a/lib/use-health.ts +++ b/lib/use-health.ts @@ -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(