freezmo/components/SettingsDialog.tsx
Martin Dvorak 3ea2cea100 Freezmo: self-hosted notes-to-activities study app
Next.js 15 app that turns photos, PDFs, DOCX/PPTX and text into a swipeable
feed of AI-generated activities (flashcards, multiple choice, true/false,
fill-in-the-blank, matching, ordering, short answer) using the user's own
Ollama models.

Highlights:
- All AI calls run in server route handlers; the browser never talks to Ollama.
- Bring-your-own-key mode (default): the key lives only in the browser and is
  sent per-request; server, local-daemon and shared-key modes also supported.
- Client-side document extraction (pdf.js, mammoth, jszip) with scanned-PDF
  page rendering for the vision model; notes stay in their original language.
- ChatGPT-style shell: collapsible sidebar / mobile drawer, neutral token-based
  light + dark themes, folder organisation, PWA (manifest, SW, offline page).
- Strict JSON validation with zod + auto-repair, rate limiting, and request
  guards (cross-origin, body-size, API-key sanitising).
- setup.sh bootstraps deps, .env.local and a production build.

Gitignore excludes .env*, the generated pdf.js worker and local scratch files.
2026-10-06 20:56:55 +00:00

289 lines
12 KiB
TypeScript

"use client";
import { AnimatePresence, motion } from "framer-motion";
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
import { clearApiKey, saveSettings } from "@/lib/settings";
import { useHealth } from "@/lib/use-health";
import { AlertTriangleIcon, CheckCircleIcon, XCircleIcon } from "@/components/icons";
import { Button, Spinner } from "@/components/ui";
export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
const { health, loading, error, refresh, apiKey } = useHealth();
const [keyDraft, setKeyDraft] = useState(apiKey);
const [reveal, setReveal] = useState(false);
const [saved, setSaved] = useState(false);
useEffect(() => {
if (!open) return;
setKeyDraft(apiKey);
setSaved(false);
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, apiKey, onClose]);
const save = () => {
saveSettings({ apiKey: keyDraft.trim() });
setSaved(true);
window.setTimeout(() => setSaved(false), 2500);
};
const forget = () => {
clearApiKey();
setKeyDraft("");
};
const needsKeyMode = health?.authMode === "user-key";
return (
<AnimatePresence>
{open ? (
<motion.div
className="fixed inset-0 z-50 flex items-end justify-center bg-frost-950/35 p-0 backdrop-blur-md 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="settings-title"
initial={{ y: 48, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 32, opacity: 0 }}
transition={{ type: "spring", stiffness: 340, damping: 32 }}
onClick={(event) => event.stopPropagation()}
className="glass-panel thin-scroll max-h-[90dvh] w-full max-w-lg overflow-y-auto rounded-t-[1.6rem] p-5 pb-safe shadow-2xl sm:rounded-[1.35rem] sm:pb-5"
>
<div className="sheet-grabber sm:hidden" aria-hidden />
<div className="mb-4 flex items-start justify-between gap-4">
<div>
<h2 id="settings-title" className="text-lg font-semibold text-frost-950 dark:text-frost-50">
Settings
</h2>
<p className="text-sm text-slate-600 dark:text-slate-300">
Models come from <code className="rounded bg-frost-100 px-1 dark:bg-white/10">.env.local</code> on
the server; your API key stays in this browser.
</p>
</div>
<Button variant="ghost" size="sm" onClick={onClose} aria-label="Close settings">
✕
</Button>
</div>
{/* ------------------------------------------------ API key */}
<section className="mb-4 space-y-2 rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-frost-950 dark:text-frost-50">Ollama API key</h3>
{apiKey ? (
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-bold text-emerald-900 dark:bg-emerald-400/20 dark:text-emerald-200">
saved in this browser
</span>
) : needsKeyMode ? (
<span className="rounded-full bg-amber-50 px-2 py-0.5 text-[11px] font-semibold text-amber-800 dark:bg-amber-500/15 dark:text-amber-200">
required
</span>
) : (
<span className="rounded-full bg-[var(--surface-sunken)] px-2 py-0.5 text-[11px] font-semibold text-frost-600 dark:text-frost-300">
not needed
</span>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<label className="sr-only" htmlFor="ollama-api-key">
Ollama API key
</label>
<input
id="ollama-api-key"
type={reveal ? "text" : "password"}
value={keyDraft}
onChange={(event) => setKeyDraft(event.target.value)}
onKeyDown={(event) => {
event.stopPropagation();
if (event.key === "Enter") save();
}}
placeholder="paste your key from ollama.com/settings/keys"
autoComplete="off"
spellCheck={false}
className="min-w-40 flex-1 rounded-xl border border-[var(--hairline)] bg-[var(--surface-strong)] px-3 py-2 font-mono text-xs text-frost-950 outline-none transition placeholder:font-sans placeholder:text-frost-400 focus:border-accent-500 dark:text-frost-50"
/>
<Button size="sm" variant="secondary" onClick={() => setReveal((value) => !value)}>
{reveal ? "Hide" : "Show"}
</Button>
<Button size="sm" onClick={save} disabled={keyDraft.trim() === apiKey}>
Save
</Button>
</div>
<p className="text-[11px] leading-relaxed text-frost-500 dark:text-frost-400">
Stored only in this browser&apos;s local storage and sent with each request so the server can talk to
Ollama for you. It is never written to the server, never logged and never put in a cookie. Prefer the
local daemon? Set <code>OLLAMA_AUTH=local</code> in <code>.env.local</code> and use{" "}
<code>ollama signin</code> instead.
</p>
<div className="flex flex-wrap items-center gap-2 pt-1">
<Button size="sm" variant="secondary" onClick={() => void refresh()} loading={loading}>
Test connection
</Button>
{apiKey ? (
<Button size="sm" variant="ghost" onClick={forget}>
Remove key from this browser
</Button>
) : null}
{saved ? (
<span className="text-xs font-semibold text-emerald-700 dark:text-emerald-300">
Saved — testing…
</span>
) : null}
</div>
</section>
{loading && !health ? (
<div className="flex items-center gap-3 py-6 text-sm text-slate-600 dark:text-slate-300">
<Spinner className="size-5" /> Checking Ollama…
</div>
) : null}
{error ? (
<div className="rounded-2xl bg-rose-100 p-3 text-sm text-rose-900 dark:bg-rose-500/15 dark:text-rose-200">
{error}
</div>
) : null}
{health ? <HealthPanel health={health} /> : null}
</motion.div>
</motion.div>
) : null}
</AnimatePresence>
);
}
function HealthPanel({ health }: { health: ReturnType<typeof useHealth>["health"] }) {
if (!health) return null;
const tone = health.ok ? "emerald" : health.needsKey ? "amber" : health.reachable ? "amber" : "rose";
const toneClasses: Record<string, string> = {
emerald: "bg-emerald-50 text-emerald-800 dark:bg-emerald-500/15 dark:text-emerald-200",
amber: "bg-amber-50 text-amber-800 dark:bg-amber-500/15 dark:text-amber-200",
rose: "bg-rose-50 text-rose-800 dark:bg-rose-500/15 dark:text-rose-200",
};
return (
<div className="space-y-3">
<div className={cn("flex items-start gap-2 rounded-2xl p-3 text-sm font-medium", toneClasses[tone])}>
<span aria-hidden className="mt-0.5 shrink-0">
{tone === "emerald" ? (
<CheckCircleIcon className="size-5" />
) : tone === "amber" ? (
<AlertTriangleIcon className="size-5" />
) : (
<XCircleIcon className="size-5" />
)}
</span>
<span>
{health.ok
? "Ollama is reachable and both models are available."
: health.needsKey
? "Waiting for your API key."
: health.keyAccepted === false
? "Ollama rejected your API key."
: health.reachable
? "Ollama is reachable, but a configured model is missing."
: "Ollama isn't reachable."}
{health.error ? <p className="mt-1 font-normal">{health.error}</p> : null}
</span>
</div>
<dl className="grid grid-cols-2 gap-2 text-sm">
<Info
label="Auth"
value={
health.authMode === "user-key"
? "Your own key"
: health.authMode === "server"
? "Server key (.env)"
: "Local daemon sign-in"
}
/>
<Info label="Endpoint" value={health.baseUrl} mono />
<Info label="Mode" value={health.mode === "direct-cloud" ? "Direct cloud API" : "Local daemon"} />
<Info
label="Key"
value={
health.needsKey
? "missing"
: health.keyAccepted === true
? "accepted"
: health.keyAccepted === false
? "rejected"
: health.authMode === "local"
? "not needed"
: "unknown"
}
/>
</dl>
<div className="space-y-2">
<ModelRow title="Vision model (reads photos)" model={health.vision} />
<ModelRow title="Text model (builds & grades)" model={health.text} />
</div>
{health.mode === "direct-cloud" && health.needsKey ? (
<p className="rounded-2xl bg-[var(--surface-sunken)] p-3 text-xs text-frost-700 dark:text-frost-200">
Create a key 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 above. Tip: with the local daemon you can run <code>ollama signin</code> instead and set{" "}
<code>OLLAMA_AUTH=local</code>.
</p>
) : null}
</div>
);
}
function Info({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div className="rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
<dt className="text-xs uppercase tracking-wide text-frost-500 dark:text-frost-400">{label}</dt>
<dd className={cn("mt-0.5 truncate font-semibold", mono && "font-mono text-xs")}>{value}</dd>
</div>
);
}
function ModelRow({
title,
model,
}: {
title: string;
model: { configured: string; resolved: string; available: boolean | null; note?: string };
}) {
const badge =
model.available === true
? { text: "available", className: "bg-emerald-100 text-emerald-900 dark:bg-emerald-400/20 dark:text-emerald-200" }
: model.available === false
? { text: "not found", className: "bg-rose-50 text-rose-800 dark:bg-rose-500/15 dark:text-rose-200" }
: { text: "unknown", className: "bg-[var(--surface-sunken)] text-frost-600 dark:text-frost-300" };
return (
<div className="rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
<div className="flex items-center justify-between gap-2">
<p className="text-xs uppercase tracking-wide text-frost-500 dark:text-frost-400">{title}</p>
<span className={cn("rounded-full px-2 py-0.5 text-[11px] font-bold", badge.className)}>
{badge.text}
</span>
</div>
<p className="mt-1 font-mono text-sm font-semibold">{model.resolved}</p>
{model.configured !== model.resolved ? (
<p className="text-xs text-frost-500 dark:text-frost-400">configured as {model.configured}</p>
) : null}
{model.note ? <p className="mt-1 text-xs text-amber-700 dark:text-amber-300">{model.note}</p> : null}
</div>
);
}