"use client"; import { AnimatePresence, motion } from "framer-motion"; import { useAppReducedMotion as useReducedMotion } from "@/lib/motion-pref"; import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; import { useTheme } from "@/lib/theme"; import { CheckCircleIcon, ExternalLinkIcon, EyeIcon, EyeOffIcon, MoonIcon, QuestionIcon, SunIcon, XIcon, } from "@/components/icons"; import { Spinner } from "@/components/ui"; import { LOCALES, useI18n } from "@/lib/i18n"; import { verifyOllamaKey } from "@/lib/client-api"; import { ProvidersSection } from "@/components/ProvidersSection"; import { PROVIDERS } from "@/lib/providers"; import { modelsFor, setGeneration, useProviders } from "@/lib/providers-client"; 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(); const providers = useProviders(); // Server-key and local-daemon installs never need a browser key. const needsKeyMode = health?.authMode !== "local" && health?.authMode !== "server"; // A paid provider is a valid first-run choice too. As soon as a key is saved // the study generation switches to that provider — Ollama stays untouched — // and the gate steps aside (the provider's models were listed on save). const firstEnabled = PROVIDERS.find((provider) => providers.keys[provider.id]); useEffect(() => { if (!firstEnabled || providers.generation) return; const models = modelsFor(providers, firstEnabled.id); if (models.length === 0) return; setGeneration({ provider: firstEnabled.id, textModel: models[0], visionModel: models[0] }); }, [firstEnabled, providers]); const providerReady = Boolean(providers.generation && providers.keys[providers.generation.provider]); // 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 || providerReady || !needsKeyMode) return <>{children}>; return ; } /** 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 ( {children} ); } function GateScreen() { const reduceMotion = useReducedMotion(); const { theme, toggle } = useTheme(); const { locale, setLocale, t } = useI18n(); 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 [providersOpen, setProvidersOpen] = useState(false); const [keyboardOpen, setKeyboardOpen] = useState(false); const inputRef = useRef(null); const rootRef = useRef(null); const wrapRef = useRef(null); const submitRef = useRef(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) => { 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 ( {/* corner controls: theme + help ---------------------------------- */} {theme === "dark" ? : } { const index = LOCALES.findIndex((entry) => entry.id === locale); setLocale(LOCALES[(index + 1) % LOCALES.length].id); }} > {LOCALES.find((entry) => entry.id === locale)?.short ?? "EN"} setHelpOpen(true)} > {t("gate.howGet")} {/* card ----------------------------------------------------------- */} {/* eslint-disable-next-line @next/next/no-img-element -- static app icon, no optimisation needed */} {t("gate.title")} {t("gate.subtitle")} {t("gate.ollamaLabel")} {t("gate.free")} {t("gate.apiKeyLabel")} { setDraft(event.target.value); if (error) setError(null); }} placeholder={t("gate.placeholder")} 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" /> setReveal((value) => !value)} aria-label={reveal ? t("gate.hide") : t("gate.show")} 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 ? : } {error ? ( {error.message} ) : null} {busy ? ( <> {t("gate.checking")} > ) : ( t("gate.continue") )} 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" > {t("gate.howGet")} {t("gate.or")} setProvidersOpen((open) => !open)} aria-expanded={providersOpen} className="btn-secondary mt-3 h-11 w-full rounded-[13px] text-[13.5px]" > {t("gate.paidOption")} {providersOpen ? ( {t("gate.paidNote")} ) : null} {t("gate.stays")} setHelpOpen(false)} /> ); } /* ------------------------------------------------------------------ */ /* “How do I get a key?” */ /* ------------------------------------------------------------------ */ function HelpDialog({ open, onClose }: { open: boolean; onClose: () => void }) { const reduceMotion = useReducedMotion(); const { t } = useI18n(); return ( {open ? ( event.stopPropagation()} className="gate-card glass-sheet thin-scroll max-h-[90dvh] w-full max-w-[480px] overflow-y-auto p-6 pb-safe sm:pb-6" > {t("gate.helpTitle")} {t("gate.freeTier.before")}{" "} {t("gate.freeTier.strong")} {t("gate.freeTier.after")} {t("gate.step1.before")}{" "} ollama.com{" "} {t("gate.step1.after")} {t("gate.step2.before")}{" "} ollama.com/settings/keys {t("gate.step2.after")} {t("gate.step3.before")}{" "} {t("gate.step3.strong")} {t("gate.step3.after")} {t("gate.step4")} {t("gate.footnote")} {t("gate.gotIt")} ) : null} ); } function ExternalLink({ href, children }: { href: string; children: React.ReactNode }) { return ( {children} ); } function Step({ index, children }: { index: number; children: React.ReactNode }) { return ( {index} {children} ); }
{t("gate.subtitle")}
{error.message}
{t("gate.paidNote")}
{t("gate.stays")}
{t("gate.freeTier.before")}{" "} {t("gate.freeTier.strong")} {t("gate.freeTier.after")}
{t("gate.footnote")}