From 3ea2cea1009e5607248f914ad6d3d743cf0fe4e4 Mon Sep 17 00:00:00 2001 From: Martin Dvorak Date: Tue, 6 Oct 2026 20:56:55 +0000 Subject: [PATCH] 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. --- .env.example | 51 ++ .gitignore | 15 + README.md | 770 +++++++++++++++++++++- app/api/generate/route.ts | 226 +++++++ app/api/grade/route.ts | 71 ++ app/api/health/route.ts | 131 ++++ app/create/page.tsx | 25 + app/deck/[id]/page.tsx | 66 ++ app/globals.css | 503 +++++++++++++- app/icon.svg | 14 + app/layout.tsx | 79 ++- app/manifest.ts | 30 + app/offline/page.tsx | 19 + app/page.tsx | 114 +--- components/ApiKeyBanner.tsx | 53 ++ components/AppHeader.tsx | 85 +++ components/DeckCard.tsx | 407 ++++++++++++ components/FolderBar.tsx | 127 ++++ components/InstallHint.tsx | 64 ++ components/LibraryScreen.tsx | 332 ++++++++++ components/ResultsScreen.tsx | 205 ++++++ components/ServiceWorkerRegistrar.tsx | 30 + components/SettingsDialog.tsx | 289 ++++++++ components/Sidebar.tsx | 362 ++++++++++ components/StudyFeed.tsx | 278 ++++++++ components/TabBar.tsx | 47 ++ components/ThemeToggle.tsx | 54 ++ components/UploadScreen.tsx | 804 +++++++++++++++++++++++ components/activities/FillBlank.tsx | 202 ++++++ components/activities/Flashcard.tsx | 92 +++ components/activities/Matching.tsx | 151 +++++ components/activities/MultipleChoice.tsx | 100 +++ components/activities/Ordering.tsx | 132 ++++ components/activities/ShortAnswer.tsx | 189 ++++++ components/activities/TrueFalse.tsx | 90 +++ components/activities/index.tsx | 44 ++ components/activities/shared.tsx | 204 ++++++ components/icons.tsx | 239 +++++++ components/ui.tsx | 151 +++++ eslint.config.mjs | 2 + lib/ai.ts | 442 +++++++++++++ lib/client-api.ts | 209 ++++++ lib/confetti.ts | 56 ++ lib/config.ts | 180 +++++ lib/deck-ops.ts | 18 + lib/documents.ts | 476 ++++++++++++++ lib/folders.ts | 153 +++++ lib/http-guard.ts | 79 +++ lib/image.ts | 131 ++++ lib/language.ts | 174 +++++ lib/nav.ts | 72 ++ lib/ollama.ts | 327 +++++++++ lib/prompts.ts | 212 ++++++ lib/rate-limit.ts | 106 +++ lib/schemas.ts | 193 ++++++ lib/settings.ts | 84 +++ lib/storage.ts | 263 ++++++++ lib/ui-events.ts | 13 + lib/use-health.ts | 39 ++ lib/utils.ts | 59 ++ lib/xp.ts | 26 + next.config.ts | 17 +- package-lock.json | 566 +++++++++++++++- package.json | 25 +- public/icons/apple-touch-icon-152.png | Bin 0 -> 4069 bytes public/icons/apple-touch-icon-167.png | Bin 0 -> 4564 bytes public/icons/apple-touch-icon.png | Bin 0 -> 4844 bytes public/icons/favicon-32.png | Bin 0 -> 1187 bytes public/icons/icon-192.png | Bin 0 -> 7580 bytes public/icons/icon-512.png | Bin 0 -> 23081 bytes public/icons/maskable-512.png | Bin 0 -> 12975 bytes public/icons/splash-1170x2532.png | Bin 0 -> 54110 bytes public/icons/splash-1179x2556.png | Bin 0 -> 54515 bytes public/icons/splash-1284x2778.png | Bin 0 -> 61883 bytes public/icons/splash-1290x2796.png | Bin 0 -> 62746 bytes public/icons/splash-1536x2048.png | Bin 0 -> 56039 bytes public/icons/splash-1668x2388.png | Bin 0 -> 66924 bytes public/icons/splash-750x1334.png | Bin 0 -> 25265 bytes public/icons/splash-828x1792.png | Bin 0 -> 34087 bytes public/sw.js | 97 +++ scripts/copy-pdf-worker.mjs | 32 + scripts/make-icons.mjs | 76 +++ setup.sh | 102 +++ 83 files changed, 10909 insertions(+), 165 deletions(-) create mode 100644 .env.example create mode 100644 app/api/generate/route.ts create mode 100644 app/api/grade/route.ts create mode 100644 app/api/health/route.ts create mode 100644 app/create/page.tsx create mode 100644 app/deck/[id]/page.tsx create mode 100644 app/icon.svg create mode 100644 app/manifest.ts create mode 100644 app/offline/page.tsx create mode 100644 components/ApiKeyBanner.tsx create mode 100644 components/AppHeader.tsx create mode 100644 components/DeckCard.tsx create mode 100644 components/FolderBar.tsx create mode 100644 components/InstallHint.tsx create mode 100644 components/LibraryScreen.tsx create mode 100644 components/ResultsScreen.tsx create mode 100644 components/ServiceWorkerRegistrar.tsx create mode 100644 components/SettingsDialog.tsx create mode 100644 components/Sidebar.tsx create mode 100644 components/StudyFeed.tsx create mode 100644 components/TabBar.tsx create mode 100644 components/ThemeToggle.tsx create mode 100644 components/UploadScreen.tsx create mode 100644 components/activities/FillBlank.tsx create mode 100644 components/activities/Flashcard.tsx create mode 100644 components/activities/Matching.tsx create mode 100644 components/activities/MultipleChoice.tsx create mode 100644 components/activities/Ordering.tsx create mode 100644 components/activities/ShortAnswer.tsx create mode 100644 components/activities/TrueFalse.tsx create mode 100644 components/activities/index.tsx create mode 100644 components/activities/shared.tsx create mode 100644 components/icons.tsx create mode 100644 components/ui.tsx create mode 100644 lib/ai.ts create mode 100644 lib/client-api.ts create mode 100644 lib/confetti.ts create mode 100644 lib/config.ts create mode 100644 lib/deck-ops.ts create mode 100644 lib/documents.ts create mode 100644 lib/folders.ts create mode 100644 lib/http-guard.ts create mode 100644 lib/image.ts create mode 100644 lib/language.ts create mode 100644 lib/nav.ts create mode 100644 lib/ollama.ts create mode 100644 lib/prompts.ts create mode 100644 lib/rate-limit.ts create mode 100644 lib/schemas.ts create mode 100644 lib/settings.ts create mode 100644 lib/storage.ts create mode 100644 lib/ui-events.ts create mode 100644 lib/use-health.ts create mode 100644 lib/utils.ts create mode 100644 lib/xp.ts create mode 100644 public/icons/apple-touch-icon-152.png create mode 100644 public/icons/apple-touch-icon-167.png create mode 100644 public/icons/apple-touch-icon.png create mode 100644 public/icons/favicon-32.png create mode 100644 public/icons/icon-192.png create mode 100644 public/icons/icon-512.png create mode 100644 public/icons/maskable-512.png create mode 100644 public/icons/splash-1170x2532.png create mode 100644 public/icons/splash-1179x2556.png create mode 100644 public/icons/splash-1284x2778.png create mode 100644 public/icons/splash-1290x2796.png create mode 100644 public/icons/splash-1536x2048.png create mode 100644 public/icons/splash-1668x2388.png create mode 100644 public/icons/splash-750x1334.png create mode 100644 public/icons/splash-828x1792.png create mode 100644 public/sw.js create mode 100644 scripts/copy-pdf-worker.mjs create mode 100644 scripts/make-icons.mjs create mode 100755 setup.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6d9a4bc --- /dev/null +++ b/.env.example @@ -0,0 +1,51 @@ +# --------------------------------------------------------------------------- +# Freezmo - Ollama configuration +# Copy this file to `.env.local` and adjust if needed. +# --------------------------------------------------------------------------- + +# WHO PROVIDES THE API KEY ----------------------------------------------------- +# user-key (default) Every browser supplies its own key. The key is typed +# into the settings dialog, kept in that browser's local +# storage and sent with each request in the +# `x-ollama-key` header. The server never stores it. +# Point OLLAMA_BASE_URL at https://ollama.com. +# server One key for everyone, read from OLLAMA_API_KEY below +# (single-user installs). Nothing is sent from the client. +# local No key at all: a local daemon signed in with +# `ollama signin` proxies the cloud models. +OLLAMA_AUTH=user-key + +# Ollama endpoint. https://ollama.com for cloud, http://localhost:11434 for a +# local daemon. In user-key mode this is the endpoint the browser's key is sent +# to; in server/local mode it is used directly. +OLLAMA_BASE_URL=https://ollama.com + +# Only used when OLLAMA_AUTH=server. Never commit a real key. +OLLAMA_API_KEY= + +# Model used for the vision/transcription step (reads photos of notes). +# Must be multimodal — gpt-oss and other text-only models cannot be used here. +# Free-tier friendly: gemma4:31b-cloud, kimi-k3:cloud (paid), mistral-large-3 (paid). +VISION_MODEL=gemma4:31b-cloud + +# Model used to generate activities, grade short answers and restore the +# language if a transcription came back translated. gpt-oss:120b is free-tier +# friendly, very good at strict JSON and the strongest multilingual model in the +# free plan. NOTE: it is text-only, so it can't be used as VISION_MODEL. +# Free alternatives: nemotron-3-super:cloud, gpt-oss:20b-cloud. +# Paid/credit: glm-5.3-flash:cloud, kimi-k3:cloud. +TEXT_MODEL=gpt-oss:120b-cloud + +# RATE LIMITS (per client IP, sliding window, in-memory) ---------------------- +# Generation decks are expensive, so they are limited tightly by default. +# RATE_LIMIT_WINDOW_MS=300000 +# RATE_LIMIT_GENERATE=12 +# RATE_LIMIT_GRADE=40 +# RATE_LIMIT_HEALTH=90 + +# Optional tuning ------------------------------------------------------------ +# OLLAMA_TIMEOUT_MS=300000 +# OLLAMA_HEALTH_TIMEOUT_MS=8000 +# Send `think: false` to thinking models for faster JSON output. +# OLLAMA_THINK=false +# FREEZMO_DEFAULT_ACTIVITY_COUNT=10 diff --git a/.gitignore b/.gitignore index 5ef6a52..51ea45a 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +!.env.example # vercel .vercel @@ -39,3 +40,17 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# generated at build/dev time by scripts/copy-pdf-worker.mjs (predev/prebuild) +/public/pdf.worker.min.mjs + +# local scratch / secrets — never commit these +copy.txt +*.local +*.log + +# editors / OS +.idea/ +.vscode/ +*.swp +Thumbs.db diff --git a/README.md b/README.md index e215bc4..ccfa380 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,764 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# ❄️ Freezmo -## Getting Started +**Turn photos of your study notes into a swipeable feed of mini study games.** -First, run the development server: +Freezmo is a TikTok/Gizmo-style study app. You paste or upload photos of your notes, an AI reads +them and builds a deck of 8–15 interactive activities — flashcards, quizzes, matching games and +more — that you swipe through vertically like a feed. Every answer gives instant feedback, XP and +streaks, and the deck is saved in your browser so you can retry it later. + +All AI runs through **your own Ollama setup** (a local daemon or the Ollama cloud API). No accounts, +no database, no data leaving your machine except the requests you send to Ollama. + +--- + +## Table of contents + +- [Features](#features) +- [Quick start](#quick-start) +- [Supported input formats](#supported-input-formats) +- [Ollama setup](#ollama-setup) +- [Rate limits](#rate-limits) + - [Mode 1 — local daemon (recommended)](#mode-1--local-daemon-proxying-cloud-models-recommended) + - [Mode 2 — direct Ollama cloud API](#mode-2--direct-ollama-cloud-api-api-key) + - [Changing models](#changing-models) + - [Checking the connection](#checking-the-connection) +- [Running on your network / from your phone](#running-on-your-network--from-your-phone) +- [Folders](#folders) +- [Install as an app (PWA)](#install-as-an-app-pwa) +- [Security notes](#security-notes) +- [How it works](#how-it-works) +- [Project structure](#project-structure) +- [API routes](#api-routes) +- [Scripts](#scripts) +- [Data, storage and privacy](#data-storage-and-privacy) +- [Troubleshooting](#troubleshooting) +- [Decisions and notes](#decisions-and-notes) + +--- + +## Features + +**Capture & generate** + +- Drag & drop, file picker, **mobile camera capture** and **clipboard paste** (Ctrl/⌘+V) +- **Photos and documents**: JPG/PNG/WEBP/HEIC images plus **PDF, DOCX, PPTX, ODT/ODP/ODS, TXT, MD, CSV** +- Documents are read **in the browser**: PDF text layers are extracted, scanned PDF pages are rendered + as images for the vision model, DOCX/PPTX text, speaker notes and embedded images are pulled out. + The raw file never leaves your device. +- Up to 8 photos + 6 documents per deck (max 12 images reach the model); photos are resized to max + 1600 px and re-encoded as JPEG before upload +- Optional extra text (pasted notes) and a topic hint +- Easy / Medium / Hard difficulty and 8 / 10 / 12 / 15 activities per deck +- Fun staged loading state (`Reading your notes… → Finding the key ideas… → Building your activities…`) +- Friendly error cards with a **Retry** button that point at Settings when a key is needed + +**Language** + +- The notes' language is detected and **preserved end to end**: transcription, deck title, questions, + options, explanations, short-answer grading and later *regenerate* / *add more* all stay in that + language. Czech notes produce a Czech deck; the app never translates them to English. +- **Language guard:** after the vision step, the transcription is checked (script + language markers). + If it clearly came back in another language, `TEXT_MODEL` (which is much stronger multilingually) + restores it before any activities are written — the UI shows *“Restoring the notes' language…”*. + On good transcriptions the guard stays silent and costs nothing. +- The deck stores its language, so short answers are graded and feedback written in the same language. +- The Freezmo interface itself stays English (only your content is language-matched). + +**Study feed** + +- Full-screen vertical scroll-snap feed, one activity per screen +- Arrow-key / PageUp / PageDown / Home / End navigation, plus swipe on touch devices +- Progress bar, activity counter, XP counter and answer streak in the chrome +- Instant feedback with a short explanation for every activity, confetti when you finish + +**Activity types** (each one is its own React component, rendered from JSON by a `type` field) + +| Type | Interaction | +| --- | --- | +| `flashcard` | Tap to flip, then self-assess *I knew it / Not yet* | +| `multiple_choice` | 4 options, keys 1–4, correct answer + explanation | +| `true_false` | Tap the buttons **or** swipe the card right/left | +| `fill_blank` | Inline input (one typo forgiven) or a word bank | +| `matching` | Tap a term, then its definition; perfect match for full XP | +| `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 | + +**Library & folders** + +- Decks saved locally with title, subject, folder, activity count, date, play count and best score +- **Folders**: create named groups ("History", "Biology", …) from the sidebar or the create screen. + New decks freeze straight into the selected folder; anything unfiled lives in **Unorganized**. + Rename, delete (decks survive — they return to Unorganized) and move decks between folders +- Rename, delete (with confirm), regenerate, and **add more activities** to an existing deck +- Export a deck as JSON and import it back — the folder name travels with the file and is recreated + on import + +**Design** + +- **ChatGPT-style shell**: a persistent left **sidebar** (brand, a quiet "New deck" row, a *Folders* + list with per-folder counts, Settings/Dark mode pinned to the bottom). It **collapses** to a 68px + icon rail (remembered in `localStorage`) and becomes a slide-in **drawer** behind a hamburger on + phones; mobile also keeps the frosted header + iOS tab bar +- **One neutral palette, both themes**: light page `#ffffff` / sidebar `#f9f9f9` / border `#e5e5e5`; + dark page `#212121` / sidebar `#171717` / surfaces `#2f2f2f` / border `rgba(255,255,255,.1)`. + Text tiers are documented AA ratios (see below), and the cards fill the row with + `repeat(auto-fill, minmax(280px, 1fr))` — no stranded cards, no dead space +- Deck cards: title → muted metadata → neutral tags (difficulty carries the only colour: + green/amber/red) → summary → a single **Study** button, with everything else in the `⋯` menu +- **Frosted UI**: the sticky header, tab bar, sheets and panels use real `backdrop-filter` blur over + a faint viewport-fixed aurora wash (sky/lilac/mint) — depth without noise +- **Pill language**: primary CTAs are near-black in light mode and white in dark mode; secondaries + are outlined or ghost. Colour is reserved for meaning — activity tints (sky/lilac/mint/peach/rose), + correct/wrong feedback, key status +- Micro-interactions: 160–200ms colour/transform transitions, `active:scale-[0.985]`, skeleton + shimmer, hairline hover washes on every row and card +- **iOS-flavoured PWA**: standalone mode, safe-area insets, bottom tab bar, sheet-style dialogs with + a grabber, inset grouped lists, segmented controls, "Add to Home Screen" hint — see + [Install as an app](#install-as-an-app-pwa) +- Mobile-first, dark mode, keyboard accessible, focus rings, ARIA labels, `prefers-reduced-motion` support + +--- + +## Quick start + +Requirements: Node.js 20+ (tested on 24) and npm. ```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev +cd freezmo +./setup.sh # install + .env.local + production build +npm start # http://0.0.0.0:3207 ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +Or do it by hand (and use the dev server): -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +```bash +cd freezmo +npm install +cp .env.example .env.local # first time only +npm run dev # http://0.0.0.0:3207 +``` -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +`setup.sh` flags: `--dev` installs and prepares `.env.local` without building, `--no-build` skips the +build too. -## Learn More +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**. -To learn more about Next.js, take a look at the following resources: +`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. -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +Production: -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +```bash +npm run build +npm start # http://0.0.0.0:3207 +``` -## Deploy on Vercel +Production is also where the PWA comes alive: the service worker registers, the app can be installed +to the home screen and the `/offline` fallback works. In `npm run dev` none of that runs, so you +won't fight a stale cache while developing. -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +--- -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +## Ollama setup + +Freezmo talks to **`POST /api/chat`** and **`GET /api/tags`** of whatever URL is in +`OLLAMA_BASE_URL`. All calls happen in Next.js route handlers on the server — the browser never +talks to Ollama directly. + +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 | +| `server` | `OLLAMA_API_KEY` in `.env.local` | a single-user install | +| `local` | nothing — a daemon signed in with `ollama signin` | you run Ollama locally | + +### Mode 1 (default) — bring your own key + +```bash +OLLAMA_AUTH=user-key +OLLAMA_BASE_URL=https://ollama.com +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. + +Where the key lives: + +- **In the browser only.** It is stored in this browser's `localStorage` under `freezmo.settings.v1` + and sent with each API call in the `x-ollama-key` header. +- **Never on the server:** the route handlers read the header, use it for that one request and drop + it. It is not written to `.env.local`, a database, a cookie, a log line or a JS bundle. Restarting + the server does not remember any key, and every browser/device needs its own (or the same) key. +- You can remove it any time with **Remove key from this browser**. +- Because the browser hands the key to your own server, use HTTPS if the LAN is not trusted — on a + plain-HTTP LAN somebody sniffing traffic could read it. The key never goes to any third party. + +Keys are verified with a real (tiny, 1-token, 5-minute-cached) chat request, because model listing +on `ollama.com` is public and would happily accept a bogus key. + +### Mode 2 — local daemon (no key at all) + +Runs cloud models through your local Ollama daemon, signed in once with `ollama signin`. + +```bash +ollama # start the app/daemon +ollama signin # approve the printed link in any browser +ollama pull gemma4:31b-cloud # vision model, reads the photos of your notes +ollama pull gpt-oss:120b-cloud # text model, builds the activities and grades +``` + +`.env.local`: + +```bash +OLLAMA_AUTH=local +OLLAMA_BASE_URL=http://localhost:11434 +VISION_MODEL=gemma4:31b-cloud +TEXT_MODEL=gpt-oss:120b-cloud +``` + +Restart `npm run dev` after editing `.env.local`. Verify with the **⚙️ settings dialog** in the +app or `curl localhost:3207/api/health`. + +**Free tier.** `gemma4:31b-cloud` and `gpt-oss:120b-cloud` are included in the Ollama **Free** plan +(verified October 2026). Other free models: `gpt-oss:20b-cloud`, `nemotron-3-super:cloud`, +`nemotron-3-nano:30b-cloud`. Models such as `glm-5.3-flash:cloud`, `deepseek-v4.1-flash`, +`minimax-m2.7` and `mistral-large-3` reply with *"this model is not included in your free +usage…"* — Freezmo detects that (HTTP 402) and says so instead of pretending the key is broken. + +### Mode 3 — one key for the whole server + +```bash +OLLAMA_AUTH=server +OLLAMA_BASE_URL=https://ollama.com +OLLAMA_API_KEY=your_key_here +``` + +The key stays in `.env.local` (gitignored) and is never sent to the browser. Requests from any +device use it. + +#### Installing without root (user-space) + +The official installer needs root. If there is no `sudo` on the machine (like the box Freezmo was +built on), Ollama can be installed entirely under your home directory: + +```bash +cd ~ && mkdir -p .local/ollama && cd .local/ollama +# grab the tarball for your arch from https://github.com/ollama/ollama/releases/latest +# (ollama-linux-amd64.tar.zst) and extract it here, so you end up with +# ~/.local/ollama/bin/ollama and ~/.local/ollama/lib/ollama/ +# No zstd CLI on Debian? Stream it with Python's zstandard module instead of tar --zstd. + +export PATH="$HOME/.local/ollama/bin:$PATH" # add to ~/.profile too +ollama serve & # daemon on 127.0.0.1:11434 +ollama signin +ollama pull gemma4:31b-cloud && ollama pull gpt-oss:120b-cloud +``` + +On the development machine the daemon runs as a **systemd user service** +(`~/.config/systemd/user/ollama.service`): + +```bash +systemctl --user status ollama # is it up? +systemctl --user restart ollama # restart it +journalctl --user -u ollama -f # logs +``` + +If the user has lingering enabled (`loginctl show-user $USER | grep Linger`) the service also +starts at boot with nobody logged in; otherwise it starts when you log in. Use the system-wide +installer if you want a boot service without a login session. + +### Changing models + +Any model that your Ollama instance exposes works. Edit `.env.local`: + +```bash +VISION_MODEL= +TEXT_MODEL= +``` + +Current cloud models that work well (verified against the Ollama cloud in October 2026): + +| Purpose | Model | Free tier? | Notes | +| --- | --- | --- | --- | +| Vision (default) | `gemma4:31b-cloud` | ✅ | Native multimodal, 256K context, current generation | +| Text (default) | `gpt-oss:120b-cloud` | ✅ | OpenAI open-weights, excellent at strict JSON, quick | +| Text, light | `gpt-oss:20b-cloud` | ✅ | Smaller/faster, slightly weaker | +| Text, cheap | `nemotron-3-super:cloud` | ✅ | 120B MoE with 12B active, very inexpensive | +| Text, smallest | `nemotron-3-nano:30b-cloud` | ✅ | Lowest cost option | +| Text, faster | `glm-5.3-flash:cloud` | 💳 credits | 18B active, 1M context, very fast | +| Text, best quality | `kimi-k3:cloud` | 💳 credits | Strongest open-weight model, slowest/most expensive | +| Vision, larger | `mistral-large-3:675b`, `deepseek-v4.1-flash` | 💳 credits | Multimodal alternatives | + +> The previously suggested `qwen3-vl:235b-cloud` has been retired. The current defaults — +> `gemma4:31b-cloud` (vision) and `gpt-oss:120b-cloud` (text) — are both free-tier friendly and +> were verified end-to-end on a real photo of notes. The full list is one click away in the +> settings dialog or at . + +If your vision model is also good at generation you can point both variables at the same model — +the two pipeline steps stay separate either way. + +### Checking the connection + +- **In the app:** click the ⚙️ button — it shows mode, base URL, API key status, latency and + whether each configured model is available, with a hint for what to fix. +- **From the shell:** `curl http://localhost:3207/api/health` +- **Directly against Ollama:** `curl http://localhost:11434/api/tags` + +--- + +## Rate limits + +Generation is expensive (two model calls per deck), so the API routes are limited per client IP with +a sliding window. Everything is configurable in `.env.local`: + +| Route | Default | Env var | +| --- | --- | --- | +| `POST /api/generate` | 12 per 5 minutes | `RATE_LIMIT_GENERATE` | +| `POST /api/grade` | 40 per 5 minutes | `RATE_LIMIT_GRADE` | +| `GET /api/health` | 90 per 5 minutes | `RATE_LIMIT_HEALTH` | +| window for all of them | 5 minutes | `RATE_LIMIT_WINDOW_MS` | + +When a limit is hit the route answers **429** with a `Retry-After` header and a friendly message +(“Too many requests — try again in 42s”); the UI shows that message and keeps the Retry button. The +client IP is taken from `x-forwarded-for` / `x-real-ip` when present (reverse proxy), otherwise all +local clients share one bucket. + +Caveats, stated honestly: counters live **in memory**, so they reset on restart and are not shared +between multiple instances or Node workers. That is fine for a self-hosted single-instance app; if +you put Freezmo behind a reverse proxy, keep the proxy's own limits in place too. + +Model names are normalised automatically: when the base URL points at `ollama.com`, Freezmo strips +the local routing suffix (`gemma4:31b-cloud` → `gemma4:31b`, `gpt-oss:120b-cloud` → `gpt-oss:120b`) +because the cloud API expects plain library names, while a local daemon wants the `-cloud` form. +The same `.env` therefore works in every mode. + +## Running on your network / from your phone + +The dev and start scripts already bind to `0.0.0.0` on port **3207**: + +```bash +npm run dev # next dev -H 0.0.0.0 -p 3207 +npm start # next start -H 0.0.0.0 -p 3207 +``` + +1. Find your machine's LAN IP: `hostname -I` (Linux) or `ipconfig getifaddr en0` (macOS). +2. On your phone open `http://:3207`. +3. Allow port 3207 through the firewall if needed + (e.g. `sudo ufw allow 3207/tcp`, or the equivalent Windows rule). +4. Ollama only needs to be reachable from the Next.js server, not from the phone. + +Notes for phones: + +- **The camera button works over plain HTTP** because it uses ``, + not `getUserMedia` (which would require HTTPS). Tapping **Camera** opens the phone's camera app + and the photo comes back as a file. +- Clipboard paste works best on desktop; on mobile use the camera or photo picker. +- Add the page to your home screen for a full-screen, app-like experience. + +--- + +## Folders + +Decks are organised into folders — the same way you'd file paper notes. + +- **Unorganized** is where every deck starts. It is virtual: it simply means "no folder yet". +- Create a folder from the library's folder bar (**New folder**) or from the create screen's + **Freeze into folder** row. Names are unique, case-insensitive, and can be renamed later. +- **Freezing a deck into a folder:** pick the folder before generating (create screen) or with the + folder row on the create screen; or use a deck's **⋯ menu → Folder** chips afterwards. The library + shows which folder each deck is in, and groups the list by folder. +- **Filtering:** the folder bar filters the library to one folder; *All decks* shows every folder as + its own section, Unorganized last, with per-folder counts on each chip. +- **Deleting a folder never deletes decks** — they move back to Unorganized. The confirmation says + exactly how many decks will be re-homed. +- Folders live beside the decks in `localStorage` (`freezmo.folders.v1`), so they are per-browser + and need no account. Exporting a deck records its folder name, and importing recreates or reuses + that folder. + +## Install as an app (PWA) + +Freezmo ships as an installable Progressive Web App with iOS in mind. + +**iOS / iPadOS (Safari):** open the app → **Share** → **Add to Home Screen**. The app shows a +one-time hint explaining exactly this. It launches full-screen in standalone mode (no browser +chrome), respects the notch/home-indicator safe areas, and has an iOS-style bottom tab bar +(Library · New deck · Settings) with sheet-style dialogs. + +**Android (Chrome):** Chrome offers "Install app" from the ⋮ menu; Freezmo also works offline for +the parts that don't need a model. + +What's included: + +| Piece | Detail | +| --- | --- | +| `app/manifest.ts` | `display: standalone`, portrait, theme/background colours, app shortcuts | +| Icons | 192/512 px, a maskable 512 px icon, and 180/167/152 px `apple-touch-icon`s | +| iOS meta | `apple-mobile-web-app-capable`, `-title`, `-status-bar-style`, `viewport-fit=cover` | +| Service worker | `public/sw.js`, **production only**: cached app shell, stale-while-revalidate for static assets, network-first navigations with an `/offline` fallback. `/api/*` is never cached | +| Offline page | `/offline` — "your decks are still here, reconnect to generate" | +| Safe areas | `pb-safe` / `pt-safe` utilities plus `pb-tabbar` so nothing hides behind the tab bar | + +The icons are generated by `scripts/make-icons.mjs` (headless Chromium); it is a one-off tool and +is not part of the app's dependencies. + +## Security notes + +An honest review of what Freezmo does and does not defend against. It is a single-user, +self-hosted study app with no accounts, no database and no server-side state. + +**What is hardened** + +- **No secrets on the client.** The API key lives only in the browser's `localStorage` and travels + in the `x-ollama-key` header per request. Route handlers use it for that one call and discard it — + no disk, no log, no cookie, no environment write. Verified by grepping the whole project and the + production `.next` output for a test key, and by restarting the server (which remembers nothing). +- **No server-side injection surface.** The Ollama endpoint and model names come only from the + server's `.env`; the client cannot point the server at another host. Model output is validated with + zod and rendered as text — there is no `dangerouslySetInnerHTML` except one static theme script, + so a deck cannot inject markup or script (React escapes everything). +- **Key-header sanitisation.** Keys from the client are stripped of control characters/whitespace + and capped at 512 chars before they are used in an outbound header, so a crafted key cannot inject + headers. Verified with a raw-socket CRLF attempt. +- **Cross-origin requests are refused** on `/api/generate` and `/api/grade` (`Origin` must match the + host), so a random website in your browser cannot make your Freezmo spend your rate limit or, in + `server` auth mode, your credits. Origin-less clients (curl) still work. +- **Body-size limits** (32 MB for generation, 64 KB for grading) are rejected with 413 before any + parsing, on top of the zod limits on image count, text length and field sizes. +- **Rate limiting** per client IP; see [Rate limits](#rate-limits). Auth is checked *before* parsing + request bodies, so unauthenticated traffic costs almost nothing. +- **No CSRF surface**: no cookies, no sessions, no server-side mutations. +- **Prompt injection is low impact by design.** Your notes could contain text that tries to + manipulate the model, but the model can only return a JSON deck (validated per activity, dropped if + invalid) — it has no tools, no file access and no network access beyond the model call. + +**Known limitations (by design)** + +- The key travels from your browser to your own Freezmo server, so on an untrusted network it needs + HTTPS to stay private. On a plain-HTTP LAN someone sniffing traffic could read it, along with your + notes. +- Anything stored in `localStorage` is readable by JavaScript on the same origin. Freezmo has no XSS + vector today, but that is the standard risk of browser-stored credentials. +- Rate limiting trusts `x-forwarded-for` when present (correct behind a proxy, spoofable when + exposed directly) and keeps counters in memory: single instance only, reset on restart. +- Ollama cloud usage is billed to whoever's key is used, so treat a shared Freezmo URL as shared key + access in `server` mode. +- No accounts means no per-user isolation: anyone who can reach the URL can use the app (and, in + `user-key` mode, only with their own key). + +## Supported input formats + +Everything is parsed **client-side**, so documents are never uploaded — only the extracted text and +page images are sent to `/api/generate`. + +| Format | What Freezmo reads | Sent to the model as | +| --- | --- | --- | +| JPG / PNG / WEBP / HEIC / GIF | the photo itself, resized to 1600 px JPEG | images (vision model) | +| **PDF, text layer** | text of every page (up to 80 pages) | text | +| **PDF, scanned / image-only** | pages with little or no text are rendered to images | images (vision model) | +| **DOCX** | document text + embedded images | text + images | +| **PPTX** | slide text + speaker notes + slide images | text + images | +| **ODT / ODP / ODS** | text from the OpenDocument `content.xml` | text | +| TXT / MD / CSV / TSV / LOG | the file's text | text | + +Limits and behaviour worth knowing: + +- up to 6 documents and 8 photos per deck, 40 MB per document, 20 MB per photo +- at most 12 images are sent (photos, rendered scanned pages and images embedded in documents) +- each document is capped at 60 000 characters and the merged text at 100 000; longer documents show + a “truncated” note in the UI +- the activity generator reads up to 48 000 characters of notes per deck — plenty for lecture notes, + but a whole book will only be sampled +- password-protected or corrupted files report a clear error on their card and can be removed + +## How it works + +``` + photos + text /api/generate + ─────────────┐ + ▼ + ┌──────────────────────────┐ step 1 (if images) ┌───────────────────────────┐ + │ browser: resize/compress │ ─────────────────────► │ VISION_MODEL │ + │ max 1600px JPEG data URL │ │ transcribe + extract │ + └──────────────────────────┘ │ notes/concepts/formulas │ + │ └────────────┬──────────────┘ + │ NDJSON progress stream │ clean structured text + ▼ ▼ + ┌──────────────────────────┐ ┌───────────────────────────┐ + │ browser: study feed │ ◄───────────────────── │ TEXT_MODEL │ + │ JSON validated by zod │ deck JSON │ design 8–15 activities │ + └──────────────────────────┘ └───────────────────────────┘ +``` + +- The prompts live in [`lib/prompts.ts`](lib/prompts.ts). They instruct the model to use **only the + student's notes**, never invent facts, vary the activity types, write a short explanation for each + activity and answer with JSON only. +- Every response is parsed (markdown ``` fences stripped), validated with **zod** + ([`lib/schemas.ts`](lib/schemas.ts)) and — if validation fails — automatically retried **once** + with the validation error fed back to the model (`lib/ai.ts`). +- Individual broken activities are dropped rather than failing the whole deck; if fewer than half + the requested activities survive, the step is retried too. +- Requests use `stream: false` with per-request timeouts and a hard overall deadline + (two sequential model calls). Errors surface as friendly messages + ("Ollama isn't reachable, check your settings") with a retry button. +- `POST /api/generate` answers with newline-delimited JSON (`stage` / `deck` / `error` events) so the + UI can show real progress between the vision and generation steps. + +--- + +## Project structure + +``` +app/ + layout.tsx root layout, iOS meta tags + theme bootstrap script + manifest.ts web app manifest (installable PWA) + offline/page.tsx offline fallback served by the service worker + globals.css Tailwind v4 theme, frosty palette, animations, reduced-motion + page.tsx library ("Your icebox") + create/page.tsx upload screen + deck/[id]/page.tsx study feed for a saved deck + api/generate/route.ts images/text in → NDJSON progress → deck JSON out + api/grade/route.ts short-answer grading + api/health/route.ts Ollama reachability + model availability +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 + TabBar.tsx InstallHint.tsx ServiceWorkerRegistrar.tsx FolderBar.tsx + LibraryScreen.tsx SettingsDialog.tsx AppHeader.tsx ThemeToggle.tsx icons.tsx ui.tsx +lib/ + ollama.ts server-only HTTP client (timeouts, auth, error mapping) + prompts.ts the vision, generation, grading and repair prompts + ai.ts the three pipeline steps with zod validation + retry + schemas.ts zod schemas and shared types + config.ts env config, cloud/direct model-name normalisation + documents.ts client-side PDF/DOCX/PPTX/ODT/text extraction + folders.ts folder library (create/rename/delete) in localStorage + settings.ts browser-side settings (the user's API key) + auth headers + use-health.ts client hook for /api/health, re-runs when the key changes + rate-limit.ts in-memory sliding-window limiter for the API routes + http-guard.ts same-origin check, body-size cap, API-key sanitiser + language.ts script/language checks that catch translated transcriptions + ui-events.ts tiny event bus (any screen can open the settings dialog) + storage.ts localStorage deck library, import/export + image.ts client-side validation, resize and compression + client-api.ts browser client for the API routes (NDJSON reader) + xp.ts utils.ts deck-ops.ts confetti.ts +public/ + sw.js service worker (production only) + icons/ PWA + iOS icons and splash screens +scripts/ + copy-pdf-worker.mjs copies the pdf.js worker to /public (runs on predev/prebuild) + make-icons.mjs regenerates the PWA/iOS icons (one-off tool) +``` + +--- + +## API routes + +| Route | Body | Returns | +| --- | --- | --- | +| `POST /api/generate` | `{ images?: string[], text?, difficulty, count?, focus?, language?, mode?, existing? }` | NDJSON stream: `{type:"stage"}`, `{type:"deck",deck,meta}`, `{type:"error"}` | +| `POST /api/grade` | `{ question, referenceAnswer, keyPoints, userAnswer, language? }` | `{ correct, score, feedback, missing }` | +| `GET /api/health` | – | `{ ok, reachable, mode, baseUrl, hasApiKey, vision, text, latencyMs, modelCount, error }` | + +All three routes accept an optional **`x-ollama-key`** header (the browser's own key; required in +`user-key` auth mode). Missing keys answer **401** with `kind: "needs_key"`, a rejected key answers +`kind: "auth"` (or `kind: "no_credits"` for HTTP 402), and exceeding a rate limit answers **429** +with `Retry-After`. Keys are used for the single request and discarded. + +Images are sent as base64 data URLs; the server strips the prefix before handing them to Ollama. +Uploads are validated for type (photos and the document formats above), count (max 8 photos, +6 documents, 12 images) and size (max ~8 MB per image after compression, 40 MB per document) and the +client compresses aggressively, so a typical upload is 1–3 MB. `language` is optional on both routes: +the vision step fills it in automatically, and the UI passes the stored deck language for grading, +regeneration and “add more”. + +--- + +## Scripts + +| Script | What it does | +| --- | --- | +| `npm run dev` | `next dev -H 0.0.0.0 -p 3207` | +| `npm run build` | production build (type-check + lint) | +| `npm start` | `next start -H 0.0.0.0 -p 3207` | +| `npm run lint` | ESLint | +| `node scripts/make-icons.mjs` | regenerate the PWA/iOS icons (needs a Playwright Chromium) | +| `node scripts/copy-pdf-worker.mjs` | copy the pdf.js worker (also runs on predev/prebuild) | + +--- + +## Data, storage and privacy + +- Decks live in **`localStorage`** under `freezmo.decks.v1`, folders under `freezmo.folders.v1` (no + accounts, no server database). + Stored per deck: title, subject, summary, language, difficulty, the extracted **source text**, + activities, play count and best score. +- **Original files are never stored or uploaded** — photos and documents are parsed in the browser, + and only the extracted text (plus page images for the vision model) is sent to your Ollama + endpoint. That is why *regenerate* and *add more activities* work from the source text rather than + the original files. Imported decks that contain no source text can't be regenerated; the UI says so. +- Documents are parsed locally by `pdfjs-dist` (PDF), `mammoth` (DOCX), `jszip` (PPTX/OpenDocument) + and the browser's own `File.text()` — nothing is uploaded for parsing. +- Images and rendered PDF pages are sent to whatever Ollama endpoint you configure. In local-daemon + mode they go to your machine first; in direct-cloud mode they are processed by Ollama's cloud. +- In the default `user-key` mode your Ollama API key is also kept in `localStorage` (key + `freezmo.settings.v1`) and sent with each API call so the server can forward it to Ollama. It is + never written to the server, a log, a cookie or the environment — see + [Ollama setup](#ollama-setup) for the full story. +- Clearing browser data deletes the library and the saved key. Use **Export JSON** for anything you + want to keep. + +--- + +## Troubleshooting + +| Symptom | Fix | +| --- | --- | +| “Ollama isn't reachable at http://localhost:11434” | Start the daemon (`ollama serve`, or `systemctl --user start ollama` for a user-space install), or fix `OLLAMA_BASE_URL`. Check `curl http://localhost:11434/api/tags`. | +| 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. | +| “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`. | +| “set to OLLAMA_AUTH=local but points at https://ollama.com” | Fix the combination: use `OLLAMA_AUTH=user-key`/`server` for the cloud URL, or point `OLLAMA_BASE_URL` at `http://localhost:11434`. | +| Settings dialog says a model is “not found” | Local daemon: pull it once — `ollama pull gemma4:31b-cloud` (cloud pulls are just manifests). Direct cloud: check the model name and that your key is valid. | +| 401/403 from `https://ollama.com` | Create a key at and put it in `OLLAMA_API_KEY` (no quotes), then restart. | +| Daemon logs / it keeps dying | User-space install: `journalctl --user -u ollama -f`. Manual start: `~/.local/ollama/bin/ollama serve`. | +| Generation times out | Use fewer/smaller photos, choose a faster `TEXT_MODEL` (e.g. `gpt-oss:20b-cloud`), or raise `OLLAMA_TIMEOUT_MS`. | +| Responses are slow | Thinking models spend tokens on reasoning. Set `OLLAMA_THINK=false` to send `think: false`, and/or use a smaller model. | +| “did not match the expected format” | Freezmo retried once automatically. If it still fails, the model is poor at JSON — switch to a stronger `TEXT_MODEL` (e.g. `gpt-oss:120b-cloud`). | +| Blank/boxed icons on Linux | The app chrome uses inline SVG, but activity badges use emoji. Install an emoji font (`fonts-noto-color-emoji`). | +| Port 3207 already in use | `lsof -i :3207` and stop the other process, or change the port in both scripts in `package.json`. | +| Camera button missing on mobile | It's in the *Add your notes* card; some in-app browsers hide file inputs — open the page in Chrome/Safari directly. | +| Hydration warning in dev | Usually a stale dev artifact: hard-reload. Decks load in `useEffect`/`useSyncExternalStore`, so no library content is server-rendered. | + +--- + +## Decisions and notes + +Things I chose while building, with the reasoning: + +1. **Defaults follow the current Ollama catalogue and the free tier.** The suggested + `qwen3-vl:235b-cloud` has been retired, so the defaults are `gemma4:31b-cloud` (vision) and + `gpt-oss:120b-cloud` (text/JSON/grading) — both verified as included in the free plan on a real + photo of notes. Paid/credit models such as `glm-5.3-flash:cloud` and `kimi-k3:cloud` are + documented as alternatives. +2. **One `.env` for both modes.** Direct-cloud names differ from daemon names (`gemma4:31b` vs + `gemma4:31b-cloud`), so `lib/config.ts` strips the `-cloud` / `:cloud` suffix automatically when + `OLLAMA_BASE_URL` points at `ollama.com`. +3. **Two separable AI steps.** Photos always go through `VISION_MODEL` for transcription/extraction; + the structured text then goes to `TEXT_MODEL`. Pointing both variables at one model works too. + Text-only requests skip the vision step entirely. +4. **NDJSON progress stream instead of a single long request.** The two model calls are sequential + and slow, so `/api/generate` streams `stage` events; the UI shows real stages rather than a fake + timer. `stream: false` is still used for each Ollama call for simplicity and reliability. +5. **zod + one repair retry, plus per-activity salvage.** A single bad activity shouldn't cost you + the whole deck: valid activities are kept, and the repair round is only used when too few survive. +6. **Flashcards count as "correct" via self-assessment** (I knew it / Not yet) so that scoring, XP + and "retry missed ones" apply to every activity type. +7. **Fill-in-the-blank is graded locally** (case/punctuation-insensitive, one typo forgiven, + `/` alternatives in the answer) — no round trip needed. Only short answers call `/api/grade`. +8. **Matching requires a perfect match for full XP**, otherwise it's marked as missed with an + explanation of how many wrong attempts you made. +9. **Source text is stored with each deck** so *regenerate* and *add more* work without keeping the + original photos (storage/privacy win). Imported decks have no source text, so those two actions + are disabled for them. +10. **XP is assigned server-side** (easy 10 / medium 15 / hard 20, +5 for matching, ordering and + short answer) so the model can't invent inconsistent rewards. +11. **Documents are parsed in the browser, not on the server.** pdf.js, mammoth and jszip run + client-side, so the raw file never leaves the device and the API stays a thin text/image pipe. + Scanned PDF pages are rendered to JPEG for the vision model, while PDFs with a text layer skip + the vision step entirely (faster and exact). +12. **The notes' language is detected once and then carried everywhere.** The vision step reports a + `language` field, the deck stores it, and generation, grading, regenerate and add-more all + receive it explicitly — plus “never translate” rules in every prompt. Verified with Czech + notes across all three input paths (text PDF, scanned PDF, DOCX + PPTX). +13. **Keys are per browser and never stored server-side.** Freezmo defaults to + `OLLAMA_AUTH=user-key`: the key is typed into the UI, kept in `localStorage` and sent in the + `x-ollama-key` header, and the route handlers use it for that request only — nothing is written + to disk, logs, cookies or the environment (verified by grepping the whole project for a test key + and by restarting the server). `server` and `local` modes remain available by env var. +14. **Key verification uses a 1-token chat probe, cached for 5 minutes.** Model listing on + `ollama.com` is public, so a bogus key would look valid; only a real authenticated request can + tell. HTTP 402 (“not included in your free usage”) is reported as a *plan* problem rather than a + broken key. +15. **Rate limits are per client IP, in memory, and generous by default** (12 decks / 5 min, 40 + grades / 5 min). They exist to stop runaway loops and accidental abuse, not to be a security + boundary — the README says so, and the counters are intentionally not persisted. +16. **A misconfiguration is caught early:** `OLLAMA_AUTH=local` combined with `https://ollama.com` + returns an explanatory error instead of a confusing 401. +17. **Folders are a first-class, non-destructive idea.** `UNORGANIZED_ID` is virtual (decks simply + have no `folderId`), deleting a folder re-homes its decks instead of deleting them, and folder + names travel in exports so an imported deck lands where it came from. 13 browser checks cover + create → freeze → move → rename → delete → reload → import. +18. **The visual language is glassmorphism, with restraint.** One accent colour drives the buttons + and highlights; the backdrop is a soft aurora wash; surfaces are frosted and translucent. Colour + appears in activity tints and semantic feedback only — pretty, but still readable and quiet. +19. **Custom CSS lives in `@layer components`, and glass uses Tailwind's backdrop utilities.** + Two build-level traps, both found by looking at real screenshots: unlayered rules beat Tailwind + utilities (which silently broke `fixed` on the feed), and a hand-written `backdrop-filter` gets + minified down to `-webkit-` only — which painted no blur at all. `@apply backdrop-blur-*` + emits both properties correctly. +20. **There is no demo/sample deck.** Every activity comes from the user's own notes; the empty + state points at the create flow and Settings instead of shipping fake content. Browser tests seed + a fixture deck into `localStorage` rather than relying on app code. +21. **Dark mode is token-driven, and contrast is audited.** Text colours come from + `--text` / `--text-muted` / `--text-subtle` (never hardcoded), and a `contrast.mjs` audit checks + every pairing at WCAG AA — currently **20/20 pairs pass** (e.g. dark secondary `#b4b4b4` on + `#212121` = 7.8:1). Difficulty colours were darkened until they cleared 4.5:1 on their pills. + This was a real bug: an earlier edit silently failed, so `.dark` never overrode `--text`, leaving + near-black text on a dark page. +22. **The dashboard follows a neutral, ChatGPT-style system.** Near-white/near-black surfaces, + hairline borders, pill CTAs, 18px radii and restrained shadows; the frost survives in the chrome + (header, tab bar, sheets) rather than in every card, which keeps text crisp and the layout + premium. Deck cards got real hierarchy: title → muted metadata → tags → summary → prominent + Study CTA, with secondary actions behind a quiet menu. +23. **One fixed backdrop layer instead of per-page blobs.** The decorative aurora used to be + rendered inside the content column, so on wide screens it was clipped into a visible hard edge. + It now lives in a single `position: fixed` layer (`.app-backdrop`, `z-index: -10`) with very low + opacities, and `body`'s colour matches its bottom so mobile overscroll shows no seam. +24. **The UI was deliberately made quieter.** One accent colour (`accent-*`), a desaturated + `frost-*` scale, hairline borders and flat surfaces replaced the saturated gradients and the + seven-colour activity badges. Semantic colours (green/amber/red) survive only where they carry + meaning: correct/wrong feedback, key status, rate limits. +25. **PWA instead of a native wrapper.** A manifest, iOS meta tags, generated icons, a + production-only service worker, an offline page and a standalone-aware layout give the + install-to-home-screen experience without adding a runtime dependency — and the app still works + as a plain website. +26. **The API is defended like a LAN service.** Cross-origin POSTs are refused, bodies are capped + before parsing, keys are sanitised before they reach a header, and auth is checked before any + body work. See [Security notes](#security-notes) for the full list and the known limitations. +27. **A language guard repairs accidentally translated transcriptions.** `TEXT_MODEL` (`gpt-oss:120b`) + is the strongest multilingual model in the free tier, so when the transcription check + (`lib/language.ts`: expected script vs. actual script, distinctive diacritics, English stopword + 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). +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 + model at all, so those decks are produced by `gpt-oss:120b` start to finish. +13. **No auth, no server storage.** Everything personal lives in `localStorage`; the only server + state is the request in flight. +14. **`canvas-confetti` and `framer-motion` are the only UI dependencies.** Document parsing adds + `pdfjs-dist`, `mammoth` and `jszip`, all dynamically imported (the `/create` page grew ~3 kB; + the parsers load only when a document is added). Animations are skipped when + `prefers-reduced-motion` is set. +15. **Tailwind v4 class-based dark mode** with a pre-paint script so there is no theme flash, and + `suppressHydrationWarning` on ``. +16. **Testing.** The pipeline was first tested against a mock Ollama HTTP server (including a + deliberately invalid first response to exercise the repair path) with the UI driven by headless + Chromium, then re-run against the real Ollama cloud. Current suites: 13/13 document extraction + (one fixture per format, including a text PDF and a scanned PDF), 15/15 language preservation, + 24/24 activity/scoring/feed checks and 11/11 real-model browser checks. diff --git a/app/api/generate/route.ts b/app/api/generate/route.ts new file mode 100644 index 0000000..4eeea45 --- /dev/null +++ b/app/api/generate/route.ts @@ -0,0 +1,226 @@ +import { z } from "zod"; + +import { extractNotes, generateActivities, notesToText, type PipelineStage } from "@/lib/ai"; +import { AUTH_ERROR_MESSAGES, authErrorKind, getServerConfig, resolveAuth, resolveModelName } from "@/lib/config"; +import { friendlyErrorMessage, OllamaError } from "@/lib/ollama"; +import { rejectCrossOrigin, rejectOversizedBody } from "@/lib/http-guard"; +import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit"; +import { difficultySchema } from "@/lib/schemas"; +import { uid } from "@/lib/utils"; +import { xpForActivity } from "@/lib/xp"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +export const maxDuration = 300; + +/** ~8 MB per image after client-side resizing; up to 16 images (photos + PDF pages). */ +const MAX_IMAGE_CHARS = 8_000_000; + +const requestSchema = z.object({ + images: z.array(z.string().min(32).max(MAX_IMAGE_CHARS)).max(16).optional(), + text: z.string().max(120_000).optional(), + difficulty: difficultySchema.default("medium"), + count: z.number().int().min(4).max(18).optional(), + focus: z.string().max(300).optional(), + /** Language the deck must be written in (usually detected by the vision step). */ + language: z.string().max(60).optional(), + mode: z.enum(["create", "more"]).default("create"), + existing: z + .object({ + title: z.string().max(120), + types: z.array(z.string()).max(30), + count: z.number().int().min(0).max(100), + }) + .optional(), +}); + +type StreamEvent = + | { type: "stage"; stage: PipelineStage; message: string } + | { type: "deck"; deck: unknown; meta: Record } + | { type: "error"; error: string; kind?: string }; + +function encoder() { + const enc = new TextEncoder(); + return (event: StreamEvent) => enc.encode(`${JSON.stringify(event)}\n`); +} + +/** Generous but finite: ~16 compressed images plus JSON overhead. */ +const MAX_BODY_BYTES = 33554432; + +export async function POST(request: Request) { + const crossOrigin = rejectCrossOrigin(request); + if (crossOrigin) return crossOrigin; + const oversized = rejectOversizedBody(request, MAX_BODY_BYTES); + if (oversized) return oversized; + + const config = getServerConfig(); + + // Cheap requests are rejected before any work happens. + const limit = rateLimit(`generate:${clientKey(request)}`, config.rateLimits.generate, config.rateLimits.windowMs); + if (!limit.allowed) return rateLimitResponse(limit); + + const auth = resolveAuth(config, request); + if ("error" in auth) { + return Response.json( + { error: AUTH_ERROR_MESSAGES[auth.error], kind: authErrorKind(auth.error) }, + { status: 401 }, + ); + } + + let payload: unknown; + try { + payload = await request.json(); + } catch { + return Response.json({ error: "Request body was not valid JSON." }, { status: 400 }); + } + + const parsed = requestSchema.safeParse(payload); + if (!parsed.success) { + return Response.json( + { + error: `Invalid request: ${parsed.error.issues + .slice(0, 3) + .map((issue) => `${issue.path.join(".") || "body"} ${issue.message}`) + .join("; ")}`, + }, + { status: 400 }, + ); + } + + const { images, text, difficulty, count, focus, mode, existing, language } = parsed.data; + const imageList = (images ?? []).map(stripDataUrlPrefix).filter(Boolean); + + if (imageList.length === 0 && !text?.trim()) { + return Response.json( + { error: "Add at least one photo of your notes or paste some text to build a deck." }, + { status: 400 }, + ); + } + + const requestedCount = count ?? config.defaultActivityCount; + const encode = encoder(); + const abort = new AbortController(); + // A single hard deadline for the whole pipeline (two sequential model calls). + const globalTimer = setTimeout(() => abort.abort(), config.timeoutMs * 2); + + const stream = new ReadableStream({ + async start(controller) { + const send = (event: StreamEvent) => { + try { + controller.enqueue(encode(event)); + } catch { + /* client went away */ + } + }; + + try { + let sourceText: string; + let deckTitle: string | undefined; + let subject: string | undefined; + let summary: string | undefined; + let detectedLanguage: string | undefined = language; + let repaired = false; + + if (imageList.length > 0) { + send({ type: "stage", stage: "vision", message: "Reading your notes…" }); + const extraction = await extractNotes({ + model: config.visionModel, + repairModel: config.textModel, + auth: auth.auth, + images: imageList, + text, + signal: abort.signal, + onProgress: (message) => send({ type: "stage", stage: "vision", message }), + }); + sourceText = notesToText(extraction.notes); + deckTitle = extraction.notes.title; + subject = extraction.notes.subject; + summary = extraction.notes.summary; + detectedLanguage = extraction.notes.language || language; + repaired = repaired || extraction.repaired || extraction.languageRepaired; + send({ + type: "stage", + stage: "generation", + message: "Finding the key ideas…", + }); + } else { + sourceText = text!.trim(); + send({ type: "stage", stage: "generation", message: "Finding the key ideas…" }); + } + + const generated = await generateActivities({ + model: config.textModel, + auth: auth.auth, + notes: sourceText, + difficulty, + count: requestedCount, + language: detectedLanguage, + existing: mode === "more" ? existing : undefined, + focus, + signal: abort.signal, + }); + + const activities = generated.deck.activities.map((activity) => ({ + ...activity, + id: uid("act"), + xp: xpForActivity(activity.type, difficulty), + })); + + send({ + type: "deck", + deck: { + title: generated.deck.title || deckTitle || "Your study deck", + subject: generated.deck.subject ?? subject, + summary: generated.deck.summary ?? summary, + language: generated.deck.language || detectedLanguage, + sourceText, + difficulty, + activities, + }, + meta: { + requested: generated.requested, + generated: activities.length, + repaired, + language: generated.deck.language || detectedLanguage || null, + visionModel: imageList.length ? resolveModelName(config.visionModel, auth.auth.directCloud) : null, + textModel: resolveModelName(config.textModel, auth.auth.directCloud), + }, + }); + } catch (error) { + const message = friendlyErrorMessage(error, auth.auth.baseUrl); + // Never log the request body: it may contain the user's notes. + console.error("[api/generate]", error instanceof OllamaError ? `${error.kind}: ${error.message}` : error); + send({ + type: "error", + error: message, + kind: error instanceof OllamaError ? error.kind : "unknown", + }); + } finally { + clearTimeout(globalTimer); + try { + controller.close(); + } catch { + /* already closed */ + } + } + }, + cancel() { + clearTimeout(globalTimer); + abort.abort(); + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "application/x-ndjson; charset=utf-8", + "Cache-Control": "no-store, no-transform", + Connection: "keep-alive", + }, + }); +} + +function stripDataUrlPrefix(value: string): string { + const commaIndex = value.indexOf(","); + if (value.startsWith("data:") && commaIndex !== -1) return value.slice(commaIndex + 1); + return value; +} diff --git a/app/api/grade/route.ts b/app/api/grade/route.ts new file mode 100644 index 0000000..ff8d0b0 --- /dev/null +++ b/app/api/grade/route.ts @@ -0,0 +1,71 @@ +import { z } from "zod"; + +import { gradeShortAnswer } from "@/lib/ai"; +import { AUTH_ERROR_MESSAGES, authErrorKind, getServerConfig, resolveAuth } from "@/lib/config"; +import { friendlyErrorMessage, OllamaError } from "@/lib/ollama"; +import { rejectCrossOrigin, rejectOversizedBody } from "@/lib/http-guard"; +import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const requestSchema = z.object({ + question: z.string().min(1).max(1000), + referenceAnswer: z.string().min(1).max(2000), + keyPoints: z.array(z.string().max(300)).max(8).default([]), + userAnswer: z.string().min(1).max(2000), + /** Language of the deck so feedback comes back in the same language. */ + language: z.string().max(60).optional(), +}); + +/** Generous but finite: ~16 compressed images plus JSON overhead. */ +const MAX_BODY_BYTES = 65536; + +export async function POST(request: Request) { + const crossOrigin = rejectCrossOrigin(request); + if (crossOrigin) return crossOrigin; + const oversized = rejectOversizedBody(request, MAX_BODY_BYTES); + if (oversized) return oversized; + + const config = getServerConfig(); + + const limit = rateLimit(`grade:${clientKey(request)}`, config.rateLimits.grade, config.rateLimits.windowMs); + if (!limit.allowed) return rateLimitResponse(limit); + + const auth = resolveAuth(config, request); + if ("error" in auth) { + return Response.json( + { error: AUTH_ERROR_MESSAGES[auth.error], kind: authErrorKind(auth.error) }, + { status: 401 }, + ); + } + + let payload: unknown; + try { + payload = await request.json(); + } catch { + return Response.json({ error: "Request body was not valid JSON." }, { status: 400 }); + } + + const parsed = requestSchema.safeParse(payload); + if (!parsed.success) { + return Response.json({ error: "Invalid grading request." }, { status: 400 }); + } + + try { + const result = await gradeShortAnswer({ + model: config.textModel, + auth: auth.auth, + ...parsed.data, + // Grading is quick; don't let a hung request block the feed for minutes. + signal: AbortSignal.timeout(Math.min(config.timeoutMs, 120_000)), + }); + return Response.json(result); + } catch (error) { + console.error("[api/grade]", error instanceof OllamaError ? `${error.kind}: ${error.message}` : error); + return Response.json( + { error: friendlyErrorMessage(error, auth.auth.baseUrl), kind: error instanceof OllamaError ? error.kind : "unknown" }, + { status: 502 }, + ); + } +} diff --git a/app/api/health/route.ts b/app/api/health/route.ts new file mode 100644 index 0000000..6579feb --- /dev/null +++ b/app/api/health/route.ts @@ -0,0 +1,131 @@ +import { AUTH_ERROR_MESSAGES, authErrorKind, canonicalModelName, getServerConfig, resolveAuth, resolveModelName, type AuthMode } from "@/lib/config"; +import { friendlyErrorMessage, ollamaListModels, ollamaVerifyKey, OllamaError } from "@/lib/ollama"; +import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export type ModelHealth = { + configured: string; + resolved: string; + available: boolean | null; + note?: string; +}; + +export type HealthResponse = { + ok: boolean; + reachable: boolean; + mode: "local-daemon" | "direct-cloud"; + authMode: AuthMode; + /** True when the browser has to provide its own key and hasn't. */ + needsKey: boolean; + /** null = not checked (no key), true/false = Ollama accepted/rejected the key. */ + keyAccepted: boolean | null; + baseUrl: string; + hasApiKey: boolean; + vision: ModelHealth; + text: ModelHealth; + latencyMs: number | null; + modelCount: number | null; + error: string | null; + kind?: string; + checkedAt: string; +}; + +export async function GET(request: Request) { + const config = getServerConfig(); + + const limit = rateLimit(`health:${clientKey(request)}`, config.rateLimits.health, config.rateLimits.windowMs); + if (!limit.allowed) return rateLimitResponse(limit); + + const started = Date.now(); + const resolvedAuth = resolveAuth(config, request); + + const health: HealthResponse = { + ok: false, + reachable: false, + mode: config.directCloud ? "direct-cloud" : "local-daemon", + authMode: config.authMode, + needsKey: "error" in resolvedAuth, + keyAccepted: null, + baseUrl: config.baseUrl, + hasApiKey: !("error" in resolvedAuth) && Boolean(resolvedAuth.auth.apiKey), + vision: { + configured: config.visionModel, + resolved: resolveModelName(config.visionModel, config.directCloud), + available: null, + }, + text: { + configured: config.textModel, + resolved: resolveModelName(config.textModel, config.directCloud), + available: null, + }, + latencyMs: null, + modelCount: null, + error: null, + checkedAt: new Date().toISOString(), + }; + + if ("error" in resolvedAuth) { + health.error = + resolvedAuth.error === "missing_key" + ? "No Ollama API key yet. Add your key in the settings dialog — it is stored only in this browser." + : AUTH_ERROR_MESSAGES[resolvedAuth.error]; + health.kind = authErrorKind(resolvedAuth.error); + return Response.json(health satisfies HealthResponse, { headers: { "Cache-Control": "no-store" } }); + } + + try { + const models = await ollamaListModels(resolvedAuth.auth); + health.reachable = true; + health.latencyMs = Date.now() - started; + health.modelCount = models.length; + + // A key is only "accepted" once Ollama authenticated a real request — + // model listings are public, so an actual (tiny, cached) chat proves it. + if (resolvedAuth.auth.apiKey) { + const probe = await ollamaVerifyKey(resolvedAuth.auth, config.textModel, config.healthTimeoutMs * 3); + health.keyAccepted = probe.valid; + if (probe.valid === false) { + health.error = "Ollama rejected this API key. Create a new one at ollama.com/settings/keys and paste it again."; + health.kind = "auth"; + return Response.json(health satisfies HealthResponse, { headers: { "Cache-Control": "no-store" } }); + } + if (probe.reason === "no_credits") { + health.error = `The key works, but ${config.textModel} is not included in this account's plan. Add usage credits or switch to a free-tier model in .env.local.`; + health.kind = "no_credits"; + } + } + + const names = new Set(models.map((model) => canonicalModelName(model.name))); + for (const key of ["vision", "text"] as const) { + const entry = health[key]; + const wanted = canonicalModelName(entry.resolved); + const listed = names.has(wanted); + entry.available = listed; + if (!listed) { + entry.note = + health.mode === "local-daemon" + ? `Not in the daemon's model list. Run \`ollama pull ${entry.resolved}\` or check your sign-in.` + : `Not returned by ${config.baseUrl}/api/tags. Check the model name and your key's plan.`; + } + } + + health.ok = Boolean(health.vision.available && health.text.available && health.keyAccepted !== false); + if (!health.ok && !health.error) { + health.error = "Ollama is reachable, but at least one configured model is not available."; + } + } catch (error) { + health.error = friendlyErrorMessage(error, config.baseUrl); + health.kind = error instanceof OllamaError ? error.kind : "unknown"; + if (error instanceof OllamaError && (error.kind === "auth" || error.kind === "http")) { + health.keyAccepted = false; + } + health.latencyMs = null; + } + + return Response.json(health satisfies HealthResponse, { + status: 200, + headers: { "Cache-Control": "no-store" }, + }); +} diff --git a/app/create/page.tsx b/app/create/page.tsx new file mode 100644 index 0000000..c7592f6 --- /dev/null +++ b/app/create/page.tsx @@ -0,0 +1,25 @@ +import { AppHeader } from "@/components/AppHeader"; +import { MobileSidebar, Sidebar, SidebarOffset } from "@/components/Sidebar"; +import { TabBar } from "@/components/TabBar"; +import { UploadScreen } from "@/components/UploadScreen"; + +export const metadata = { + title: "New deck", +}; + +export default function CreatePage() { + return ( +
+ + + +
+ +
+ +
+ +
+
+ ); +} diff --git a/app/deck/[id]/page.tsx b/app/deck/[id]/page.tsx new file mode 100644 index 0000000..b7a3321 --- /dev/null +++ b/app/deck/[id]/page.tsx @@ -0,0 +1,66 @@ +"use client"; + +import Link from "next/link"; +import { useParams } from "next/navigation"; +import { useEffect, useState } from "react"; + +import { StudyFeed } from "@/components/StudyFeed"; +import { Button, Spinner } from "@/components/ui"; +import type { Deck } from "@/lib/schemas"; +import { getDeck } from "@/lib/storage"; + +export default function DeckPage() { + const params = useParams<{ id: string }>(); + const id = typeof params.id === "string" ? params.id : ""; + const [deck, setDeck] = useState(null); + const [loaded, setLoaded] = useState(false); + + useEffect(() => { + if (!id) { + setLoaded(true); + return; + } + setDeck(getDeck(id) ?? null); + setLoaded(true); + }, [id]); + + if (!loaded) { + return ( +
+
+ Loading your deck… +
+
+ ); + } + + if (!deck) { + return ( +
+
+

+ 🧊 +

+

Deck not found

+

+ This deck isn't in this browser's library. It may have been deleted, or it was + created in a different browser profile. +

+
+ + Back to library + + + + +
+
+
+ ); + } + + return ; +} diff --git a/app/globals.css b/app/globals.css index a2dc41e..02e33d4 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,26 +1,499 @@ @import "tailwindcss"; +/* Class-based dark mode (toggled on by the theme script). */ +@custom-variant dark (&:where(.dark, .dark *)); + +@theme { + /* Neutral scale (a whisper of cool) — the ChatGPT-ish workhorse. ------- */ + --color-frost-50: #f8f8f9; + --color-frost-100: #f1f1f3; + --color-frost-200: #e4e4e7; + --color-frost-300: #cfcfd4; + --color-frost-400: #a3a3aa; + --color-frost-500: #7a7a82; + --color-frost-600: #5d5d64; + --color-frost-700: #47474d; + --color-frost-800: #34343a; + --color-frost-900: #26262a; + --color-frost-950: #171719; + + /* Accent: one confident blue for buttons and highlights. -------------- */ + --color-accent-50: #eef7fd; + --color-accent-100: #d9edfa; + --color-accent-200: #b6dcf4; + --color-accent-300: #83c3ea; + --color-accent-400: #4aa4dc; + --color-accent-500: #2387c4; + --color-accent-600: #186ca6; + --color-accent-700: #175687; + --color-accent-800: #18496f; + --color-accent-900: #183e5d; + + /* Aurora hues used only for the frosted backdrop wash. ----------------- */ + --color-aurora-mint: #9fe3d0; + --color-aurora-sky: #9ccdf5; + --color-aurora-lilac: #c3b6f0; + --color-aurora-peach: #f6cdb4; + + --color-night-50: #f6f9fc; + --color-night-700: #1d2733; + --color-night-800: #151d27; + --color-night-900: #101720; + --color-night-950: #0a1017; + + --font-sans: -apple-system, BlinkMacSystemFont, ui-sans-serif, system-ui, + "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, + "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji"; + + --shadow-frost: 0 14px 34px -22px rgb(13 13 13 / 0.28); + --shadow-lift: 0 4px 14px -8px rgb(13 13 13 / 0.18); + --shadow-hairline: 0 1px 0 0 rgb(13 13 13 / 0.04); + + --animate-float: float 11s ease-in-out infinite; + --animate-pop: pop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); + --animate-wiggle: wiggle 0.4s ease-in-out; + --animate-rise: rise 0.4s cubic-bezier(0.22, 1, 0.36, 1) both; + --animate-sheet: sheet 0.32s cubic-bezier(0.22, 1, 0.36, 1) both; +} + +@keyframes float { + 0%, 100% { transform: translate3d(0, 0, 0) scale(1) } + 50% { transform: translate3d(0, -18px, 0) scale(1.04) } +} +@keyframes pop { + 0% { transform: scale(0.92) } + 60% { transform: scale(1.02) } + 100% { transform: scale(1) } +} +@keyframes wiggle { + 0%, 100% { transform: translateX(0) } + 25% { transform: translateX(-5px) } + 75% { transform: translateX(5px) } +} +@keyframes rise { + from { opacity: 0; transform: translateY(10px) } + to { opacity: 1; transform: translateY(0) } +} +@keyframes sheet { + from { opacity: 0; transform: translateY(24px) } + to { opacity: 1; transform: translateY(0) } +} + +/* --------------------------------------------------------------------------- + Theme tokens. + Light: ChatGPT-ish neutral (page #ffffff, sidebar #f9f9f9, border #e5e5e5). + Dark: page #212121, sidebar #171717, surfaces #2f2f2f, borders rgba(255,255,255,.1). + Text tiers are picked to clear WCAG AA (4.5:1) on their own background. + --------------------------------------------------------------------------- */ :root { - --background: #ffffff; - --foreground: #171717; + color-scheme: light; + --page: #ffffff; + --sidebar: #f9f9f9; + --surface: #ffffff; + --surface-raised: #ffffff; + --surface-sunken: rgb(13 13 13 / 0.05); + --hairline: #e5e5e5; + --hairline-soft: rgb(13 13 13 / 0.07); + --glass-tint: rgb(255 255 255 / 0.6); + --text: #0d0d0d; /* 17.6:1 on #ffffff */ + --text-muted: #5d5d5d; /* 7.4:1 */ + --text-subtle: #6b6b6b; /* 5.4:1 */ + --hover: rgb(13 13 13 / 0.05); + --sidebar-top: #fbfbfc; + --sidebar-bottom: #f6f6f7; + --sidebar-edge: 0 1px 0 var(--hairline), 6px 0 16px -14px rgb(13 13 13 / 0.28); + --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); } -@theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); +.dark { + color-scheme: dark; + --page: #212121; + --sidebar: #171717; + --surface: #2f2f2f; + --surface-raised: #383838; + --surface-sunken: rgb(255 255 255 / 0.08); + --hairline: rgb(255 255 255 / 0.14); + --hairline-soft: rgb(255 255 255 / 0.08); + --glass-tint: rgb(255 255 255 / 0.08); + --text: #ececec; /* 12.9:1 on #212121 */ + --text-muted: #b4b4b4; /* 7.6:1 */ + --text-subtle: #a0a0a0; /* 6.0:1 */ + --hover: rgb(255 255 255 / 0.07); + --sidebar-top: #1a1a1a; + --sidebar-bottom: #151515; + --sidebar-edge: 0 1px 0 var(--hairline), 6px 0 16px -14px rgb(0 0 0 / 0.75); + --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); } -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } +/* Backdrop: a whisper of cool over a neutral base (never a coloured surface). */ +:root { + --bd-sky: rgb(150 200 240 / 0.07); + --bd-lilac: rgb(188 178 236 / 0.05); + --bd-mint: rgb(150 220 202 / 0.05); + --bd-top: #fdfdfd; + --bd-mid: #fafafa; + --bd-bottom: #f7f7f7; +} +.dark { + --bd-sky: rgb(74 164 220 / 0.05); + --bd-lilac: rgb(160 140 235 / 0.04); + --bd-mint: rgb(88 201 173 / 0.04); + --bd-top: #232323; + --bd-mid: #212121; + --bd-bottom: #1e1e1e; +} + +html, +body { + height: 100%; } body { - background: var(--background); - color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; + font-family: var(--font-sans); + -webkit-tap-highlight-color: transparent; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; + /* Matches the backdrop so iOS rubber-banding never shows a seam. */ + background-color: var(--page); + color: var(--text); + overscroll-behavior-y: none; +} + +.dark body { + color: var(--color-frost-100); +} + +/* The page backdrop now lives in ONE viewport-fixed layer (see .app-backdrop) + so it can never stretch, scroll or be clipped by a content column. + `.frost-bg` stays as a transparent wrapper for pages that used it. */ +.frost-bg { + background: transparent; +} + +.app-backdrop { + position: fixed; + inset: 0; + z-index: -10; + overflow: hidden; + pointer-events: none; + background: + radial-gradient(135vw 110vh at 4% -30%, var(--bd-sky) 0%, transparent 72%), + radial-gradient(125vw 105vh at 104% -18%, var(--bd-lilac) 0%, transparent 70%), + radial-gradient(135vw 105vh at 38% 128%, var(--bd-mint) 0%, transparent 72%), + linear-gradient(180deg, var(--bd-top) 0%, var(--bd-mid) 50%, var(--bd-bottom) 100%); +} + +/* Two slow, very soft blobs for a little life behind the glass. */ +.app-backdrop::before, +.app-backdrop::after { + content: ""; + position: absolute; + border-radius: 50%; + filter: blur(110px); + opacity: 0.22; +} +.app-backdrop::before { + width: 52vw; + height: 52vw; + min-width: 24rem; + min-height: 24rem; + top: -26vh; + left: -18vw; + background: radial-gradient(circle, var(--bd-sky), transparent 66%); + animation: float 16s ease-in-out infinite; +} +.app-backdrop::after { + width: 46vw; + height: 46vw; + min-width: 22rem; + min-height: 22rem; + bottom: -28vh; + right: -14vw; + background: radial-gradient(circle, var(--bd-mint), transparent 66%); + animation: float 20s ease-in-out infinite reverse; +} +.dark .app-backdrop::before, +.dark .app-backdrop::after { + opacity: 0.16; +} + +@layer components { +/* Glass primitives ------------------------------------------------------ */ +.glass-panel { + @apply backdrop-blur-xl; + background: var(--surface); + border: 1px solid var(--hairline); + box-shadow: var(--shadow-card); +} + +.glass-chip { + @apply backdrop-blur-md; + background: var(--surface-sunken); + border: 1px solid transparent; +} +.glass-chip:hover { + background: var(--hover); +} + +.glass { + /* Tailwind emits both the prefixed and standard properties; hand-written + backdrop-filter gets minified down to -webkit- only, which paints nothing. */ + @apply backdrop-blur-xl backdrop-saturate-150; + background: color-mix(in oklab, white 68%, transparent); + border-bottom: 1px solid var(--hairline); +} +.dark .glass { + background: color-mix(in oklab, #101720 70%, transparent); +} + +.card { + border-radius: 1.125rem; /* 18px */ + background: var(--surface); + border: 1px solid var(--hairline); + box-shadow: var(--shadow-card); +} + +/* Opt-in hover lift for interactive cards (deck cards) */ +.card-hover { + transition: box-shadow 180ms ease, border-color 180ms ease, transform 180ms ease; +} +.card-hover:hover { + background: var(--surface-raised); + border-color: color-mix(in oklab, var(--hairline) 55%, var(--text) 18%); + box-shadow: var(--shadow-card-hover); +} +.card-hover:active { + transform: translateY(0); +} + +/* Inset grouped list ---------------------------------------------------- */ +.list-group { + border-radius: 1rem; + @apply backdrop-blur-lg backdrop-saturate-150; + background: var(--surface); + border: 1px solid var(--hairline); + overflow: hidden; +} +.list-group > * + * { + border-top: 1px solid var(--hairline-soft); +} + +/* iOS segmented control ------------------------------------------------- */ +.segmented { + display: flex; + gap: 2px; + padding: 3px; + border-radius: 0.95rem; + @apply backdrop-blur-sm; + background: var(--surface-sunken); + border: 1px solid var(--hairline); +} +.segmented > button { + flex: 1; + border-radius: 0.75rem; + padding: 0.42rem 0.5rem; + font-size: 0.875rem; + font-weight: 600; + color: var(--color-frost-700); + transition: background-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease; +} +.dark .segmented > button { + color: var(--color-frost-300); +} +.segmented > button[aria-pressed="true"], +.segmented > button[aria-checked="true"] { + background: var(--surface-strong); + color: var(--text); + box-shadow: 0 1px 2px rgb(13 13 13 / 0.12); +} +.dark .segmented > button[aria-pressed="true"], +.dark .segmented > button[aria-checked="true"] { + background: #383838; + color: #fff; +} + +/* Sheet grabber --------------------------------------------------------- */ +.sheet-grabber { + width: 36px; + height: 5px; + border-radius: 999px; + background: var(--hairline-soft); + margin: 0 auto 0.75rem; +} + +.tabbar { + @apply backdrop-blur-xl; + background: color-mix(in oklab, var(--page) 88%, transparent); + border-top: 1px solid var(--hairline); +} + +/* Soft tinted chip for activity types (a hint of colour, not a rainbow) -- */ +.tint-sky { background: linear-gradient(140deg, rgb(156 205 245 / 0.34), rgb(156 205 245 / 0.14)); color: #1c4e72 } +.tint-mint { background: linear-gradient(140deg, rgb(159 227 208 / 0.4), rgb(159 227 208 / 0.16)); color: #1d5c4c } +.tint-lilac { background: linear-gradient(140deg, rgb(195 182 240 / 0.4), rgb(195 182 240 / 0.16)); color: #47397f } +.tint-peach { background: linear-gradient(140deg, rgb(246 205 180 / 0.45), rgb(246 205 180 / 0.18)); color: #7d4522 } +.tint-rose { background: linear-gradient(140deg, rgb(246 178 190 / 0.4), rgb(246 178 190 / 0.16)); color: #7c2b3d } +.dark .tint-sky { background: linear-gradient(140deg, rgb(74 164 220 / 0.26), rgb(74 164 220 / 0.1)); color: #bfe2fb } +.dark .tint-mint { background: linear-gradient(140deg, rgb(88 201 173 / 0.26), rgb(88 201 173 / 0.1)); color: #bdf0e2 } +.dark .tint-lilac { background: linear-gradient(140deg, rgb(160 140 235 / 0.26), rgb(160 140 235 / 0.1)); color: #ddd5fb } +.dark .tint-peach { background: linear-gradient(140deg, rgb(230 160 118 / 0.26), rgb(230 160 118 / 0.1)); color: #fadfc9 } +.dark .tint-rose { background: linear-gradient(140deg, rgb(230 130 150 / 0.26), rgb(230 130 150 / 0.1)); color: #fbd5dd } + +/* Sidebar surface: a touch darker than the page, with a real edge. */ +.sidebar-surface { + background: linear-gradient(180deg, var(--sidebar-top), var(--sidebar-bottom)); + border-right: 1px solid var(--hairline); + box-shadow: var(--sidebar-edge); +} + +/* Sidebar nav rows */ +.nav-row { + display: flex; + align-items: center; + gap: 0.625rem; + width: 100%; + border-radius: 0.625rem; + padding: 0.5rem 0.625rem; + font-size: 13.5px; + font-weight: 500; + color: var(--text-muted); + transition: background-color 140ms ease, color 140ms ease; +} +.nav-row:hover { + background: var(--hover); + color: var(--text); +} +.nav-row[aria-current="page"], +.nav-row[aria-pressed="true"] { + background: var(--surface-sunken); + color: var(--text); +} + +/* Shared CTA language: near-black pill in light mode, white pill in dark. */ +.btn-primary { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + border-radius: 999px; + background: #0d0d0d; + color: #ffffff; + font-weight: 500; + box-shadow: 0 1px 2px rgb(13 13 13 / 0.18); + transition: background-color 160ms ease, transform 160ms ease, box-shadow 160ms ease; +} +.btn-primary:hover { background: #2f2f2f; } +.btn-primary:active { transform: scale(0.985); } +.dark .btn-primary { + background: #ffffff; + color: #0d0d0d; + box-shadow: none; +} +.dark .btn-primary:hover { background: #e5e5e5; } + +.btn-secondary { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + border-radius: 999px; + background: transparent; + color: var(--text); + border: 1px solid var(--hairline); + font-weight: 500; + transition: background-color 160ms ease, border-color 160ms ease, transform 160ms ease; +} +.btn-secondary:hover { background: var(--hover); border-color: color-mix(in oklab, var(--hairline) 70%, var(--text) 12%); } +.btn-secondary:active { transform: scale(0.985); } + +.btn-ghost { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + border-radius: 0.75rem; + background: transparent; + color: var(--text-muted); + font-weight: 500; + transition: background-color 160ms ease, color 160ms ease; +} +.btn-ghost:hover { background: var(--hover); color: var(--text); } + +/* Difficulty: neutral pill, colour lives in the text only. */ +.diff-easy { color: #16714f; } +.diff-medium { color: #8a5e00; } +.diff-hard { color: #b4342f; } +.dark .diff-easy { color: #6fd3a8; } +.dark .diff-medium { color: #e3b341; } +.dark .diff-hard { color: #f08a84; } + +/* Loading skeleton */ +.skeleton { + position: relative; + overflow: hidden; + background: var(--surface-sunken); + border-radius: 0.75rem; +} +.skeleton::after { + content: ""; + position: absolute; + inset: 0; + transform: translateX(-100%); + background: linear-gradient(90deg, transparent, rgb(255 255 255 / 0.45), transparent); + animation: shimmer 1.6s infinite; +} +.dark .skeleton::after { + background: linear-gradient(90deg, transparent, rgb(255 255 255 / 0.06), transparent); +} +@keyframes shimmer { + 100% { transform: translateX(100%); } +} + +.text-balance { + text-wrap: balance; +} + +/* Focus rings ------------------------------------------------------------ */ +:where(button, a, input, textarea, [tabindex]):focus-visible { + outline: 2.5px solid var(--color-accent-500); + outline-offset: 2px; + border-radius: 0.75rem; +} + +/* Scrollbars ------------------------------------------------------------- */ +.thin-scroll { + scrollbar-width: thin; + scrollbar-color: color-mix(in oklab, var(--color-frost-400) 55%, transparent) transparent; +} +.thin-scroll::-webkit-scrollbar { width: 8px; height: 8px } +.thin-scroll::-webkit-scrollbar-thumb { + background: color-mix(in oklab, var(--color-frost-400) 45%, transparent); + border-radius: 999px; +} +.no-scrollbar::-webkit-scrollbar { display: none } +.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none } + +/* Mobile safe areas ------------------------------------------------------ */ +.pt-safe { padding-top: max(env(safe-area-inset-top), 0.75rem) } +.pb-safe { padding-bottom: max(env(safe-area-inset-bottom), 0.75rem) } +.pb-tabbar { padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 5.25rem) } +.pb-cta { padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 9rem) } + +@media (display-mode: standalone) { + .standalone-hide { display: none } +} +} + +/* Reduced motion --------------------------------------------------------- */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + scroll-behavior: auto !important; + } } diff --git a/app/icon.svg b/app/icon.svg new file mode 100644 index 0000000..02c15b6 --- /dev/null +++ b/app/icon.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/app/layout.tsx b/app/layout.tsx index f7fa87e..6b9433e 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,33 +1,66 @@ -import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; +import type { Metadata, Viewport } from "next"; + +import { ServiceWorkerRegistrar } from "@/components/ServiceWorkerRegistrar"; import "./globals.css"; -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], -}); - -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); - export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: { + default: "Freezmo — turn your notes into a study feed", + template: "%s · Freezmo", + }, + description: + "Freezmo turns photos and documents of your notes into a swipeable feed of short interactive study activities, powered by your own Ollama models.", + applicationName: "Freezmo", + manifest: "/manifest.webmanifest", + appleWebApp: { + capable: true, + title: "Freezmo", + statusBarStyle: "default", + }, + icons: { + icon: [ + { url: "/icons/favicon-32.png", sizes: "32x32", type: "image/png" }, + { url: "/icons/icon-192.png", sizes: "192x192", type: "image/png" }, + { url: "/icons/icon-512.png", sizes: "512x512", type: "image/png" }, + ], + apple: [ + { url: "/icons/apple-touch-icon.png", sizes: "180x180" }, + { url: "/icons/apple-touch-icon-167.png", sizes: "167x167" }, + { url: "/icons/apple-touch-icon-152.png", sizes: "152x152" }, + ], + }, + other: { + // iOS standalone niceties + "apple-mobile-web-app-capable": "yes", + "mobile-web-app-capable": "yes", + "format-detection": "telephone=no", + }, }; -export default function RootLayout({ - children, -}: Readonly<{ - children: React.ReactNode; -}>) { +export const viewport: Viewport = { + width: "device-width", + initialScale: 1, + maximumScale: 5, + viewportFit: "cover", + themeColor: [ + { media: "(prefers-color-scheme: light)", color: "#f8fafc" }, + { media: "(prefers-color-scheme: dark)", color: "#0a0f13" }, + ], +}; + +/** Applies the saved theme before paint to avoid a flash. */ +const themeScript = `(function(){try{var s=localStorage.getItem('freezmo.theme');var d=s?s==='dark':window.matchMedia('(prefers-color-scheme: dark)').matches;if(d)document.documentElement.classList.add('dark');}catch(e){}})();`; + +export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - - + + +