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.
51
.env.example
Normal file
|
|
@ -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
|
||||
15
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
770
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 <http://localhost:3207>. The app runs without any Ollama configuration — you'll just need a
|
||||
key (or a signed-in daemon) before it can generate a deck. Complete one of the Ollama setups below,
|
||||
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 <https://ollama.com/settings/keys> 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=<model used to transcribe the photos>
|
||||
TEXT_MODEL=<model used to build activities and grade short answers>
|
||||
```
|
||||
|
||||
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 <https://ollama.com/search?c=cloud>.
|
||||
|
||||
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://<your-lan-ip>: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 `<input type="file" capture="environment">`,
|
||||
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 <https://ollama.com/settings/keys> 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 <https://ollama.com/settings>. |
|
||||
| “Too many requests — try again in Ns” | You hit a rate limit (12 decks / 40 grades per 5 min by default). Wait, or raise `RATE_LIMIT_*` in `.env.local`. |
|
||||
| “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 <https://ollama.com/settings/keys> 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 `<html>`.
|
||||
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.
|
||||
|
|
|
|||
226
app/api/generate/route.ts
Normal file
|
|
@ -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<string, unknown> }
|
||||
| { 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<Uint8Array>({
|
||||
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;
|
||||
}
|
||||
71
app/api/grade/route.ts
Normal file
|
|
@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
131
app/api/health/route.ts
Normal file
|
|
@ -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" },
|
||||
});
|
||||
}
|
||||
25
app/create/page.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="flex min-h-dvh bg-[var(--page)]">
|
||||
<Sidebar />
|
||||
<SidebarOffset />
|
||||
<MobileSidebar />
|
||||
<main className="min-w-0 flex-1">
|
||||
<AppHeader />
|
||||
<div className="mx-auto w-full max-w-3xl px-4 pb-cta lg:px-10 lg:pb-20">
|
||||
<UploadScreen />
|
||||
</div>
|
||||
<TabBar />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
app/deck/[id]/page.tsx
Normal file
|
|
@ -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<Deck | null>(null);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) {
|
||||
setLoaded(true);
|
||||
return;
|
||||
}
|
||||
setDeck(getDeck(id) ?? null);
|
||||
setLoaded(true);
|
||||
}, [id]);
|
||||
|
||||
if (!loaded) {
|
||||
return (
|
||||
<main className="frost-bg grid min-h-dvh place-items-center">
|
||||
<div className="relative flex items-center gap-3 text-sm font-semibold text-frost-800 dark:text-frost-100">
|
||||
<Spinner className="size-5" /> Loading your deck…
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (!deck) {
|
||||
return (
|
||||
<main className="frost-bg grid min-h-dvh place-items-center px-4">
|
||||
<div className="card relative max-w-md space-y-3 p-6 text-center">
|
||||
<p className="text-4xl" aria-hidden>
|
||||
🧊
|
||||
</p>
|
||||
<h1 className="text-xl font-extrabold text-frost-950 dark:text-frost-50">Deck not found</h1>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||||
This deck isn't in this browser's library. It may have been deleted, or it was
|
||||
created in a different browser profile.
|
||||
</p>
|
||||
<div className="flex justify-center gap-2 pt-1">
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-2 rounded-2xl bg-accent-600 px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-accent-700"
|
||||
>
|
||||
Back to library
|
||||
</Link>
|
||||
<Link href="/create">
|
||||
<Button variant="secondary">Create a deck</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return <StudyFeed key={deck.id} deck={deck} />;
|
||||
}
|
||||
501
app/globals.css
|
|
@ -1,26 +1,499 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
/* Class-based dark mode (toggled on <html> 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;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
@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) }
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
/* ---------------------------------------------------------------------------
|
||||
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: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
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);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
14
app/icon.svg
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
||||
<defs>
|
||||
<linearGradient id="frost" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#38bdf8" />
|
||||
<stop offset="100%" stop-color="#0369a1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="64" height="64" rx="16" fill="url(#frost)" />
|
||||
<g stroke="#ffffff" stroke-width="3.4" stroke-linecap="round" fill="none">
|
||||
<path d="M32 12v40M14.7 22l34.6 20M49.3 22 14.7 42" />
|
||||
<path d="M32 20.5 26.8 15.3M32 20.5l5.2-5.2M32 43.5l-5.2 5.2M32 43.5l5.2 5.2" />
|
||||
<path d="M20.7 27.5l-6.6-1.7M20.7 36.5l-6.6 1.7M43.3 27.5l6.6-1.7M43.3 36.5l6.6 1.7" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 671 B |
|
|
@ -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 (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
|
||||
</head>
|
||||
<body className="min-h-full antialiased">
|
||||
<div className="app-backdrop" aria-hidden />
|
||||
{children}
|
||||
<ServiceWorkerRegistrar />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
|
|
|||
30
app/manifest.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import type { MetadataRoute } from "next";
|
||||
|
||||
/**
|
||||
* Web app manifest — what makes Freezmo installable on iOS/Android
|
||||
* (iOS reads the apple-* meta tags in app/layout.tsx as well).
|
||||
*/
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: "Freezmo — notes to study feed",
|
||||
short_name: "Freezmo",
|
||||
description:
|
||||
"Turn photos and documents of your notes into a swipeable feed of short interactive study activities, powered by your own Ollama models.",
|
||||
start_url: "/",
|
||||
scope: "/",
|
||||
display: "standalone",
|
||||
orientation: "portrait",
|
||||
background_color: "#f8fafc",
|
||||
theme_color: "#f8fafc",
|
||||
categories: ["education", "productivity"],
|
||||
icons: [
|
||||
{ src: "/icons/icon-192.png", sizes: "192x192", type: "image/png", purpose: "any" },
|
||||
{ src: "/icons/icon-512.png", sizes: "512x512", type: "image/png", purpose: "any" },
|
||||
{ src: "/icons/maskable-512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
|
||||
],
|
||||
shortcuts: [
|
||||
{ name: "New deck", short_name: "New", url: "/create" },
|
||||
{ name: "Library", short_name: "Library", url: "/" },
|
||||
],
|
||||
};
|
||||
}
|
||||
19
app/offline/page.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { HomeIcon } from "@/components/icons";
|
||||
|
||||
export const metadata = { title: "Offline" };
|
||||
|
||||
/** Shown by the service worker when a navigation can't reach the network. */
|
||||
export default function OfflinePage() {
|
||||
return (
|
||||
<main className="frost-bg grid min-h-dvh place-items-center px-4">
|
||||
<div className="card relative max-w-sm space-y-3 p-6 text-center">
|
||||
<HomeIcon className="mx-auto size-8 text-frost-400" />
|
||||
<h1 className="text-xl font-semibold text-frost-950 dark:text-frost-50">You're offline</h1>
|
||||
<p className="text-sm text-frost-600 dark:text-frost-300">
|
||||
Freezmo needs a connection for generating decks and grading answers. Your saved decks are
|
||||
still in this browser — reconnect and reload.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
114
app/page.tsx
|
|
@ -1,103 +1,25 @@
|
|||
import Image from "next/image";
|
||||
import { AppHeader } from "@/components/AppHeader";
|
||||
import { InstallHint } from "@/components/InstallHint";
|
||||
import { LibraryScreen } from "@/components/LibraryScreen";
|
||||
import { MobileSidebar, Sidebar, SidebarOffset } from "@/components/Sidebar";
|
||||
import { TabBar } from "@/components/TabBar";
|
||||
|
||||
export default function Home() {
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<div className="font-sans grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20">
|
||||
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={180}
|
||||
height={38}
|
||||
priority
|
||||
/>
|
||||
<ol className="font-mono list-inside list-decimal text-sm/6 text-center sm:text-left">
|
||||
<li className="mb-2 tracking-[-.01em]">
|
||||
Get started by editing{" "}
|
||||
<code className="bg-black/[.05] dark:bg-white/[.06] font-mono font-semibold px-1 py-0.5 rounded">
|
||||
app/page.tsx
|
||||
</code>
|
||||
.
|
||||
</li>
|
||||
<li className="tracking-[-.01em]">
|
||||
Save and see your changes instantly.
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<div className="flex gap-4 items-center flex-col sm:flex-row">
|
||||
<a
|
||||
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
Deploy now
|
||||
</a>
|
||||
<a
|
||||
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Read our docs
|
||||
</a>
|
||||
<div className="flex min-h-dvh bg-[var(--page)]">
|
||||
<Sidebar />
|
||||
<SidebarOffset />
|
||||
<MobileSidebar />
|
||||
<main className="min-w-0 flex-1">
|
||||
<AppHeader />
|
||||
<div className="mx-auto w-full max-w-6xl px-4 pb-tabbar lg:px-10 lg:pb-16">
|
||||
<div className="mb-6">
|
||||
<InstallHint />
|
||||
</div>
|
||||
<LibraryScreen />
|
||||
</div>
|
||||
<TabBar />
|
||||
</main>
|
||||
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/file.svg"
|
||||
alt="File icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Learn
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/window.svg"
|
||||
alt="Window icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Examples
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/globe.svg"
|
||||
alt="Globe icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Go to nextjs.org →
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
53
components/ApiKeyBanner.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
"use client";
|
||||
|
||||
import { useHealth } from "@/lib/use-health";
|
||||
import { openSettings } from "@/lib/ui-events";
|
||||
import { Button } from "@/components/ui";
|
||||
|
||||
/**
|
||||
* Tells the user when they need to add (or fix) their own Ollama API key.
|
||||
* Renders nothing in local-daemon / server-key setups.
|
||||
*/
|
||||
export function ApiKeyBanner() {
|
||||
const { health, loading } = useHealth();
|
||||
if (!health || loading) return null;
|
||||
if (health.authMode !== "user-key") return null;
|
||||
|
||||
const rejected = health.keyAccepted === false;
|
||||
if (!health.needsKey && !rejected) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className={
|
||||
rejected
|
||||
? "glass-panel animate-rise flex flex-wrap items-center gap-3 rounded-[1.35rem] p-4 text-sm text-rose-900 dark:text-rose-100"
|
||||
: "glass-panel animate-rise flex flex-wrap items-center gap-3 rounded-[1.35rem] p-4 text-sm text-frost-900 dark:text-frost-50"
|
||||
}
|
||||
>
|
||||
<span aria-hidden className="text-xl">
|
||||
🔑
|
||||
</span>
|
||||
<p className="min-w-48 flex-1">
|
||||
<span className="font-bold">
|
||||
{rejected ? "Your Ollama API key was rejected." : "Add your Ollama API key to generate decks."}
|
||||
</span>{" "}
|
||||
<span className="font-normal">
|
||||
Create one at{" "}
|
||||
<a
|
||||
className="font-semibold underline underline-offset-2"
|
||||
href="https://ollama.com/settings/keys"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
ollama.com/settings/keys
|
||||
</a>{" "}
|
||||
and paste it in settings. It stays in this browser — the server never stores it.
|
||||
</span>
|
||||
</p>
|
||||
<Button size="sm" onClick={openSettings}>
|
||||
Add API key
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
85
components/AppHeader.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { GearIcon, MenuIcon, SnowflakeIcon } from "@/components/icons";
|
||||
import { SettingsDialog } from "@/components/SettingsDialog";
|
||||
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||
import { sidebarOpen } from "@/lib/nav";
|
||||
import { OPEN_SETTINGS_EVENT } from "@/lib/ui-events";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Minimal, sticky frosted top bar: brand on the left, quiet actions on the
|
||||
* right. The phone layout keeps the brand compact and relies on the tab bar
|
||||
* for navigation.
|
||||
*/
|
||||
export function AppHeader({ className, compact = false }: { className?: string; compact?: boolean }) {
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onOpen = () => setSettingsOpen(true);
|
||||
window.addEventListener(OPEN_SETTINGS_EVENT, onOpen);
|
||||
return () => window.removeEventListener(OPEN_SETTINGS_EVENT, onOpen);
|
||||
}, []);
|
||||
|
||||
// Restore the desktop sidebar width preference once, on the client.
|
||||
useEffect(() => {
|
||||
void import("@/components/Sidebar").then((module) => module.sidebarCollapse.hydrate());
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<header
|
||||
className={cn(
|
||||
"glass sticky top-0 z-40 -mx-4 mb-6 px-4 lg:hidden sm:-mx-6 sm:px-6",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => sidebarOpen.toggle()}
|
||||
aria-label="Open menu"
|
||||
className="mr-1 grid size-9 shrink-0 place-items-center rounded-full text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
||||
>
|
||||
<MenuIcon className="size-5" />
|
||||
</button>
|
||||
<Link href="/" className="group flex items-center gap-2.5 rounded-xl px-1 py-1" aria-label="Freezmo home">
|
||||
<span
|
||||
aria-hidden
|
||||
className="grid size-8 place-items-center rounded-[0.6rem] bg-[var(--text)] text-[var(--page)] transition-transform group-hover:scale-[1.04]"
|
||||
>
|
||||
<SnowflakeIcon className="size-[18px]" />
|
||||
</span>
|
||||
<span className="flex items-baseline gap-2">
|
||||
<span className="text-[15px] font-semibold tracking-[-0.01em] text-[var(--text)]">
|
||||
Freezmo
|
||||
</span>
|
||||
{!compact ? (
|
||||
<span className="hidden text-[13px] text-[var(--text-subtle)] sm:inline">
|
||||
study feed
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
aria-label="Open settings and Ollama status"
|
||||
title="Settings"
|
||||
className="inline-flex size-9 items-center justify-center rounded-full text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
||||
>
|
||||
<GearIcon className="size-[18px]" />
|
||||
</button>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<SettingsDialog open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
407
components/DeckCard.tsx
Normal file
|
|
@ -0,0 +1,407 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
import { ApiError, generateDeck } from "@/lib/client-api";
|
||||
import { activityTypes, materialiseActivities } from "@/lib/deck-ops";
|
||||
import { createFolder as makeFolder, folderName, listFolders } from "@/lib/folders";
|
||||
import { UNORGANIZED_ID, type Deck } from "@/lib/schemas";
|
||||
import { appendActivities, deleteDeck, downloadDeck, renameDeck, upsertDeck } from "@/lib/storage";
|
||||
import { cn, formatDate, truncate } from "@/lib/utils";
|
||||
import { DownloadIcon, FolderIcon, PlayIcon, RefreshIcon } from "@/components/icons";
|
||||
import { Button, ProgressBar } from "@/components/ui";
|
||||
|
||||
type Busy = null | "more" | "regenerate";
|
||||
|
||||
const ADD_OPTIONS = [3, 5, 8];
|
||||
|
||||
/**
|
||||
* One deck in the dashboard grid: title, muted metadata, tags, summary and a
|
||||
* prominent Study CTA with quiet secondary actions.
|
||||
*/
|
||||
export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: string | undefined) => void }) {
|
||||
const folders = listFolders();
|
||||
const [busy, setBusy] = useState<Busy>(null);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const [title, setTitle] = useState(deck.title);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [addCount, setAddCount] = useState(5);
|
||||
const [newFolder, setNewFolder] = useState<string | null>(null);
|
||||
|
||||
const hasSource = deck.sourceText.trim().length > 20;
|
||||
|
||||
const saveTitle = () => {
|
||||
const trimmed = title.trim();
|
||||
if (trimmed && trimmed !== deck.title) renameDeck(deck.id, trimmed);
|
||||
else setTitle(deck.title);
|
||||
setRenaming(false);
|
||||
setMenuOpen(false);
|
||||
};
|
||||
|
||||
const addMore = async () => {
|
||||
if (!hasSource) {
|
||||
setError("This deck has no stored source notes, so it can't be extended.");
|
||||
return;
|
||||
}
|
||||
setBusy("more");
|
||||
setError(null);
|
||||
try {
|
||||
const { deck: generated } = await generateDeck({
|
||||
text: deck.sourceText,
|
||||
difficulty: deck.difficulty,
|
||||
language: deck.language,
|
||||
count: addCount,
|
||||
mode: "more",
|
||||
existing: { title: deck.title, types: activityTypes(deck.activities), count: deck.activities.length },
|
||||
});
|
||||
appendActivities(deck.id, materialiseActivities(generated.activities, deck.difficulty));
|
||||
setNotice(`Added ${generated.activities.length} activities`);
|
||||
setMenuOpen(false);
|
||||
window.setTimeout(() => setNotice(null), 3000);
|
||||
} catch (addError) {
|
||||
setError(addError instanceof ApiError ? addError.message : "Could not add more activities.");
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const regenerate = async () => {
|
||||
if (!hasSource) {
|
||||
setError("This deck has no stored source notes, so it can't be regenerated.");
|
||||
return;
|
||||
}
|
||||
setBusy("regenerate");
|
||||
setError(null);
|
||||
try {
|
||||
const { deck: generated } = await generateDeck({
|
||||
text: deck.sourceText,
|
||||
difficulty: deck.difficulty,
|
||||
language: deck.language,
|
||||
count: Math.max(8, deck.activities.length),
|
||||
});
|
||||
upsertDeck({
|
||||
...deck,
|
||||
title: generated.title || deck.title,
|
||||
subject: generated.subject ?? deck.subject,
|
||||
summary: generated.summary ?? deck.summary,
|
||||
language: generated.language ?? deck.language,
|
||||
activities: materialiseActivities(generated.activities, deck.difficulty),
|
||||
attempts: 0,
|
||||
bestScore: undefined,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
setNotice("Deck regenerated");
|
||||
setMenuOpen(false);
|
||||
window.setTimeout(() => setNotice(null), 3000);
|
||||
} catch (regenerateError) {
|
||||
setError(regenerateError instanceof ApiError ? regenerateError.message : "Could not regenerate the deck.");
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const menuRow =
|
||||
"flex w-full items-center gap-2.5 rounded-xl px-3 py-2 text-left text-[13px] font-medium text-[var(--text)] transition hover:bg-[var(--hover)]";
|
||||
|
||||
return (
|
||||
<li className="card card-hover group relative flex flex-col p-5">
|
||||
{/* header ------------------------------------------------------- */}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
{renaming ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="sr-only" htmlFor={`rename-${deck.id}`}>
|
||||
Deck title
|
||||
</label>
|
||||
<input
|
||||
id={`rename-${deck.id}`}
|
||||
value={title}
|
||||
autoFocus
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation();
|
||||
if (event.key === "Enter") saveTitle();
|
||||
if (event.key === "Escape") {
|
||||
setTitle(deck.title);
|
||||
setRenaming(false);
|
||||
}
|
||||
}}
|
||||
className="min-w-0 flex-1 rounded-lg border border-[var(--hairline)] bg-[var(--surface-strong)] px-2.5 py-1.5 text-[15px] font-semibold text-[var(--text)] outline-none focus:border-accent-400"
|
||||
/>
|
||||
<Button size="sm" onClick={saveTitle}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<h3
|
||||
className="line-clamp-2 text-[15px] font-semibold leading-snug tracking-[-0.01em] text-[var(--text)]"
|
||||
title={deck.title}
|
||||
>
|
||||
{truncate(deck.title, 90)}
|
||||
</h3>
|
||||
)}
|
||||
<p className="mt-1 text-[12.5px] text-[var(--text-subtle)]">
|
||||
{formatDate(deck.updatedAt)}
|
||||
<span aria-hidden> · </span>
|
||||
{deck.activities.length} activities
|
||||
{deck.attempts > 0 ? (
|
||||
<>
|
||||
<span aria-hidden> · </span>
|
||||
{deck.attempts} {deck.attempts === 1 ? "run" : "runs"}
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Deck actions"
|
||||
aria-expanded={menuOpen}
|
||||
onClick={() => setMenuOpen((value) => !value)}
|
||||
className={cn(
|
||||
"grid size-8 shrink-0 place-items-center rounded-full text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]",
|
||||
menuOpen && "bg-[var(--hover)] text-[var(--text)]",
|
||||
)}
|
||||
>
|
||||
⋯
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* tags --------------------------------------------------------- */}
|
||||
<div className="mt-3 flex flex-wrap items-center gap-1.5">
|
||||
{deck.subject ? <Tag>{deck.subject}</Tag> : null}
|
||||
<Tag>
|
||||
<span className={difficultyClass(deck.difficulty)}>{deck.difficulty}</span>
|
||||
</Tag>
|
||||
{deck.folderId ? (
|
||||
<Tag>
|
||||
<FolderIcon className="size-3.5 opacity-70" />
|
||||
{folderName(deck.folderId)}
|
||||
</Tag>
|
||||
) : null}
|
||||
{deck.bestScore !== undefined ? (
|
||||
<Tag>
|
||||
Best <span className="text-emerald-700 dark:text-emerald-300">{deck.bestScore}%</span>
|
||||
</Tag>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* summary ------------------------------------------------------ */}
|
||||
{deck.summary ? (
|
||||
<p className="mt-3 line-clamp-2 text-[13.5px] leading-relaxed text-[var(--text-muted)]">
|
||||
{truncate(deck.summary, 160)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{/* status ------------------------------------------------------- */}
|
||||
{busy ? (
|
||||
<div className="mt-4 space-y-2">
|
||||
<ProgressBar value={1} max={2} label={busy === "more" ? "Adding activities" : "Regenerating deck"} />
|
||||
<p className="text-[12.5px] font-medium text-[var(--text-muted)]">
|
||||
{busy === "more" ? "Adding new activities…" : "Regenerating the whole deck…"}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{notice ? (
|
||||
<p className="mt-3 rounded-xl bg-emerald-500/10 px-3 py-2 text-[12.5px] font-medium text-emerald-700 dark:text-emerald-300">
|
||||
{notice}
|
||||
</p>
|
||||
) : null}
|
||||
{error ? (
|
||||
<p className="mt-3 rounded-xl bg-rose-500/10 px-3 py-2 text-[12.5px] font-medium text-rose-700 dark:text-rose-300">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{/* single action ------------------------------------------------ */}
|
||||
<div className="mt-5">
|
||||
<Link href={`/deck/${deck.id}`} className="btn-primary h-10 w-full px-4 text-sm">
|
||||
<PlayIcon className="size-4" />
|
||||
Study
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* actions panel ------------------------------------------------ */}
|
||||
{menuOpen ? (
|
||||
<div
|
||||
data-testid="deck-actions"
|
||||
className="animate-rise mt-3 space-y-1 border-t border-[var(--hairline)] pt-3"
|
||||
>
|
||||
<button type="button" className={menuRow} onClick={() => setRenaming(true)} disabled={Boolean(busy)}>
|
||||
<span aria-hidden className="w-4 text-center text-[var(--text-subtle)]">
|
||||
✎
|
||||
</span>
|
||||
Rename
|
||||
</button>
|
||||
<button type="button" className={menuRow} onClick={() => downloadDeck(deck)} disabled={Boolean(busy)}>
|
||||
<DownloadIcon className="size-4 text-[var(--text-subtle)]" />
|
||||
Export JSON
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={menuRow}
|
||||
onClick={() => void regenerate()}
|
||||
disabled={Boolean(busy) || !hasSource}
|
||||
>
|
||||
<RefreshIcon className="size-4 text-[var(--text-subtle)]" />
|
||||
Regenerate deck
|
||||
</button>
|
||||
|
||||
{confirmDelete ? (
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<Button size="sm" variant="danger" onClick={() => deleteDeck(deck.id)}>
|
||||
Really delete
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setConfirmDelete(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<button type="button" className={cn(menuRow, "text-rose-600 dark:text-rose-300")} onClick={() => setConfirmDelete(true)}>
|
||||
<span aria-hidden className="w-4 text-center">
|
||||
⌫
|
||||
</span>
|
||||
Delete deck
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* folder ---------------------------------------------------- */}
|
||||
<div className="mt-2 rounded-xl bg-[var(--surface-sunken)] p-2.5">
|
||||
<p className="px-1 pb-2 text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
|
||||
Folder
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{[{ id: UNORGANIZED_ID, name: "Unorganized" }, ...folders].map((folder) => {
|
||||
const current = (deck.folderId ?? UNORGANIZED_ID) === folder.id;
|
||||
return (
|
||||
<button
|
||||
key={folder.id}
|
||||
type="button"
|
||||
aria-pressed={current}
|
||||
onClick={() => {
|
||||
onMove?.(folder.id === UNORGANIZED_ID ? undefined : folder.id);
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"rounded-full px-2.5 py-1 text-[12px] font-medium transition",
|
||||
current
|
||||
? "bg-[var(--text)] text-white dark:bg-white dark:text-[#0d0d0d]"
|
||||
: "text-[var(--text-muted)] hover:bg-[var(--hover)] hover:text-[var(--text)]",
|
||||
)}
|
||||
>
|
||||
{folder.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{newFolder === null ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNewFolder("")}
|
||||
className="rounded-full px-2.5 py-1 text-[12px] font-medium text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
||||
>
|
||||
+ New folder
|
||||
</button>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-[var(--hairline)] bg-[var(--surface-strong)] py-0.5 pl-2.5 pr-1">
|
||||
<input
|
||||
autoFocus
|
||||
value={newFolder}
|
||||
onChange={(event) => setNewFolder(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation();
|
||||
if (event.key === "Escape") setNewFolder(null);
|
||||
if (event.key === "Enter" && newFolder.trim()) {
|
||||
const created = makeFolder(newFolder);
|
||||
if ("folder" in created) {
|
||||
onMove?.(created.folder.id);
|
||||
setNewFolder(null);
|
||||
setMenuOpen(false);
|
||||
} else {
|
||||
setError(created.error);
|
||||
}
|
||||
}
|
||||
}}
|
||||
aria-label="New folder name"
|
||||
placeholder="Folder name"
|
||||
maxLength={60}
|
||||
className="w-24 bg-transparent text-[12px] text-[var(--text)] outline-none placeholder:text-[var(--text-subtle)]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary h-6 px-2.5 text-[11px]"
|
||||
onClick={() => {
|
||||
const created = makeFolder(newFolder);
|
||||
if ("folder" in created) {
|
||||
onMove?.(created.folder.id);
|
||||
setNewFolder(null);
|
||||
setMenuOpen(false);
|
||||
} else {
|
||||
setError(created.error);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* add more -------------------------------------------------- */}
|
||||
<div className="mt-1 rounded-xl bg-[var(--surface-sunken)] p-2.5">
|
||||
<p className="px-1 pb-2 text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
|
||||
Add more activities
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{ADD_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
onClick={() => setAddCount(option)}
|
||||
aria-pressed={addCount === option}
|
||||
className={cn(
|
||||
"min-w-9 rounded-full px-2.5 py-1 text-[12px] font-medium transition",
|
||||
addCount === option
|
||||
? "bg-[var(--text)] text-white dark:bg-white dark:text-[#0d0d0d]"
|
||||
: "text-[var(--text-muted)] hover:bg-[var(--hover)] hover:text-[var(--text)]",
|
||||
)}
|
||||
>
|
||||
+{option}
|
||||
</button>
|
||||
))}
|
||||
<Button size="sm" onClick={() => void addMore()} disabled={Boolean(busy) || !hasSource} loading={busy === "more"}>
|
||||
Generate
|
||||
</Button>
|
||||
</div>
|
||||
{!hasSource ? (
|
||||
<p className="px-1 pt-2 text-[11px] text-amber-700 dark:text-amber-300">
|
||||
No stored source text for this deck (imported decks don't carry it).
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function difficultyClass(difficulty: Deck["difficulty"]): string {
|
||||
if (difficulty === "easy") return "diff-easy";
|
||||
if (difficulty === "hard") return "diff-hard";
|
||||
return "diff-medium";
|
||||
}
|
||||
|
||||
/** Neutral pill; colour is only ever used on the text inside it. */
|
||||
function Tag({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-[var(--surface-sunken)] px-2.5 py-1 text-[12px] font-medium leading-none text-[var(--text-muted)]">
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
127
components/FolderBar.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
import { FolderIcon, PlusIcon } from "@/components/icons";
|
||||
import { createFolder } from "@/lib/folders";
|
||||
import type { Folder } from "@/lib/schemas";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const ALL_FOLDERS = "all";
|
||||
|
||||
/**
|
||||
* Folder switcher. Reads like a row of quiet pills (ChatGPT sidebar items):
|
||||
* the active one is filled, the rest are text with a hover wash.
|
||||
*/
|
||||
export function FolderBar({
|
||||
folders,
|
||||
counts,
|
||||
selected,
|
||||
onSelect,
|
||||
onCreated,
|
||||
className,
|
||||
}: {
|
||||
folders: Folder[];
|
||||
counts: Record<string, number>;
|
||||
selected: string;
|
||||
onSelect: (id: string) => void;
|
||||
onCreated: (id: string) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const submit = () => {
|
||||
const result = createFolder(draft);
|
||||
if ("error" in result) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
onCreated(result.folder.id);
|
||||
setDraft("");
|
||||
setError(null);
|
||||
setCreating(false);
|
||||
};
|
||||
|
||||
const pill = (active: boolean) =>
|
||||
cn(
|
||||
"inline-flex shrink-0 items-center gap-2 rounded-full px-3.5 py-1.5 text-[13px] font-medium transition",
|
||||
active
|
||||
? "bg-[var(--surface-sunken)] text-[var(--text)]"
|
||||
: "text-[var(--text-muted)] hover:bg-[var(--hover)] hover:text-[var(--text)]",
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn("no-scrollbar -mx-4 overflow-x-auto px-4 sm:mx-0 sm:px-0", className)}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(ALL_FOLDERS)}
|
||||
aria-pressed={selected === ALL_FOLDERS}
|
||||
className={pill(selected === ALL_FOLDERS)}
|
||||
>
|
||||
All decks
|
||||
</button>
|
||||
|
||||
{folders.map((folder) => (
|
||||
<button
|
||||
key={folder.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(folder.id)}
|
||||
aria-pressed={selected === folder.id}
|
||||
className={pill(selected === folder.id)}
|
||||
>
|
||||
<FolderIcon className="size-4 opacity-80" />
|
||||
{folder.name}
|
||||
<span className={cn("tabular-nums", selected === folder.id ? "opacity-70" : "text-[var(--text-subtle)]")}>
|
||||
{counts[folder.id] ?? 0}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{creating ? (
|
||||
<span className="inline-flex shrink-0 items-center gap-1 rounded-full border border-[var(--hairline)] bg-[var(--surface-strong)] py-1 pl-3 pr-1">
|
||||
<input
|
||||
ref={inputRef}
|
||||
autoFocus
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation();
|
||||
if (event.key === "Enter") submit();
|
||||
if (event.key === "Escape") {
|
||||
setCreating(false);
|
||||
setDraft("");
|
||||
setError(null);
|
||||
}
|
||||
}}
|
||||
placeholder="Folder name"
|
||||
aria-label="New folder name"
|
||||
maxLength={60}
|
||||
className="w-28 bg-transparent text-[13px] text-[var(--text)] outline-none placeholder:text-[var(--text-subtle)]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
className="btn-primary h-7 px-3 text-[12px]"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreating(true)}
|
||||
className="inline-flex shrink-0 items-center gap-2 rounded-full px-3.5 py-1.5 text-[13px] font-medium text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
New folder
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{error ? <p className="pt-1 text-xs font-medium text-rose-600 dark:text-rose-300">{error}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
components/InstallHint.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { ShareIcon } from "@/components/icons";
|
||||
|
||||
const DISMISS_KEY = "freezmo.installHintDismissed";
|
||||
|
||||
/**
|
||||
* iOS has no `beforeinstallprompt`, so the only way to explain installing the
|
||||
* PWA is to point at the Share sheet. Shown once, only in iOS Safari, only
|
||||
* outside standalone mode.
|
||||
*/
|
||||
export function InstallHint() {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (window.localStorage.getItem(DISMISS_KEY) === "1") return;
|
||||
const ua = window.navigator.userAgent;
|
||||
const isIos = /iPad|iPhone|iPod/.test(ua) || (ua.includes("Macintosh") && "ontouchend" in document);
|
||||
const isSafari = /Safari/.test(ua) && !/CriOS|FxiOS|EdgiOS|OPiOS/.test(ua);
|
||||
const standalone =
|
||||
window.matchMedia("(display-mode: standalone)").matches ||
|
||||
(window.navigator as unknown as { standalone?: boolean }).standalone === true;
|
||||
if (isIos && isSafari && !standalone) setVisible(true);
|
||||
} catch {
|
||||
/* private mode */
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const dismiss = () => {
|
||||
setVisible(false);
|
||||
try {
|
||||
window.localStorage.setItem(DISMISS_KEY, "1");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="glass-panel animate-rise flex items-start gap-3 rounded-[1.35rem] p-3.5 text-sm">
|
||||
<ShareIcon className="mt-0.5 size-5 shrink-0 text-accent-600 dark:text-accent-300" />
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold text-frost-950 dark:text-frost-50">Install Freezmo</p>
|
||||
<p className="text-frost-600 dark:text-frost-300">
|
||||
Tap <span className="font-medium">Share</span> then{" "}
|
||||
<span className="font-medium">Add to Home Screen</span> for a full-screen, app-like
|
||||
experience that works without the browser chrome.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismiss}
|
||||
aria-label="Dismiss install hint"
|
||||
className="rounded-lg px-2 py-1 text-frost-400 transition-colors hover:bg-[var(--surface-sunken)]"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
332
components/LibraryScreen.tsx
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||
|
||||
import { ApiKeyBanner } from "@/components/ApiKeyBanner";
|
||||
import { DeckCard } from "@/components/DeckCard";
|
||||
import { FolderBar } from "@/components/FolderBar";
|
||||
import { FolderIcon, UploadIcon } from "@/components/icons";
|
||||
import { Button, EmptyState } from "@/components/ui";
|
||||
import { folderStore, renameFolder } from "@/lib/folders";
|
||||
import { ALL_FOLDERS, folderSelection, useSelectedFolder } from "@/lib/nav";
|
||||
import { openSettings } from "@/lib/ui-events";
|
||||
import { UNORGANIZED_ID, type Deck } from "@/lib/schemas";
|
||||
import { deckStore, deleteFolder, importDeck, sortDecks, upsertDeck } from "@/lib/storage";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Dashboard: page title, folder pills, then a spacious responsive grid of
|
||||
* deck cards grouped by folder.
|
||||
*/
|
||||
export function LibraryScreen() {
|
||||
const decks = useSyncExternalStore(deckStore.subscribe, deckStore.getSnapshot, deckStore.getServerSnapshot);
|
||||
const folders = useSyncExternalStore(
|
||||
folderStore.subscribe,
|
||||
folderStore.getSnapshot,
|
||||
folderStore.getServerSnapshot,
|
||||
);
|
||||
const selected = useSelectedFolder();
|
||||
const setSelected = folderSelection.set;
|
||||
const [message, setMessage] = useState<{ tone: "ok" | "error"; text: string } | null>(null);
|
||||
const [editingFolder, setEditingFolder] = useState<{ id: string; name: string } | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
const importRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const sorted = useMemo(() => sortDecks(decks), [decks]);
|
||||
const folderList = useMemo(
|
||||
() => [...folders].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" })),
|
||||
[folders],
|
||||
);
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const map: Record<string, number> = {};
|
||||
for (const folder of folderList) map[folder.id] = 0;
|
||||
for (const deck of decks) {
|
||||
const key = deck.folderId && map[deck.folderId] !== undefined ? deck.folderId : UNORGANIZED_ID;
|
||||
map[key] = (map[key] ?? 0) + 1;
|
||||
}
|
||||
return map;
|
||||
}, [decks, folderList]);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const unorganized: { id: string; name: string; decks: Deck[]; virtual: boolean } = {
|
||||
id: UNORGANIZED_ID,
|
||||
name: "Unorganized",
|
||||
decks: sorted.filter((deck) => !deck.folderId),
|
||||
virtual: true,
|
||||
};
|
||||
|
||||
if (selected === UNORGANIZED_ID) return [unorganized];
|
||||
if (selected !== ALL_FOLDERS) {
|
||||
const folder = folderList.find((entry) => entry.id === selected);
|
||||
if (!folder) return [unorganized];
|
||||
return [
|
||||
{ id: folder.id, name: folder.name, decks: sorted.filter((deck) => deck.folderId === folder.id), virtual: false },
|
||||
];
|
||||
}
|
||||
|
||||
const named = folderList.map((folder) => ({
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
decks: sorted.filter((deck) => deck.folderId === folder.id),
|
||||
virtual: false,
|
||||
}));
|
||||
return [...named, unorganized];
|
||||
}, [folderList, selected, sorted]);
|
||||
|
||||
const totalActivities = decks.reduce((sum, deck) => sum + deck.activities.length, 0);
|
||||
const bestEver = decks.reduce((best, deck) => Math.max(best, deck.bestScore ?? 0), 0);
|
||||
const played = decks.some((deck) => deck.attempts > 0);
|
||||
|
||||
const handleImport = async (file: File | undefined) => {
|
||||
if (!file) return;
|
||||
try {
|
||||
const text = await file.text();
|
||||
const result = importDeck(text);
|
||||
if ("error" in result) setMessage({ tone: "error", text: result.error });
|
||||
else setMessage({ tone: "ok", text: `Imported “${result.deck.title}”.` });
|
||||
} catch {
|
||||
setMessage({ tone: "error", text: "Could not read that file." });
|
||||
}
|
||||
};
|
||||
|
||||
const removeFolderEverywhere = (folderId: string) => {
|
||||
deleteFolder(folderId);
|
||||
setConfirmDelete(null);
|
||||
setSelected(ALL_FOLDERS);
|
||||
setMessage({ tone: "ok", text: "Folder deleted — its decks moved to Unorganized." });
|
||||
};
|
||||
|
||||
const isEmpty = sorted.length === 0 && folderList.length === 0;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* page header ---------------------------------------------------- */}
|
||||
<section className="mb-6 space-y-5">
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-[24px] font-medium leading-tight tracking-[-0.01em] text-[var(--text)] sm:text-[26px]">
|
||||
Your icebox
|
||||
</h1>
|
||||
<p className="mt-1.5 text-[13.5px] text-[var(--text-subtle)]">
|
||||
Decks frozen from your notes, saved in this browser.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="secondary" size="md" onClick={() => importRef.current?.click()}>
|
||||
<UploadIcon className="size-4" />
|
||||
Import JSON
|
||||
</Button>
|
||||
<input
|
||||
ref={importRef}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
className="sr-only"
|
||||
onChange={(event) => {
|
||||
void handleImport(event.target.files?.[0]);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Secondary stats folded into the header as quiet text on desktop,
|
||||
where the sidebar already shows per-folder counts. */}
|
||||
{decks.length > 0 ? (
|
||||
<p className="text-[13px] text-[var(--text-subtle)] lg:hidden">
|
||||
{decks.length} {decks.length === 1 ? "deck" : "decks"}
|
||||
<span aria-hidden> · </span>
|
||||
{folderList.length} {folderList.length === 1 ? "folder" : "folders"}
|
||||
<span aria-hidden> · </span>
|
||||
{totalActivities} activities
|
||||
{played ? (
|
||||
<>
|
||||
<span aria-hidden> · </span>
|
||||
best {bestEver}%
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{/* Mobile folder switcher; the sidebar covers desktop. */}
|
||||
<FolderBar className="lg:hidden"
|
||||
folders={folderList}
|
||||
counts={counts}
|
||||
selected={selected}
|
||||
onSelect={(id) => {
|
||||
setSelected(id);
|
||||
setConfirmDelete(null);
|
||||
}}
|
||||
onCreated={(id) => {
|
||||
setSelected(id);
|
||||
setMessage({
|
||||
tone: "ok",
|
||||
text: "Folder created — decks you freeze while it's selected land here.",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<ApiKeyBanner />
|
||||
|
||||
{message ? (
|
||||
<p
|
||||
role="status"
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-xl border border-[var(--hairline)] bg-[var(--surface)] px-3.5 py-2.5 text-[13px] font-medium",
|
||||
message.tone === "ok" ? "text-emerald-700 dark:text-emerald-300" : "text-rose-700 dark:text-rose-300",
|
||||
)}
|
||||
>
|
||||
{message.text}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{/* deck grid ------------------------------------------------------ */}
|
||||
{isEmpty ? (
|
||||
<EmptyState
|
||||
title="No decks yet"
|
||||
action={
|
||||
<Link href="/create" className="btn-primary h-10 px-4 text-sm">
|
||||
Make your first deck
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
Freeze a page of notes into a feed of flashcards and quizzes.
|
||||
<span className="mt-3 block text-[12.5px] text-[var(--text-subtle)]">
|
||||
Using your own Ollama models —{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={openSettings}
|
||||
className="font-medium text-[var(--text-muted)] underline underline-offset-2 hover:text-[var(--text)]"
|
||||
>
|
||||
check the connection in Settings
|
||||
</button>
|
||||
.
|
||||
</span>
|
||||
</EmptyState>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{groups.map((group) => {
|
||||
const isEditing = editingFolder?.id === group.id;
|
||||
return (
|
||||
<section key={group.id} aria-label={group.name}>
|
||||
{/* group header -------------------------------------- */}
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<FolderIcon className="size-4 shrink-0 text-[var(--text-subtle)]" />
|
||||
|
||||
{isEditing && editingFolder ? (
|
||||
<span className="flex flex-1 items-center gap-2">
|
||||
<input
|
||||
autoFocus
|
||||
value={editingFolder.name}
|
||||
onChange={(event) => setEditingFolder({ ...editingFolder, name: event.target.value })}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation();
|
||||
if (event.key === "Enter") {
|
||||
const result = renameFolder(editingFolder.id, editingFolder.name);
|
||||
if ("error" in result) setMessage({ tone: "error", text: result.error });
|
||||
else setEditingFolder(null);
|
||||
}
|
||||
if (event.key === "Escape") setEditingFolder(null);
|
||||
}}
|
||||
aria-label="Folder name"
|
||||
maxLength={60}
|
||||
className="min-w-32 max-w-xs flex-1 rounded-lg border border-[var(--hairline)] bg-[var(--surface-strong)] px-2.5 py-1.5 text-[15px] font-semibold text-[var(--text)] outline-none focus:border-accent-400"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const result = renameFolder(editingFolder.id, editingFolder.name);
|
||||
if ("error" in result) setMessage({ tone: "error", text: result.error });
|
||||
else setEditingFolder(null);
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelected(group.virtual ? UNORGANIZED_ID : group.id)}
|
||||
className="truncate text-[15px] font-semibold tracking-[-0.01em] text-[var(--text)]"
|
||||
>
|
||||
{group.name}
|
||||
<span className="ml-2 text-[13px] font-normal tabular-nums text-[var(--text-subtle)]">
|
||||
{group.decks.length}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!group.virtual && !isEditing ? (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingFolder({ id: group.id, name: group.name })}
|
||||
className="rounded-lg px-2 py-1 text-[12.5px] font-medium text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
||||
>
|
||||
Rename
|
||||
</button>
|
||||
{confirmDelete === group.id ? (
|
||||
<>
|
||||
<Button size="sm" variant="danger" onClick={() => removeFolderEverywhere(group.id)}>
|
||||
Delete
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setConfirmDelete(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmDelete(group.id)}
|
||||
className="rounded-lg px-2 py-1 text-[12.5px] font-medium text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-rose-600 dark:hover:text-rose-300"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{confirmDelete === group.id ? (
|
||||
<p className="mb-3 text-[12.5px] text-[var(--text-muted)]">
|
||||
The {group.decks.length} deck{group.decks.length === 1 ? "" : "s"} inside will move to
|
||||
Unorganized — nothing is deleted.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{group.decks.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-[var(--hairline)] px-5 py-8 text-center text-[13px] text-[var(--text-subtle)]">
|
||||
{group.virtual
|
||||
? "Nothing unfiled right now."
|
||||
: "Empty folder — freeze a deck into it or move one over from Unorganized."}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="grid list-none grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-4">
|
||||
{group.decks.map((deck) => (
|
||||
<DeckCard
|
||||
key={deck.id}
|
||||
deck={deck}
|
||||
onMove={(folderId) => {
|
||||
upsertDeck({ ...deck, folderId });
|
||||
setMessage({
|
||||
tone: "ok",
|
||||
text: folderId ? "Deck moved." : "Deck moved to Unorganized.",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
205
components/ResultsScreen.tsx
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
import type { Activity, Deck } from "@/lib/schemas";
|
||||
import { downloadDeck } from "@/lib/storage";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { BoltIcon, DownloadIcon, FlameIcon, HomeIcon, RefreshIcon, SnowflakeIcon } from "@/components/icons";
|
||||
import { Button, Chip } from "@/components/ui";
|
||||
|
||||
import type { AttemptResult } from "./StudyFeed";
|
||||
|
||||
export function answerFor(activity: Activity): string {
|
||||
switch (activity.type) {
|
||||
case "flashcard":
|
||||
return activity.back;
|
||||
case "multiple_choice":
|
||||
return activity.options[activity.correctIndex];
|
||||
case "true_false":
|
||||
return activity.answer ? "True" : "False";
|
||||
case "fill_blank":
|
||||
return activity.answer;
|
||||
case "matching":
|
||||
return activity.pairs.map((pair) => `${pair.term} → ${pair.definition}`).join("; ");
|
||||
case "ordering":
|
||||
return activity.items.map((item, index) => `${index + 1}. ${item}`).join(" ");
|
||||
case "short_answer":
|
||||
return activity.referenceAnswer;
|
||||
}
|
||||
}
|
||||
|
||||
export function promptFor(activity: Activity): string {
|
||||
switch (activity.type) {
|
||||
case "flashcard":
|
||||
return activity.front;
|
||||
case "multiple_choice":
|
||||
return activity.question;
|
||||
case "true_false":
|
||||
return activity.statement;
|
||||
case "fill_blank":
|
||||
return activity.sentence.replace(/_{2,}/, "____");
|
||||
case "matching":
|
||||
return activity.instruction;
|
||||
case "ordering":
|
||||
return activity.instruction;
|
||||
case "short_answer":
|
||||
return activity.question;
|
||||
}
|
||||
}
|
||||
|
||||
export function ResultsScreen({
|
||||
deck,
|
||||
xp,
|
||||
bestStreak,
|
||||
total,
|
||||
correct,
|
||||
missed,
|
||||
results,
|
||||
retryMode,
|
||||
onRetryMissed,
|
||||
onRestart,
|
||||
}: {
|
||||
deck: Deck;
|
||||
xp: number;
|
||||
bestStreak: number;
|
||||
total: number;
|
||||
correct: number;
|
||||
missed: Activity[];
|
||||
results: Record<string, AttemptResult>;
|
||||
retryMode: boolean;
|
||||
onRetryMissed: () => void;
|
||||
onRestart: () => void;
|
||||
}) {
|
||||
const percent = total > 0 ? Math.round((correct / total) * 100) : 0;
|
||||
const perfect = missed.length === 0 && total > 0;
|
||||
const tone = percent >= 80 ? "emerald" : percent >= 50 ? "frost" : "amber";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.96 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="card flex flex-col items-center gap-3 p-6 text-center"
|
||||
>
|
||||
<ScoreRing percent={percent} tone={tone} />
|
||||
<h1 className="text-2xl font-semibold text-frost-950 text-balance dark:text-frost-50">
|
||||
{perfect ? "Perfect freeze! 🧊" : percent >= 60 ? "Nicely done!" : "Good start — keep going!"}
|
||||
</h1>
|
||||
<p className="text-sm text-frost-600 dark:text-frost-300">
|
||||
{perfect
|
||||
? `You aced all ${total} activities in “${deck.title}”.`
|
||||
: `You got ${correct} of ${total} right${retryMode ? " on the retry run" : ""}.`}
|
||||
</p>
|
||||
|
||||
<div className="mt-1 flex flex-wrap justify-center gap-2">
|
||||
<Chip tone="accent">
|
||||
<BoltIcon className="size-3.5" /> {xp} XP earned
|
||||
</Chip>
|
||||
<Chip tone="warning">
|
||||
<FlameIcon className="size-3.5" /> Best streak {bestStreak}
|
||||
</Chip>
|
||||
<Chip tone="neutral">
|
||||
{[deck.difficulty].map((value) => value[0].toUpperCase() + value.slice(1))[0]} difficulty
|
||||
</Chip>
|
||||
{deck.bestScore !== undefined && !retryMode ? (
|
||||
<Chip tone="success">🏆 Best {deck.bestScore}%</Chip>
|
||||
) : null}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{missed.length > 0 ? (
|
||||
<div className="card space-y-3 p-5">
|
||||
<h2 className="flex items-center gap-2 text-base font-bold text-frost-950 dark:text-frost-50">
|
||||
<SnowflakeIcon className="size-4" /> Missed items ({missed.length})
|
||||
</h2>
|
||||
<ul className="space-y-3">
|
||||
{missed.map((activity) => (
|
||||
<li
|
||||
key={activity.id}
|
||||
className="rounded-2xl border border-white/50 bg-white/45 backdrop-blur-xl p-3 text-sm dark:border-white/10 dark:bg-white/5"
|
||||
>
|
||||
<p className="font-semibold text-frost-950 dark:text-frost-50">{promptFor(activity)}</p>
|
||||
{results[activity.id]?.userAnswer ? (
|
||||
<p className="mt-1 text-xs text-rose-600 dark:text-rose-300">
|
||||
Your answer: {results[activity.id]?.userAnswer}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="mt-1 text-xs text-emerald-700 dark:text-emerald-300">
|
||||
<span className="font-bold">Answer: </span>
|
||||
{answerFor(activity)}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{missed.length > 0 ? (
|
||||
<Button size="lg" onClick={onRetryMissed}>
|
||||
<RefreshIcon className="size-5" /> Retry missed ones ({missed.length})
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="lg" variant="secondary" onClick={onRestart}>
|
||||
<RefreshIcon className="size-5" /> Study this deck again
|
||||
</Button>
|
||||
)}
|
||||
{missed.length > 0 ? (
|
||||
<Button size="lg" variant="secondary" onClick={onRestart}>
|
||||
<RefreshIcon className="size-5" /> Study the whole deck again
|
||||
</Button>
|
||||
) : null}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button variant="ghost" onClick={() => downloadDeck(deck)}>
|
||||
<DownloadIcon className="size-4" /> Export JSON
|
||||
</Button>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-white/80 px-4 py-2.5 text-sm font-semibold text-frost-800 transition hover:bg-white dark:bg-white/10 dark:text-frost-100 dark:hover:bg-white/20"
|
||||
>
|
||||
<HomeIcon className="size-4" /> Library
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScoreRing({ percent, tone }: { percent: number; tone: "emerald" | "frost" | "amber" }) {
|
||||
const radius = 52;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const offset = circumference * (1 - Math.min(100, Math.max(0, percent)) / 100);
|
||||
const colors: Record<string, string> = {
|
||||
emerald: "stroke-emerald-500",
|
||||
frost: "stroke-accent-500",
|
||||
amber: "stroke-amber-500",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative grid size-32 place-items-center">
|
||||
<svg viewBox="0 0 120 120" className="size-32 -rotate-90" aria-hidden>
|
||||
<circle cx="60" cy="60" r={radius} className="fill-none stroke-[var(--surface-sunken)]" strokeWidth="10" />
|
||||
<motion.circle
|
||||
cx="60"
|
||||
cy="60"
|
||||
r={radius}
|
||||
strokeWidth="10"
|
||||
strokeLinecap="round"
|
||||
className={cn("fill-none", colors[tone])}
|
||||
strokeDasharray={circumference}
|
||||
initial={{ strokeDashoffset: circumference }}
|
||||
animate={{ strokeDashoffset: offset }}
|
||||
transition={{ duration: 1, ease: "easeOut" }}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute flex flex-col items-center">
|
||||
<span className="text-3xl font-extrabold text-frost-950 dark:text-frost-50">{percent}%</span>
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-frost-500 dark:text-frost-400">
|
||||
score
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
components/ServiceWorkerRegistrar.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Registers the service worker in production builds only.
|
||||
*
|
||||
* Dev stays untouched: a cached shell would fight hot reload, and the worker
|
||||
* would also hide real 404s while developing.
|
||||
*/
|
||||
export function ServiceWorkerRegistrar() {
|
||||
useEffect(() => {
|
||||
if (process.env.NODE_ENV !== "production") return;
|
||||
if (typeof navigator === "undefined" || !("serviceWorker" in navigator)) return;
|
||||
|
||||
const register = () => {
|
||||
navigator.serviceWorker.register("/sw.js", { scope: "/" }).catch((error) => {
|
||||
console.warn("[freezmo] service worker registration failed", error);
|
||||
});
|
||||
};
|
||||
|
||||
if (document.readyState === "complete") register();
|
||||
else {
|
||||
window.addEventListener("load", register, { once: true });
|
||||
return () => window.removeEventListener("load", register);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
289
components/SettingsDialog.tsx
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { clearApiKey, saveSettings } from "@/lib/settings";
|
||||
import { useHealth } from "@/lib/use-health";
|
||||
import { AlertTriangleIcon, CheckCircleIcon, XCircleIcon } from "@/components/icons";
|
||||
import { Button, Spinner } from "@/components/ui";
|
||||
|
||||
export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const { health, loading, error, refresh, apiKey } = useHealth();
|
||||
const [keyDraft, setKeyDraft] = useState(apiKey);
|
||||
const [reveal, setReveal] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setKeyDraft(apiKey);
|
||||
setSaved(false);
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open, apiKey, onClose]);
|
||||
|
||||
const save = () => {
|
||||
saveSettings({ apiKey: keyDraft.trim() });
|
||||
setSaved(true);
|
||||
window.setTimeout(() => setSaved(false), 2500);
|
||||
};
|
||||
|
||||
const forget = () => {
|
||||
clearApiKey();
|
||||
setKeyDraft("");
|
||||
};
|
||||
|
||||
const needsKeyMode = health?.authMode === "user-key";
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open ? (
|
||||
<motion.div
|
||||
className="fixed inset-0 z-50 flex items-end justify-center bg-frost-950/35 p-0 backdrop-blur-md sm:items-center sm:p-6"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="settings-title"
|
||||
initial={{ y: 48, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: 32, opacity: 0 }}
|
||||
transition={{ type: "spring", stiffness: 340, damping: 32 }}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
className="glass-panel thin-scroll max-h-[90dvh] w-full max-w-lg overflow-y-auto rounded-t-[1.6rem] p-5 pb-safe shadow-2xl sm:rounded-[1.35rem] sm:pb-5"
|
||||
>
|
||||
<div className="sheet-grabber sm:hidden" aria-hidden />
|
||||
<div className="mb-4 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 id="settings-title" className="text-lg font-semibold text-frost-950 dark:text-frost-50">
|
||||
Settings
|
||||
</h2>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||||
Models come from <code className="rounded bg-frost-100 px-1 dark:bg-white/10">.env.local</code> on
|
||||
the server; your API key stays in this browser.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={onClose} aria-label="Close settings">
|
||||
✕
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ------------------------------------------------ API key */}
|
||||
<section className="mb-4 space-y-2 rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold text-frost-950 dark:text-frost-50">Ollama API key</h3>
|
||||
{apiKey ? (
|
||||
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-bold text-emerald-900 dark:bg-emerald-400/20 dark:text-emerald-200">
|
||||
saved in this browser
|
||||
</span>
|
||||
) : needsKeyMode ? (
|
||||
<span className="rounded-full bg-amber-50 px-2 py-0.5 text-[11px] font-semibold text-amber-800 dark:bg-amber-500/15 dark:text-amber-200">
|
||||
required
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded-full bg-[var(--surface-sunken)] px-2 py-0.5 text-[11px] font-semibold text-frost-600 dark:text-frost-300">
|
||||
not needed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<label className="sr-only" htmlFor="ollama-api-key">
|
||||
Ollama API key
|
||||
</label>
|
||||
<input
|
||||
id="ollama-api-key"
|
||||
type={reveal ? "text" : "password"}
|
||||
value={keyDraft}
|
||||
onChange={(event) => setKeyDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation();
|
||||
if (event.key === "Enter") save();
|
||||
}}
|
||||
placeholder="paste your key from ollama.com/settings/keys"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="min-w-40 flex-1 rounded-xl border border-[var(--hairline)] bg-[var(--surface-strong)] px-3 py-2 font-mono text-xs text-frost-950 outline-none transition placeholder:font-sans placeholder:text-frost-400 focus:border-accent-500 dark:text-frost-50"
|
||||
/>
|
||||
<Button size="sm" variant="secondary" onClick={() => setReveal((value) => !value)}>
|
||||
{reveal ? "Hide" : "Show"}
|
||||
</Button>
|
||||
<Button size="sm" onClick={save} disabled={keyDraft.trim() === apiKey}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] leading-relaxed text-frost-500 dark:text-frost-400">
|
||||
Stored only in this browser's local storage and sent with each request so the server can talk to
|
||||
Ollama for you. It is never written to the server, never logged and never put in a cookie. Prefer the
|
||||
local daemon? Set <code>OLLAMA_AUTH=local</code> in <code>.env.local</code> and use{" "}
|
||||
<code>ollama signin</code> instead.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 pt-1">
|
||||
<Button size="sm" variant="secondary" onClick={() => void refresh()} loading={loading}>
|
||||
Test connection
|
||||
</Button>
|
||||
{apiKey ? (
|
||||
<Button size="sm" variant="ghost" onClick={forget}>
|
||||
Remove key from this browser
|
||||
</Button>
|
||||
) : null}
|
||||
{saved ? (
|
||||
<span className="text-xs font-semibold text-emerald-700 dark:text-emerald-300">
|
||||
Saved — testing…
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{loading && !health ? (
|
||||
<div className="flex items-center gap-3 py-6 text-sm text-slate-600 dark:text-slate-300">
|
||||
<Spinner className="size-5" /> Checking Ollama…
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-2xl bg-rose-100 p-3 text-sm text-rose-900 dark:bg-rose-500/15 dark:text-rose-200">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{health ? <HealthPanel health={health} /> : null}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
function HealthPanel({ health }: { health: ReturnType<typeof useHealth>["health"] }) {
|
||||
if (!health) return null;
|
||||
const tone = health.ok ? "emerald" : health.needsKey ? "amber" : health.reachable ? "amber" : "rose";
|
||||
const toneClasses: Record<string, string> = {
|
||||
emerald: "bg-emerald-50 text-emerald-800 dark:bg-emerald-500/15 dark:text-emerald-200",
|
||||
amber: "bg-amber-50 text-amber-800 dark:bg-amber-500/15 dark:text-amber-200",
|
||||
rose: "bg-rose-50 text-rose-800 dark:bg-rose-500/15 dark:text-rose-200",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className={cn("flex items-start gap-2 rounded-2xl p-3 text-sm font-medium", toneClasses[tone])}>
|
||||
<span aria-hidden className="mt-0.5 shrink-0">
|
||||
{tone === "emerald" ? (
|
||||
<CheckCircleIcon className="size-5" />
|
||||
) : tone === "amber" ? (
|
||||
<AlertTriangleIcon className="size-5" />
|
||||
) : (
|
||||
<XCircleIcon className="size-5" />
|
||||
)}
|
||||
</span>
|
||||
<span>
|
||||
{health.ok
|
||||
? "Ollama is reachable and both models are available."
|
||||
: health.needsKey
|
||||
? "Waiting for your API key."
|
||||
: health.keyAccepted === false
|
||||
? "Ollama rejected your API key."
|
||||
: health.reachable
|
||||
? "Ollama is reachable, but a configured model is missing."
|
||||
: "Ollama isn't reachable."}
|
||||
{health.error ? <p className="mt-1 font-normal">{health.error}</p> : null}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<dl className="grid grid-cols-2 gap-2 text-sm">
|
||||
<Info
|
||||
label="Auth"
|
||||
value={
|
||||
health.authMode === "user-key"
|
||||
? "Your own key"
|
||||
: health.authMode === "server"
|
||||
? "Server key (.env)"
|
||||
: "Local daemon sign-in"
|
||||
}
|
||||
/>
|
||||
<Info label="Endpoint" value={health.baseUrl} mono />
|
||||
<Info label="Mode" value={health.mode === "direct-cloud" ? "Direct cloud API" : "Local daemon"} />
|
||||
<Info
|
||||
label="Key"
|
||||
value={
|
||||
health.needsKey
|
||||
? "missing"
|
||||
: health.keyAccepted === true
|
||||
? "accepted"
|
||||
: health.keyAccepted === false
|
||||
? "rejected"
|
||||
: health.authMode === "local"
|
||||
? "not needed"
|
||||
: "unknown"
|
||||
}
|
||||
/>
|
||||
</dl>
|
||||
|
||||
<div className="space-y-2">
|
||||
<ModelRow title="Vision model (reads photos)" model={health.vision} />
|
||||
<ModelRow title="Text model (builds & grades)" model={health.text} />
|
||||
</div>
|
||||
|
||||
{health.mode === "direct-cloud" && health.needsKey ? (
|
||||
<p className="rounded-2xl bg-[var(--surface-sunken)] p-3 text-xs text-frost-700 dark:text-frost-200">
|
||||
Create a key at{" "}
|
||||
<a className="font-semibold underline underline-offset-2" href="https://ollama.com/settings/keys" target="_blank" rel="noreferrer">
|
||||
ollama.com/settings/keys
|
||||
</a>{" "}
|
||||
and paste it above. Tip: with the local daemon you can run <code>ollama signin</code> instead and set{" "}
|
||||
<code>OLLAMA_AUTH=local</code>.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Info({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
|
||||
<dt className="text-xs uppercase tracking-wide text-frost-500 dark:text-frost-400">{label}</dt>
|
||||
<dd className={cn("mt-0.5 truncate font-semibold", mono && "font-mono text-xs")}>{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelRow({
|
||||
title,
|
||||
model,
|
||||
}: {
|
||||
title: string;
|
||||
model: { configured: string; resolved: string; available: boolean | null; note?: string };
|
||||
}) {
|
||||
const badge =
|
||||
model.available === true
|
||||
? { text: "available", className: "bg-emerald-100 text-emerald-900 dark:bg-emerald-400/20 dark:text-emerald-200" }
|
||||
: model.available === false
|
||||
? { text: "not found", className: "bg-rose-50 text-rose-800 dark:bg-rose-500/15 dark:text-rose-200" }
|
||||
: { text: "unknown", className: "bg-[var(--surface-sunken)] text-frost-600 dark:text-frost-300" };
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs uppercase tracking-wide text-frost-500 dark:text-frost-400">{title}</p>
|
||||
<span className={cn("rounded-full px-2 py-0.5 text-[11px] font-bold", badge.className)}>
|
||||
{badge.text}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 font-mono text-sm font-semibold">{model.resolved}</p>
|
||||
{model.configured !== model.resolved ? (
|
||||
<p className="text-xs text-frost-500 dark:text-frost-400">configured as {model.configured}</p>
|
||||
) : null}
|
||||
{model.note ? <p className="mt-1 text-xs text-amber-700 dark:text-amber-300">{model.note}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
362
components/Sidebar.tsx
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState, useSyncExternalStore } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
FolderIcon,
|
||||
GearIcon,
|
||||
LayersIcon,
|
||||
MoonIcon,
|
||||
PlusIcon,
|
||||
SnowflakeIcon,
|
||||
SunIcon,
|
||||
} from "@/components/icons";
|
||||
import { useTheme } from "@/components/ThemeToggle";
|
||||
import { createFolder, folderStore } from "@/lib/folders";
|
||||
import { ALL_FOLDERS, folderSelection, sidebarOpen, useSelectedFolder, useSidebarOpen } from "@/lib/nav";
|
||||
import { UNORGANIZED_ID } from "@/lib/schemas";
|
||||
import { deckStore } from "@/lib/storage";
|
||||
import { openSettings } from "@/lib/ui-events";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const COLLAPSE_KEY = "freezmo.sidebarCollapsed";
|
||||
|
||||
/** Shared nav content for the desktop rail and the mobile drawer. */
|
||||
function NavContent({ collapsed = false, onNavigate }: { collapsed?: boolean; onNavigate?: () => void }) {
|
||||
const decks = useSyncExternalStore(deckStore.subscribe, deckStore.getSnapshot, deckStore.getServerSnapshot);
|
||||
const folders = useSyncExternalStore(
|
||||
folderStore.subscribe,
|
||||
folderStore.getSnapshot,
|
||||
folderStore.getServerSnapshot,
|
||||
);
|
||||
const selected = useSelectedFolder();
|
||||
const { theme, toggle } = useTheme();
|
||||
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const sortedFolders = useMemo(
|
||||
() => [...folders].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" })),
|
||||
[folders],
|
||||
);
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const map: Record<string, number> = { [UNORGANIZED_ID]: 0 };
|
||||
for (const folder of sortedFolders) map[folder.id] = 0;
|
||||
for (const deck of decks) {
|
||||
const key = deck.folderId && map[deck.folderId] !== undefined ? deck.folderId : UNORGANIZED_ID;
|
||||
map[key] = (map[key] ?? 0) + 1;
|
||||
}
|
||||
return map;
|
||||
}, [decks, sortedFolders]);
|
||||
|
||||
const submit = () => {
|
||||
const result = createFolder(draft);
|
||||
if ("error" in result) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
folderSelection.set(result.folder.id);
|
||||
setDraft("");
|
||||
setError(null);
|
||||
setCreating(false);
|
||||
};
|
||||
|
||||
const select = (id: string) => {
|
||||
folderSelection.set(id);
|
||||
onNavigate?.();
|
||||
};
|
||||
|
||||
/** Counts of 0 are noise — only show a count when there is something in it. */
|
||||
const count = (value: number) =>
|
||||
!collapsed && value > 0 ? <span className="text-[12px] tabular-nums opacity-60">{value}</span> : null;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{/* brand ---------------------------------------------------------- */}
|
||||
<div className={cn("mb-1 flex items-center", collapsed ? "justify-center" : "justify-between")}>
|
||||
<Link
|
||||
href="/"
|
||||
onClick={onNavigate}
|
||||
className={cn("nav-row gap-2.5", collapsed ? "w-auto px-2" : "flex-1")}
|
||||
aria-label="Freezmo home"
|
||||
title="Freezmo"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="grid size-7 shrink-0 place-items-center rounded-lg bg-[var(--text)] text-[var(--page)]"
|
||||
>
|
||||
<SnowflakeIcon className="size-4" />
|
||||
</span>
|
||||
{!collapsed ? (
|
||||
<span className="text-[15px] font-semibold tracking-[-0.01em] text-[var(--text)]">Freezmo</span>
|
||||
) : null}
|
||||
</Link>
|
||||
{!collapsed ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => sidebarCollapse.set(true)}
|
||||
aria-label="Collapse sidebar"
|
||||
title="Collapse sidebar"
|
||||
className="grid size-8 place-items-center rounded-lg text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
||||
>
|
||||
<ChevronLeftIcon className="size-4" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* new deck: a quiet row, not a competing primary button ---------- */}
|
||||
<Link
|
||||
href="/create"
|
||||
onClick={onNavigate}
|
||||
className={cn("nav-row mb-3", collapsed && "justify-center px-2")}
|
||||
title="New deck"
|
||||
>
|
||||
<PlusIcon className="size-4 shrink-0" />
|
||||
{!collapsed ? <span className="flex-1 truncate text-left">New deck</span> : null}
|
||||
</Link>
|
||||
|
||||
{/* folders --------------------------------------------------------- */}
|
||||
{!collapsed ? (
|
||||
<div className="flex items-center justify-between px-2 pb-1">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-[var(--text-subtle)]">
|
||||
Folders
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreating(true)}
|
||||
aria-label="New folder"
|
||||
title="New folder"
|
||||
className="grid size-6 place-items-center rounded-md text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
||||
>
|
||||
<PlusIcon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<nav aria-label="Folders" className="thin-scroll min-h-0 flex-1 space-y-0.5 overflow-y-auto">
|
||||
{collapsed ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => sidebarCollapse.set(false)}
|
||||
aria-label="Expand sidebar"
|
||||
title="Expand sidebar"
|
||||
className="nav-row justify-center px-2"
|
||||
>
|
||||
<ChevronRightIcon className="size-4" />
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => select(ALL_FOLDERS)}
|
||||
aria-pressed={selected === ALL_FOLDERS}
|
||||
title="All decks"
|
||||
className={cn("nav-row", collapsed && "justify-center px-2")}
|
||||
>
|
||||
<LayersIcon className="size-4 shrink-0 opacity-70" />
|
||||
{!collapsed ? <span className="flex-1 truncate text-left">All decks</span> : null}
|
||||
{count(decks.length)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => select(UNORGANIZED_ID)}
|
||||
aria-pressed={selected === UNORGANIZED_ID}
|
||||
title="Unorganized"
|
||||
className={cn("nav-row", collapsed && "justify-center px-2")}
|
||||
>
|
||||
<FolderIcon className="size-4 shrink-0 opacity-70" />
|
||||
{!collapsed ? <span className="flex-1 truncate text-left">Unorganized</span> : null}
|
||||
{count(counts[UNORGANIZED_ID] ?? 0)}
|
||||
</button>
|
||||
|
||||
{sortedFolders.map((folder) => (
|
||||
<button
|
||||
key={folder.id}
|
||||
type="button"
|
||||
onClick={() => select(folder.id)}
|
||||
aria-pressed={selected === folder.id}
|
||||
title={folder.name}
|
||||
className={cn("nav-row", collapsed && "justify-center px-2")}
|
||||
>
|
||||
<FolderIcon className="size-4 shrink-0 opacity-70" />
|
||||
{!collapsed ? <span className="flex-1 truncate text-left">{folder.name}</span> : null}
|
||||
{count(counts[folder.id] ?? 0)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{creating && !collapsed ? (
|
||||
<div className="flex items-center gap-1 px-1 pt-1">
|
||||
<input
|
||||
autoFocus
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation();
|
||||
if (event.key === "Enter") submit();
|
||||
if (event.key === "Escape") {
|
||||
setCreating(false);
|
||||
setDraft("");
|
||||
setError(null);
|
||||
}
|
||||
}}
|
||||
placeholder="Folder name"
|
||||
aria-label="New folder name"
|
||||
maxLength={60}
|
||||
className="min-w-0 flex-1 rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-2.5 py-1.5 text-[13px] text-[var(--text)] outline-none focus:border-[var(--text-subtle)]"
|
||||
/>
|
||||
<button type="button" onClick={submit} className="btn-primary h-8 px-3 text-[12px]">
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? <p className="px-2 pt-1 text-[12px] text-rose-600 dark:text-rose-300">{error}</p> : null}
|
||||
</nav>
|
||||
|
||||
{/* footer ---------------------------------------------------------- */}
|
||||
<div className="mt-2 space-y-0.5 border-t border-[var(--hairline)] pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={openSettings}
|
||||
title="Settings"
|
||||
className={cn("nav-row", collapsed && "justify-center px-2")}
|
||||
>
|
||||
<GearIcon className="size-4 shrink-0 opacity-70" />
|
||||
{!collapsed ? <span className="flex-1 truncate text-left">Settings</span> : null}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
title={theme === "dark" ? "Light mode" : "Dark mode"}
|
||||
className={cn("nav-row", collapsed && "justify-center px-2")}
|
||||
>
|
||||
{theme === "dark" ? (
|
||||
<SunIcon className="size-4 shrink-0 opacity-70" />
|
||||
) : (
|
||||
<MoonIcon className="size-4 shrink-0 opacity-70" />
|
||||
)}
|
||||
{!collapsed ? (
|
||||
<span className="flex-1 truncate text-left">{theme === "dark" ? "Light mode" : "Dark mode"}</span>
|
||||
) : null}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Collapsed state (shared between rail and drawer) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
let collapsed = false;
|
||||
const collapseListeners = new Set<() => void>();
|
||||
|
||||
export const sidebarCollapse = {
|
||||
subscribe(listener: () => void): () => void {
|
||||
collapseListeners.add(listener);
|
||||
return () => collapseListeners.delete(listener);
|
||||
},
|
||||
getSnapshot(): boolean {
|
||||
return collapsed;
|
||||
},
|
||||
getServerSnapshot(): boolean {
|
||||
return false;
|
||||
},
|
||||
set(value: boolean): void {
|
||||
collapsed = value;
|
||||
try {
|
||||
window.localStorage.setItem(COLLAPSE_KEY, value ? "1" : "0");
|
||||
} catch {
|
||||
/* private mode */
|
||||
}
|
||||
collapseListeners.forEach((listener) => listener());
|
||||
},
|
||||
hydrate(): void {
|
||||
try {
|
||||
collapsed = window.localStorage.getItem(COLLAPSE_KEY) === "1";
|
||||
} catch {
|
||||
collapsed = false;
|
||||
}
|
||||
collapseListeners.forEach((listener) => listener());
|
||||
},
|
||||
};
|
||||
|
||||
function useCollapsed(): boolean {
|
||||
return useSyncExternalStore(
|
||||
sidebarCollapse.subscribe,
|
||||
sidebarCollapse.getSnapshot,
|
||||
sidebarCollapse.getServerSnapshot,
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Desktop rail + mobile drawer */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function Sidebar() {
|
||||
const isCollapsed = useCollapsed();
|
||||
|
||||
return (
|
||||
<aside
|
||||
aria-label="Sidebar"
|
||||
className={cn(
|
||||
"sidebar-surface fixed inset-y-0 left-0 z-40 hidden flex-col px-2.5 py-3 transition-[width] duration-200 lg:flex",
|
||||
isCollapsed ? "w-[68px]" : "w-[260px]",
|
||||
)}
|
||||
>
|
||||
<NavContent collapsed={isCollapsed} />
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export function MobileSidebar() {
|
||||
const open = useSidebarOpen();
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open ? (
|
||||
<motion.div
|
||||
className="fixed inset-0 z-50 flex lg:hidden"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close menu"
|
||||
onClick={() => sidebarOpen.set(false)}
|
||||
className="absolute inset-0 bg-black/40 backdrop-blur-sm"
|
||||
/>
|
||||
<motion.aside
|
||||
aria-label="Menu"
|
||||
initial={{ x: -280 }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: -280 }}
|
||||
transition={{ type: "spring", stiffness: 380, damping: 36 }}
|
||||
className="sidebar-surface relative z-10 flex h-full w-[272px] flex-col px-2.5 py-3 pb-safe"
|
||||
>
|
||||
<NavContent onNavigate={() => sidebarOpen.set(false)} />
|
||||
</motion.aside>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
/** Spacer so page content clears the fixed rail (desktop only). */
|
||||
export function SidebarOffset() {
|
||||
const isCollapsed = useCollapsed();
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn("hidden shrink-0 transition-[width] duration-200 lg:block", isCollapsed ? "w-[68px]" : "w-[260px]")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
278
components/StudyFeed.tsx
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { ActivityRenderer } from "@/components/activities";
|
||||
import { celebrate } from "@/lib/confetti";
|
||||
import type { Activity, Deck } from "@/lib/schemas";
|
||||
import { recordAttempt } from "@/lib/storage";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { BoltIcon, FlameIcon } from "@/components/icons";
|
||||
import { Button, ProgressBar } from "@/components/ui";
|
||||
|
||||
import { ResultsScreen } from "./ResultsScreen";
|
||||
|
||||
export type AttemptResult = { correct: boolean; userAnswer?: string };
|
||||
|
||||
export function StudyFeed({ deck }: { deck: Deck }) {
|
||||
const [queue, setQueue] = useState<Activity[]>(deck.activities);
|
||||
const [index, setIndex] = useState(0);
|
||||
const [results, setResults] = useState<Record<string, AttemptResult>>({});
|
||||
const [xp, setXp] = useState(0);
|
||||
const [streak, setStreak] = useState(0);
|
||||
const [bestStreak, setBestStreak] = useState(0);
|
||||
const [retryMode, setRetryMode] = useState(false);
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const recordedRef = useRef(false);
|
||||
|
||||
const total = queue.length;
|
||||
const slideCount = total + 1; // + results slide
|
||||
|
||||
const goTo = useCallback((target: number) => {
|
||||
const container = scrollRef.current;
|
||||
if (!container) return;
|
||||
const clamped = Math.max(0, Math.min(target, slideCount - 1));
|
||||
container.scrollTo({ top: clamped * container.clientHeight, behavior: "smooth" });
|
||||
}, [slideCount]);
|
||||
|
||||
const next = useCallback(() => goTo(index + 1), [goTo, index]);
|
||||
|
||||
// Track which slide is visible (works for wheel, touch and programmatic scroll).
|
||||
useEffect(() => {
|
||||
const container = scrollRef.current;
|
||||
if (!container) return;
|
||||
let frame = 0;
|
||||
const onScroll = () => {
|
||||
cancelAnimationFrame(frame);
|
||||
frame = requestAnimationFrame(() => {
|
||||
const height = container.clientHeight || 1;
|
||||
const current = Math.round(container.scrollTop / height);
|
||||
setIndex((previous) => (previous === current ? previous : current));
|
||||
});
|
||||
};
|
||||
container.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => {
|
||||
container.removeEventListener("scroll", onScroll);
|
||||
cancelAnimationFrame(frame);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Keyboard navigation.
|
||||
useEffect(() => {
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target && (/^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName) || target.isContentEditable)) return;
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
||||
if (event.key === "ArrowDown" || event.key === "PageDown" || event.key === "j") {
|
||||
event.preventDefault();
|
||||
next();
|
||||
} else if (event.key === "ArrowUp" || event.key === "PageUp" || event.key === "k") {
|
||||
event.preventDefault();
|
||||
goTo(index - 1);
|
||||
} else if (event.key === "Home") {
|
||||
event.preventDefault();
|
||||
goTo(0);
|
||||
} else if (event.key === "End") {
|
||||
event.preventDefault();
|
||||
goTo(slideCount - 1);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [goTo, index, next, slideCount]);
|
||||
|
||||
const finished = index >= total;
|
||||
|
||||
useEffect(() => {
|
||||
if (!finished) return;
|
||||
if (!retryMode && !recordedRef.current) {
|
||||
recordedRef.current = true;
|
||||
const answered = Object.values(results);
|
||||
const correct = answered.filter((entry) => entry.correct).length;
|
||||
const score = total > 0 ? (correct / total) * 100 : 0;
|
||||
recordAttempt(deck.id, { scorePercent: score, correct, total });
|
||||
void celebrate("big");
|
||||
}
|
||||
}, [finished, results, total, deck.id, retryMode]);
|
||||
|
||||
const handleAnswered = useCallback(
|
||||
(activity: Activity, correct: boolean, meta?: { userAnswer?: string }) => {
|
||||
setResults((current) => {
|
||||
if (current[activity.id]) return current; // first answer counts
|
||||
return { ...current, [activity.id]: { correct, userAnswer: meta?.userAnswer } };
|
||||
});
|
||||
if (correct) {
|
||||
const nextStreak = streak + 1;
|
||||
setStreak(nextStreak);
|
||||
setBestStreak((best) => Math.max(best, nextStreak));
|
||||
setXp((value) => value + activity.xp);
|
||||
if (nextStreak > 0 && nextStreak % 5 === 0) void celebrate("normal");
|
||||
} else {
|
||||
setStreak(0);
|
||||
}
|
||||
},
|
||||
[streak],
|
||||
);
|
||||
|
||||
const missed = useMemo(
|
||||
() => queue.filter((activity) => !results[activity.id]?.correct),
|
||||
[queue, results],
|
||||
);
|
||||
|
||||
const correctCount = useMemo(
|
||||
() => queue.filter((activity) => results[activity.id]?.correct).length,
|
||||
[queue, results],
|
||||
);
|
||||
|
||||
const restart = useCallback(
|
||||
(activities: Activity[], retry: boolean) => {
|
||||
setQueue(activities);
|
||||
setResults({});
|
||||
setStreak(0);
|
||||
setRetryMode(retry);
|
||||
recordedRef.current = retry; // don't overwrite the best score with a retry run
|
||||
setIndex(0);
|
||||
requestAnimationFrame(() => goTo(0));
|
||||
},
|
||||
[goTo],
|
||||
);
|
||||
|
||||
const progressValue = Math.min(index + 1, slideCount);
|
||||
const progressMax = slideCount;
|
||||
|
||||
return (
|
||||
<div className="frost-bg fixed inset-0 flex flex-col">
|
||||
{/* Chrome ---------------------------------------------------- */}
|
||||
<header className="pt-safe absolute inset-x-0 top-0 z-30 px-3">
|
||||
<div className="glass mx-auto max-w-xl space-y-1.5 rounded-2xl px-3 py-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href="/"
|
||||
aria-label="Back to your library"
|
||||
className="grid size-9 shrink-0 place-items-center rounded-xl bg-white/80 text-sm font-bold text-frost-800 transition hover:bg-white dark:bg-white/10 dark:text-frost-100"
|
||||
>
|
||||
✕
|
||||
</Link>
|
||||
<div className="min-w-0 flex-1">
|
||||
<ProgressBar
|
||||
value={progressValue}
|
||||
max={progressMax}
|
||||
label={`Activity ${progressValue} of ${progressMax}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-xl px-2 py-1 text-xs font-extrabold transition",
|
||||
streak >= 3
|
||||
? "tint-peach"
|
||||
: "glass-chip text-frost-600 dark:text-frost-200",
|
||||
)}
|
||||
title={`Answer streak: ${streak}`}
|
||||
aria-label={`Answer streak: ${streak}`}
|
||||
>
|
||||
<FlameIcon className="size-3.5" />
|
||||
{streak}
|
||||
</span>
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded-xl bg-frost-500/15 px-2 py-1 text-xs font-extrabold text-frost-800 dark:text-frost-100"
|
||||
title={`XP earned: ${xp}`}
|
||||
aria-label={`XP earned: ${xp}`}
|
||||
>
|
||||
<BoltIcon className="size-3.5" />
|
||||
{xp}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 text-[11px] font-semibold text-frost-600 dark:text-frost-300">
|
||||
<span className="min-w-0 truncate">{deck.title}</span>
|
||||
<span className="shrink-0">
|
||||
{retryMode ? "Retry · " : ""}
|
||||
{Math.min(index + 1, total)}/{total}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Feed ------------------------------------------------------ */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
data-testid="study-feed"
|
||||
className="no-scrollbar h-full snap-y snap-mandatory overflow-y-scroll overscroll-y-contain"
|
||||
>
|
||||
{queue.map((activity, activityIndex) => (
|
||||
<section
|
||||
key={activity.id}
|
||||
aria-label={`Activity ${activityIndex + 1} of ${total}`}
|
||||
className="flex h-full snap-start snap-always items-stretch justify-center overflow-y-auto overscroll-y-contain"
|
||||
>
|
||||
<div className="mx-auto flex min-h-full w-full max-w-xl flex-col gap-4 px-4 pb-24 pt-24 [justify-content:safe_center]">
|
||||
<ActivityRenderer
|
||||
activity={activity}
|
||||
active={index === activityIndex}
|
||||
language={deck.language}
|
||||
isLast={activityIndex === total - 1}
|
||||
onAnswered={(correct, meta) => handleAnswered(activity, correct, meta)}
|
||||
onNext={() => goTo(activityIndex + 1)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
<section
|
||||
aria-label="Results"
|
||||
className="h-full snap-start snap-always overflow-y-auto overscroll-y-contain"
|
||||
>
|
||||
<div className="mx-auto flex min-h-full w-full max-w-xl flex-col gap-4 px-4 pb-24 pt-24 [justify-content:safe_center]">
|
||||
<ResultsScreen
|
||||
deck={deck}
|
||||
xp={xp}
|
||||
bestStreak={bestStreak}
|
||||
total={total}
|
||||
correct={correctCount}
|
||||
missed={missed}
|
||||
results={results}
|
||||
retryMode={retryMode}
|
||||
onRetryMissed={() => restart(missed, true)}
|
||||
onRestart={() => restart(deck.activities, false)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Swipe hint on the very first screen ----------------------- */}
|
||||
<AnimatePresence>
|
||||
{index === 0 && Object.keys(results).length === 0 ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="pointer-events-none absolute inset-x-0 bottom-6 z-20 flex justify-center"
|
||||
>
|
||||
<span className="rounded-full bg-frost-950/70 px-4 py-2 text-xs font-semibold text-white backdrop-blur">
|
||||
Swipe up for the next activity ↑
|
||||
</span>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Desktop next/prev buttons --------------------------------- */}
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 hidden justify-center pb-4 sm:flex">
|
||||
<div className="glass pointer-events-auto flex items-center gap-2 rounded-2xl px-2 py-1.5">
|
||||
<Button variant="ghost" size="sm" onClick={() => goTo(index - 1)} disabled={index === 0} aria-label="Previous activity">
|
||||
↑
|
||||
</Button>
|
||||
<span className="px-2 text-xs font-semibold text-frost-600 dark:text-frost-300">
|
||||
{index + 1} / {slideCount}
|
||||
</span>
|
||||
<Button variant="ghost" size="sm" onClick={() => goTo(index + 1)} disabled={index >= slideCount - 1} aria-label="Next activity">
|
||||
↓
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
components/TabBar.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { GearIcon, HomeIcon, PlusIcon } from "@/components/icons";
|
||||
import { openSettings } from "@/lib/ui-events";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* iOS-style bottom tab bar. Rendered on the library and create screens on
|
||||
* phones; hidden on desktop (the header carries the same actions there) and on
|
||||
* the full-screen study feed.
|
||||
*/
|
||||
export function TabBar() {
|
||||
const pathname = usePathname();
|
||||
|
||||
const item = "flex flex-1 flex-col items-center justify-center gap-1 py-2 text-[11px] font-medium transition-colors";
|
||||
const active = "text-[var(--text)]";
|
||||
const idle = "text-[var(--text-subtle)]";
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="Main"
|
||||
className="tabbar fixed inset-x-0 bottom-0 z-40 flex items-stretch pb-safe sm:hidden"
|
||||
>
|
||||
<Link href="/" aria-current={pathname === "/" ? "page" : undefined} className={cn(item, pathname === "/" ? active : idle)}>
|
||||
<HomeIcon className="size-6" />
|
||||
Library
|
||||
</Link>
|
||||
<Link
|
||||
href="/create"
|
||||
aria-current={pathname === "/create" ? "page" : undefined}
|
||||
className={cn(item, pathname === "/create" ? active : idle)}
|
||||
>
|
||||
<span className="grid size-6 place-items-center rounded-full bg-[var(--text)] text-white dark:bg-white dark:text-[#0d0d0d]">
|
||||
<PlusIcon className="size-4" />
|
||||
</span>
|
||||
New deck
|
||||
</Link>
|
||||
<button type="button" onClick={openSettings} className={cn(item, idle)}>
|
||||
<GearIcon className="size-6" />
|
||||
Settings
|
||||
</button>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
54
components/ThemeToggle.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MoonIcon, SunIcon } from "@/components/icons";
|
||||
|
||||
const STORAGE_KEY = "freezmo.theme";
|
||||
|
||||
export function useTheme() {
|
||||
const [theme, setTheme] = useState<"light" | "dark">("light");
|
||||
|
||||
useEffect(() => {
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||
const isDark = stored
|
||||
? stored === "dark"
|
||||
: window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
setTheme(isDark ? "dark" : "light");
|
||||
}, []);
|
||||
|
||||
const apply = (next: "light" | "dark") => {
|
||||
setTheme(next);
|
||||
document.documentElement.classList.toggle("dark", next === "dark");
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, next);
|
||||
} catch {
|
||||
/* private mode */
|
||||
}
|
||||
};
|
||||
|
||||
return { theme, toggle: () => apply(theme === "dark" ? "light" : "dark") };
|
||||
}
|
||||
|
||||
export function ThemeToggle({ className }: { className?: string }) {
|
||||
const { theme, toggle } = useTheme();
|
||||
const dark = theme === "dark";
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
aria-label={dark ? "Switch to light mode" : "Switch to dark mode"}
|
||||
title={dark ? "Light mode" : "Dark mode"}
|
||||
className={cn(
|
||||
"inline-flex size-9 items-center justify-center rounded-full text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span aria-hidden>
|
||||
{dark ? <SunIcon className="size-5" /> : <MoonIcon className="size-5" />}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
804
components/UploadScreen.tsx
Normal file
|
|
@ -0,0 +1,804 @@
|
|||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||
|
||||
import { ApiError, generateDeck, type GenerateProgress } from "@/lib/client-api";
|
||||
import {
|
||||
DOCUMENT_ACCEPT_ATTR,
|
||||
KIND_LABELS,
|
||||
describeDocumentError,
|
||||
documentKindOf,
|
||||
documentsToText,
|
||||
extractDocument,
|
||||
isDocumentFile,
|
||||
type DocumentKind,
|
||||
type ExtractedDocument,
|
||||
} from "@/lib/documents";
|
||||
import {
|
||||
ACCEPTED_TYPES,
|
||||
MAX_IMAGES,
|
||||
describeFileError,
|
||||
formatBytes,
|
||||
imageFilesFromDataTransfer,
|
||||
isImageFile,
|
||||
prepareImage,
|
||||
type PreparedImage,
|
||||
} from "@/lib/image";
|
||||
import { createFolder, folderStore } from "@/lib/folders";
|
||||
import { UNORGANIZED_ID, type Difficulty } from "@/lib/schemas";
|
||||
import { upsertDeck } from "@/lib/storage";
|
||||
import { openSettings } from "@/lib/ui-events";
|
||||
import { cn, uid } from "@/lib/utils";
|
||||
import { ApiKeyBanner } from "@/components/ApiKeyBanner";
|
||||
import { CameraIcon, FolderIcon, IceCubeIcon, PlusIcon, SnowflakeIcon, SunIcon } from "@/components/icons";
|
||||
import { Button, ProgressBar } from "@/components/ui";
|
||||
|
||||
type Phase = "idle" | "preparing" | "loading" | "error";
|
||||
|
||||
/** A document the user added, with its client-side extraction state. */
|
||||
type DocItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: DocumentKind;
|
||||
status: "extracting" | "ready" | "error";
|
||||
progress?: string;
|
||||
extracted?: ExtractedDocument;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const MAX_DOCUMENTS = 6;
|
||||
/** Photos + rendered PDF pages + images embedded in documents. */
|
||||
const MAX_IMAGES_TO_SEND = 12;
|
||||
/** Cap for the merged text sent to the API. */
|
||||
const MAX_TEXT_TO_SEND = 100_000;
|
||||
|
||||
function describeExtraction(document: ExtractedDocument): string {
|
||||
const parts = [
|
||||
`${document.words.toLocaleString()} word${document.words === 1 ? "" : "s"}`,
|
||||
document.pages ? `${document.pages} page${document.pages === 1 ? "" : "s"}` : "",
|
||||
document.images.length
|
||||
? `${document.images.length} page image${document.images.length === 1 ? "" : "s"}`
|
||||
: "",
|
||||
document.truncated ? "truncated" : "",
|
||||
];
|
||||
return parts.filter(Boolean).join(" · ");
|
||||
}
|
||||
|
||||
const DIFFICULTIES: Array<{ value: Difficulty; label: string; icon: React.ReactNode; blurb: string }> = [
|
||||
{
|
||||
value: "easy",
|
||||
label: "Easy",
|
||||
icon: <SunIcon className="size-5" />,
|
||||
blurb: "Recall & recognition",
|
||||
},
|
||||
{ value: "medium", label: "Medium", icon: <SnowflakeIcon className="size-5" />, blurb: "Understanding & application" },
|
||||
{ value: "hard", label: "Hard", icon: <IceCubeIcon className="size-5" />, blurb: "Analysis & tricky edges" },
|
||||
];
|
||||
|
||||
const COUNT_OPTIONS = [8, 10, 12, 15];
|
||||
|
||||
const GENERATION_TIPS = [
|
||||
"Tip: swipe up and down through your deck like a feed.",
|
||||
"Tip: matching questions only give full XP for a perfect match.",
|
||||
"Tip: your decks are saved in this browser — no account needed.",
|
||||
"Tip: export a deck as JSON to share it with a friend.",
|
||||
];
|
||||
|
||||
export function UploadScreen() {
|
||||
const router = useRouter();
|
||||
const [images, setImages] = useState<PreparedImage[]>([]);
|
||||
const [documents, setDocuments] = useState<DocItem[]>([]);
|
||||
const [text, setText] = useState("");
|
||||
const [focus, setFocus] = useState("");
|
||||
const [difficulty, setDifficulty] = useState<Difficulty>("medium");
|
||||
const [folderId, setFolderId] = useState<string>(UNORGANIZED_ID);
|
||||
const [creatingFolder, setCreatingFolder] = useState(false);
|
||||
const [folderDraft, setFolderDraft] = useState("");
|
||||
const [count, setCount] = useState(10);
|
||||
const [phase, setPhase] = useState<Phase>("idle");
|
||||
const [error, setError] = useState<{ message: string; kind?: string } | null>(null);
|
||||
const [fileError, setFileError] = useState<string | null>(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [progress, setProgress] = useState<GenerateProgress>({ stage: "vision", message: "Warming up…" });
|
||||
|
||||
const folders = useSyncExternalStore(
|
||||
folderStore.subscribe,
|
||||
folderStore.getSnapshot,
|
||||
folderStore.getServerSnapshot,
|
||||
);
|
||||
const sortedFolders = useMemo(
|
||||
() => [...folders].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" })),
|
||||
[folders],
|
||||
);
|
||||
const targetFolder = folderId === UNORGANIZED_ID ? undefined : folderId;
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const cameraInputRef = useRef<HTMLInputElement>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
/**
|
||||
* Mirrors of the current lists. React defers state updaters, so reading the
|
||||
* count from inside `setState` would silently skip work — always read these.
|
||||
*/
|
||||
const imagesRef = useRef<PreparedImage[]>([]);
|
||||
const documentsRef = useRef<DocItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
imagesRef.current = images;
|
||||
}, [images]);
|
||||
useEffect(() => {
|
||||
documentsRef.current = documents;
|
||||
}, [documents]);
|
||||
|
||||
const addFiles = useCallback(async (files: File[]) => {
|
||||
if (files.length === 0) return;
|
||||
setFileError(null);
|
||||
setPhase("preparing");
|
||||
|
||||
const problems: string[] = [];
|
||||
const imageFiles: File[] = [];
|
||||
const documentFiles: File[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (isImageFile(file)) {
|
||||
const problem = describeFileError(file);
|
||||
if (problem) problems.push(problem);
|
||||
else imageFiles.push(file);
|
||||
} else if (isDocumentFile(file)) {
|
||||
const problem = describeDocumentError(file);
|
||||
if (problem) problems.push(problem);
|
||||
else documentFiles.push(file);
|
||||
} else {
|
||||
problems.push(
|
||||
`"${file.name}" is not a supported format. Add photos or a PDF, DOCX, PPTX, ODT/ODP, TXT or MD file.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const documentRoom = Math.max(0, MAX_DOCUMENTS - documentsRef.current.length);
|
||||
const acceptedDocuments = documentFiles.slice(0, documentRoom);
|
||||
if (documentFiles.length > documentRoom) problems.push(`Up to ${MAX_DOCUMENTS} documents per deck.`);
|
||||
|
||||
// Documents are read asynchronously (pdf.js can take a while for scans).
|
||||
if (acceptedDocuments.length > 0) {
|
||||
for (const file of acceptedDocuments) {
|
||||
const kind = documentKindOf(file);
|
||||
if (!kind) continue;
|
||||
const item: DocItem = { id: uid("doc"), name: file.name, kind, status: "extracting" };
|
||||
documentsRef.current = [...documentsRef.current, item];
|
||||
setDocuments((current) => (current.some((entry) => entry.id === item.id) ? current : [...current, item]));
|
||||
|
||||
const update = (patch: Partial<DocItem>) =>
|
||||
setDocuments((current) =>
|
||||
current.map((entry) => (entry.id === item.id ? { ...entry, ...patch } : entry)),
|
||||
);
|
||||
|
||||
try {
|
||||
const extracted = await extractDocument(file, (progress) => update({ progress: progress.message }));
|
||||
update({ status: "ready", extracted, progress: undefined });
|
||||
} catch (error) {
|
||||
update({
|
||||
status: "error",
|
||||
error: error instanceof Error ? error.message : "Could not read this file.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const photoRoom = Math.max(0, MAX_IMAGES - imagesRef.current.length);
|
||||
const acceptedImages = imageFiles.slice(0, photoRoom);
|
||||
if (imageFiles.length > photoRoom) problems.push(`Up to ${MAX_IMAGES} photos per deck.`);
|
||||
|
||||
const prepared: PreparedImage[] = [];
|
||||
for (const file of acceptedImages) {
|
||||
try {
|
||||
prepared.push(await prepareImage(file));
|
||||
} catch {
|
||||
problems.push(`Could not read "${file.name}".`);
|
||||
}
|
||||
}
|
||||
if (prepared.length > 0) {
|
||||
imagesRef.current = [...imagesRef.current, ...prepared];
|
||||
setImages((current) => [...current, ...prepared]);
|
||||
}
|
||||
|
||||
if (problems.length) setFileError(problems.slice(0, 3).join(" "));
|
||||
setPhase("idle");
|
||||
}, []);
|
||||
|
||||
// Clipboard paste (Ctrl/⌘ + V) of screenshots or copied photos.
|
||||
useEffect(() => {
|
||||
const onPaste = (event: ClipboardEvent) => {
|
||||
if (phase === "loading") return;
|
||||
const target = event.target as HTMLElement | null;
|
||||
const isTextField = target && /^(INPUT|TEXTAREA)$/.test(target.tagName);
|
||||
const files = imageFilesFromDataTransfer(event.clipboardData);
|
||||
if (files.length > 0) {
|
||||
event.preventDefault();
|
||||
void addFiles(files);
|
||||
return;
|
||||
}
|
||||
if (!isTextField) {
|
||||
const pasted = event.clipboardData?.getData("text/plain")?.trim();
|
||||
if (pasted && pasted.length > 40) {
|
||||
event.preventDefault();
|
||||
setText((current) => (current ? `${current}\n${pasted}` : pasted));
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener("paste", onPaste);
|
||||
return () => window.removeEventListener("paste", onPaste);
|
||||
}, [addFiles, phase]);
|
||||
|
||||
const removeImage = (id: string) => {
|
||||
imagesRef.current = imagesRef.current.filter((image) => image.id !== id);
|
||||
setImages((current) => current.filter((image) => image.id !== id));
|
||||
};
|
||||
const removeDocument = (id: string) => {
|
||||
documentsRef.current = documentsRef.current.filter((entry) => entry.id !== id);
|
||||
setDocuments((current) => current.filter((entry) => entry.id !== id));
|
||||
};
|
||||
|
||||
const readyDocuments = documents.filter((entry) => entry.status === "ready" && entry.extracted);
|
||||
const documentsBusy = documents.some((entry) => entry.status === "extracting");
|
||||
const documentImages = readyDocuments.flatMap((entry) => entry.extracted!.images);
|
||||
const documentText = documentsToText(readyDocuments.map((entry) => entry.extracted!));
|
||||
|
||||
const totalBytes = images.reduce((sum, image) => sum + image.bytes, 0);
|
||||
const hasContent =
|
||||
images.length > 0 || documentImages.length > 0 || text.trim().length > 0 || documentText.trim().length > 0;
|
||||
const canGenerate = hasContent && !documentsBusy && phase !== "preparing";
|
||||
const imagesToSend = [...images.map((image) => image.dataUrl), ...documentImages.map((image) => image.dataUrl)].slice(
|
||||
0,
|
||||
MAX_IMAGES_TO_SEND,
|
||||
);
|
||||
const imagesSkipped =
|
||||
images.length + documentImages.length - imagesToSend.length;
|
||||
|
||||
const generate = async () => {
|
||||
if (!canGenerate) return;
|
||||
setError(null);
|
||||
setPhase("loading");
|
||||
setProgress({
|
||||
stage: imagesToSend.length ? "vision" : "generation",
|
||||
message: imagesToSend.length ? "Reading your notes…" : "Finding the key ideas…",
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
const mergedText = [text.trim(), documentText.trim()].filter(Boolean).join("\n\n").slice(0, MAX_TEXT_TO_SEND);
|
||||
|
||||
try {
|
||||
const { deck } = await generateDeck(
|
||||
{
|
||||
images: imagesToSend.length ? imagesToSend : undefined,
|
||||
text: mergedText || undefined,
|
||||
focus: focus.trim() || undefined,
|
||||
difficulty,
|
||||
count,
|
||||
},
|
||||
{ onProgress: setProgress, signal: controller.signal },
|
||||
);
|
||||
|
||||
const now = Date.now();
|
||||
const stored = {
|
||||
...deck,
|
||||
id: uid("deck"),
|
||||
folderId: targetFolder,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
attempts: 0,
|
||||
};
|
||||
upsertDeck(stored);
|
||||
router.push(`/deck/${stored.id}`);
|
||||
} catch (generateError) {
|
||||
setError({
|
||||
message:
|
||||
generateError instanceof ApiError
|
||||
? generateError.message
|
||||
: "Something went wrong while generating your deck. Check your Ollama settings and try again.",
|
||||
kind: generateError instanceof ApiError ? generateError.kind : undefined,
|
||||
});
|
||||
setPhase("error");
|
||||
} finally {
|
||||
abortRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
if (phase === "loading") {
|
||||
return <LoadingState progress={progress} onCancel={() => abortRef.current?.abort()} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="relative space-y-5">
|
||||
<section className="space-y-2">
|
||||
<h1 className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[28px] font-semibold leading-tight tracking-[-0.02em] text-[var(--text)] text-balance sm:text-[32px]">
|
||||
<SnowflakeIcon className="size-8 shrink-0 text-frost-500 dark:text-frost-200" />
|
||||
<span>Turn your notes into a study feed</span>
|
||||
</h1>
|
||||
<p className="max-w-xl text-[15px] text-frost-600 dark:text-frost-300">
|
||||
Snap or drop photos of your notes. Freezmo reads them with your own Ollama vision model
|
||||
and builds a swipeable deck of mini study games.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<ApiKeyBanner />
|
||||
|
||||
{error ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="animate-rise rounded-3xl border-2 border-rose-300 bg-rose-50/95 p-4 text-sm text-rose-900 dark:border-rose-400/40 dark:bg-rose-400/10 dark:text-rose-100"
|
||||
>
|
||||
<p className="font-bold">
|
||||
{error.kind === "needs_key"
|
||||
? "API key required"
|
||||
: error.kind === "rate_limited"
|
||||
? "Slow down a moment"
|
||||
: error.kind === "auth"
|
||||
? "Ollama rejected your key"
|
||||
: "Generation failed"}
|
||||
</p>
|
||||
<p className="mt-1">{error.message}</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{error.kind === "needs_key" || error.kind === "auth" ? (
|
||||
<Button size="sm" onClick={openSettings}>
|
||||
Open settings
|
||||
</Button>
|
||||
) : null}
|
||||
<Button size="sm" variant={error.kind === "needs_key" || error.kind === "auth" ? "secondary" : "primary"} onClick={() => void generate()}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* ---------------------------------------------------------- */}
|
||||
<section
|
||||
aria-label="Add photos of your notes"
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
setDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
setDragging(false);
|
||||
void addFiles(imageFilesFromDataTransfer(event.dataTransfer));
|
||||
}}
|
||||
className={cn(
|
||||
"card relative overflow-hidden p-4 transition-all sm:p-5",
|
||||
dragging && "border-frost-400 bg-frost-50 ring-4 ring-frost-200 dark:bg-frost-500/10 dark:ring-frost-500/30",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-[15px] font-semibold tracking-[-0.01em] text-[var(--text)]">1. Add your notes</h2>
|
||||
<p className="text-[12.5px] text-[var(--text-subtle)]">
|
||||
Drag & drop, paste a screenshot (Ctrl/⌘+V), or use the camera. Photos, PDF, Word,
|
||||
PowerPoint, OpenDocument and text files. Up to {MAX_IMAGES} photos and {MAX_DOCUMENTS}{" "}
|
||||
documents.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={() => fileInputRef.current?.click()}>
|
||||
<FolderIcon className="size-4" /> Choose files
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => cameraInputRef.current?.click()}>
|
||||
<CameraIcon className="size-4" /> Camera
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={`${ACCEPTED_TYPES.join(",")},${DOCUMENT_ACCEPT_ATTR}`}
|
||||
multiple
|
||||
className="sr-only"
|
||||
onChange={(event) => {
|
||||
void addFiles(Array.from(event.target.files ?? []));
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={cameraInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
className="sr-only"
|
||||
onChange={(event) => {
|
||||
void addFiles(Array.from(event.target.files ?? []));
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
|
||||
{images.length === 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="mt-4 flex w-full flex-col items-center gap-2 rounded-2xl border-2 border-dashed border-white/70 bg-white/40 px-4 py-10 text-center backdrop-blur-xl transition hover:border-accent-300 hover:bg-white/65 dark:border-white/15 dark:bg-white/[0.05] dark:hover:bg-white/[0.09]"
|
||||
>
|
||||
<span aria-hidden className="animate-float text-frost-500 dark:text-frost-200">
|
||||
<SnowflakeIcon className="size-12" />
|
||||
</span>
|
||||
<span className="text-[14px] font-medium text-[var(--text)]">
|
||||
Drop your notes here
|
||||
</span>
|
||||
<span className="text-[12.5px] text-[var(--text-subtle)]">
|
||||
Photos, PDF, DOCX, PPTX, ODT/ODP, TXT, MD · images resized to 1600px before sending
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<ul className="mt-4 grid grid-cols-3 gap-2 sm:grid-cols-4">
|
||||
{images.map((image) => (
|
||||
<li key={image.id} className="group relative overflow-hidden rounded-2xl border border-frost-200 dark:border-white/15">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={image.dataUrl}
|
||||
alt={image.name}
|
||||
className="aspect-square w-full object-cover"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeImage(image.id)}
|
||||
aria-label={`Remove ${image.name}`}
|
||||
className="absolute right-1 top-1 grid size-7 place-items-center rounded-full bg-frost-950/70 text-xs font-bold text-white transition hover:bg-rose-600"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<span className="absolute inset-x-0 bottom-0 bg-frost-950/60 px-2 py-1 text-[10px] font-semibold text-white">
|
||||
{formatBytes(image.bytes)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{images.length < MAX_IMAGES ? (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="grid aspect-square w-full place-items-center rounded-2xl border-2 border-dashed border-white/70 text-2xl text-accent-500 backdrop-blur-xl transition hover:border-accent-300 hover:bg-white/60 dark:border-white/15 dark:text-accent-200"
|
||||
aria-label="Add more photos"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{documents.length > 0 ? (
|
||||
<ul className="mt-3 space-y-2">
|
||||
{documents.map((entry) => (
|
||||
<li
|
||||
key={entry.id}
|
||||
className="flex items-start gap-3 rounded-2xl border border-white/60 bg-white/50 p-3 backdrop-blur-xl dark:border-white/12 dark:bg-white/[0.06]"
|
||||
>
|
||||
<span className="grid size-10 shrink-0 place-items-center rounded-xl bg-frost-100 text-[10px] font-extrabold uppercase tracking-wide text-frost-700 dark:bg-white/10 dark:text-frost-100">
|
||||
{KIND_LABELS[entry.kind]}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold text-[var(--text)]" title={entry.name}>
|
||||
{entry.name}
|
||||
</p>
|
||||
{entry.status === "extracting" ? (
|
||||
<p className="text-xs font-medium text-frost-700 dark:text-frost-200">
|
||||
{entry.progress ?? "Reading…"}
|
||||
</p>
|
||||
) : null}
|
||||
{entry.status === "error" ? (
|
||||
<p className="text-xs font-medium text-rose-600 dark:text-rose-300">{entry.error}</p>
|
||||
) : null}
|
||||
{entry.status === "ready" && entry.extracted ? (
|
||||
<>
|
||||
<p className="text-[12.5px] text-[var(--text-subtle)]">
|
||||
{describeExtraction(entry.extracted)}
|
||||
</p>
|
||||
{entry.extracted.words === 0 && entry.extracted.images.length === 0 ? (
|
||||
<p className="text-xs font-medium text-amber-700 dark:text-amber-300">
|
||||
No readable text — is this a scan without a text layer?
|
||||
</p>
|
||||
) : null}
|
||||
{entry.extracted.warnings.map((warning) => (
|
||||
<p key={warning} className="text-[12.5px] text-[var(--text-subtle)]">
|
||||
ⓘ {warning}
|
||||
</p>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeDocument(entry.id)}
|
||||
aria-label={`Remove ${entry.name}`}
|
||||
className="grid size-7 shrink-0 place-items-center rounded-full bg-frost-950/10 text-xs font-bold text-frost-900 transition hover:bg-rose-500 hover:text-white dark:bg-white/10 dark:text-frost-50"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
{images.length > 0 || documents.length > 0 ? (
|
||||
<p className="mt-2 text-[12.5px] text-[var(--text-subtle)]">
|
||||
{images.length > 0
|
||||
? `${images.length} photo${images.length === 1 ? "" : "s"} · ${formatBytes(totalBytes)} after compression`
|
||||
: ""}
|
||||
{images.length > 0 && documents.length > 0 ? " · " : ""}
|
||||
{documents.length > 0
|
||||
? `${documents.length} document${documents.length === 1 ? "" : "s"}${
|
||||
documentImages.length ? ` (${documentImages.length} page image${documentImages.length === 1 ? "" : "s"})` : ""
|
||||
}`
|
||||
: ""}
|
||||
{imagesSkipped > 0
|
||||
? ` · ${imagesSkipped} image${imagesSkipped === 1 ? "" : "s"} will be skipped (max ${MAX_IMAGES_TO_SEND} per deck)`
|
||||
: ""}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{phase === "preparing" ? (
|
||||
<p className="mt-2 text-[12.5px] font-medium text-[var(--text-muted)]">Compressing photos…</p>
|
||||
) : null}
|
||||
{fileError ? (
|
||||
<p role="alert" className="mt-2 text-xs font-semibold text-rose-600 dark:text-rose-300">
|
||||
{fileError}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{/* ---------------------------------------------------------- */}
|
||||
<section className="card space-y-4 p-4 sm:p-5">
|
||||
<div>
|
||||
<h2 className="text-[15px] font-semibold tracking-[-0.01em] text-[var(--text)]">2. Optional extras</h2>
|
||||
<p className="text-[12.5px] text-[var(--text-subtle)]">
|
||||
Paste extra notes or give the AI a hint about what to focus on.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="notes-text" className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
|
||||
Pasted notes or text
|
||||
</label>
|
||||
<textarea
|
||||
id="notes-text"
|
||||
value={text}
|
||||
onChange={(event) => setText(event.target.value)}
|
||||
rows={4}
|
||||
placeholder="Paste lecture text, a summary, or any extra facts here…"
|
||||
className="w-full resize-y rounded-2xl border border-white/60 bg-white/55 px-4 py-3 text-sm text-frost-950 outline-none backdrop-blur-xl transition placeholder:text-frost-400 focus:border-accent-400 dark:border-white/12 dark:bg-white/[0.07] dark:text-frost-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="focus" className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
|
||||
Topic hint (optional)
|
||||
</label>
|
||||
<input
|
||||
id="focus"
|
||||
value={focus}
|
||||
onChange={(event) => setFocus(event.target.value)}
|
||||
placeholder="e.g. focus on the formulas, or on dates"
|
||||
className="w-full rounded-2xl border border-white/60 bg-white/55 px-4 py-3 text-sm text-frost-950 outline-none backdrop-blur-xl transition placeholder:text-frost-400 focus:border-accent-400 dark:border-white/12 dark:bg-white/[0.07] dark:text-frost-50"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---------------------------------------------------------- */}
|
||||
<section className="card space-y-4 p-4 sm:p-5">
|
||||
<h2 className="text-[15px] font-semibold tracking-[-0.01em] text-[var(--text)]">3. Difficulty & size</h2>
|
||||
|
||||
<div className="space-y-2">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
|
||||
Freeze into folder
|
||||
</span>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{[{ id: UNORGANIZED_ID, name: "Unorganized" }, ...sortedFolders].map((folder) => {
|
||||
const active = folderId === folder.id;
|
||||
return (
|
||||
<button
|
||||
key={folder.id}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() => setFolderId(folder.id)}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full border px-3.5 py-1.5 text-[13px] font-medium transition",
|
||||
active
|
||||
? "border-accent-500 bg-accent-50 text-accent-800 dark:bg-accent-500/20 dark:text-accent-100"
|
||||
: "border-[var(--hairline)] text-frost-600 hover:text-frost-900 dark:text-frost-300 dark:hover:text-white",
|
||||
)}
|
||||
>
|
||||
<FolderIcon className="size-4" />
|
||||
{folder.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{creatingFolder ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<input
|
||||
autoFocus
|
||||
value={folderDraft}
|
||||
onChange={(event) => setFolderDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation();
|
||||
if (event.key === "Escape") {
|
||||
setCreatingFolder(false);
|
||||
setFolderDraft("");
|
||||
}
|
||||
if (event.key === "Enter") {
|
||||
const created = createFolder(folderDraft);
|
||||
if ("folder" in created) {
|
||||
setFolderId(created.folder.id);
|
||||
setFolderDraft("");
|
||||
setCreatingFolder(false);
|
||||
} else {
|
||||
setFileError(created.error);
|
||||
}
|
||||
}
|
||||
}}
|
||||
aria-label="New folder name"
|
||||
placeholder="e.g. History"
|
||||
maxLength={60}
|
||||
className="w-32 rounded-xl border border-[var(--hairline)] bg-[var(--surface-strong)] px-3 py-1.5 text-sm outline-none"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const created = createFolder(folderDraft);
|
||||
if ("folder" in created) {
|
||||
setFolderId(created.folder.id);
|
||||
setFolderDraft("");
|
||||
setCreatingFolder(false);
|
||||
} else {
|
||||
setFileError(created.error);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreatingFolder(true)}
|
||||
className="inline-flex items-center gap-1.5 rounded-xl border border-dashed border-[var(--hairline)] px-3 py-1.5 text-sm font-semibold text-frost-500 transition-colors hover:text-accent-700 dark:text-frost-400 dark:hover:text-accent-200"
|
||||
>
|
||||
<PlusIcon className="size-4" /> New folder
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div role="radiogroup" aria-label="Difficulty" className="grid grid-cols-3 gap-2">
|
||||
{DIFFICULTIES.map((option) => {
|
||||
const selected = difficulty === option.value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
onClick={() => setDifficulty(option.value)}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-1 rounded-2xl border px-2 py-3 text-center backdrop-blur-xl transition-colors",
|
||||
selected
|
||||
? "border-accent-400 bg-accent-50/80 shadow-lift dark:bg-accent-500/20"
|
||||
: "border-white/60 bg-white/45 backdrop-blur-xl hover:bg-white/70 dark:border-white/12 dark:bg-white/[0.05]",
|
||||
)}
|
||||
>
|
||||
<span aria-hidden className="text-frost-600 dark:text-frost-200">
|
||||
{option.icon}
|
||||
</span>
|
||||
<span className="text-sm font-bold text-[var(--text)]">{option.label}</span>
|
||||
<span className="hidden text-[11px] text-slate-500 sm:block dark:text-slate-400">{option.blurb}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
|
||||
Activities
|
||||
</span>
|
||||
{COUNT_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
aria-pressed={count === option}
|
||||
onClick={() => setCount(option)}
|
||||
className={cn(
|
||||
"min-w-12 rounded-xl border-2 px-3 py-1.5 text-sm font-bold transition",
|
||||
count === option
|
||||
? "border-transparent bg-[var(--text)] text-white dark:bg-white dark:text-[#0d0d0d]"
|
||||
: "border-[var(--hairline)] text-[var(--text-muted)] hover:bg-[var(--hover)]",
|
||||
)}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="sticky bottom-[calc(env(safe-area-inset-bottom,0px)+4.6rem)] z-20 -mx-4 border-t border-[var(--hairline)] bg-white/65 px-4 py-3 backdrop-blur-2xl dark:bg-night-950/70 sm:bottom-0 sm:mx-0 sm:rounded-[1.35rem] sm:border sm:px-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-[12.5px] text-[var(--text-subtle)]">
|
||||
{documentsBusy
|
||||
? "Reading your documents…"
|
||||
: hasContent
|
||||
? "Ready when you are — generation can take a minute."
|
||||
: "Add a photo or a document (or some text) to start."}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="lg" onClick={() => void generate()} disabled={!canGenerate}>
|
||||
<SnowflakeIcon className="size-5" /> Freeze my deck
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Loading state */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const STAGE_STEPS: Array<{ stage: GenerateProgress["stage"]; messages: string[] }> = [
|
||||
{ stage: "vision", messages: ["Reading your notes…", "Transcribing handwriting…", "Spotting definitions…"] },
|
||||
{ stage: "generation", messages: ["Finding the key ideas…", "Writing good questions…", "Building your activities…"] },
|
||||
];
|
||||
|
||||
function LoadingState({ progress, onCancel }: { progress: GenerateProgress; onCancel: () => void }) {
|
||||
const [tick, setTick] = useState(0);
|
||||
const [tip, setTip] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setTick((value) => value + 1), 3200);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setTip(Math.floor(Math.random() * GENERATION_TIPS.length));
|
||||
}, []);
|
||||
|
||||
const activeIndex = progress.stage === "vision" ? 0 : 1;
|
||||
const messages = STAGE_STEPS[activeIndex].messages;
|
||||
const message = progress.message && tick === 0 ? progress.message : messages[tick % messages.length];
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-[70dvh] flex-col items-center justify-center gap-6 text-center">
|
||||
<motion.div
|
||||
aria-hidden
|
||||
animate={{ rotate: [0, 8, -8, 0], scale: [1, 1.08, 1] }}
|
||||
transition={{ duration: 3.2, repeat: Infinity, ease: "easeInOut" }}
|
||||
className="relative grid size-28 place-items-center rounded-[1.75rem] bg-accent-600 text-white shadow-lift"
|
||||
>
|
||||
<SnowflakeIcon className="size-16" />
|
||||
</motion.div>
|
||||
|
||||
<div className="relative w-full max-w-md space-y-3">
|
||||
<p className="text-xl font-extrabold text-[var(--text)]" aria-live="polite">
|
||||
{message}
|
||||
</p>
|
||||
<ProgressBar value={activeIndex + 1} max={2} label="Generation progress" />
|
||||
<ol className="space-y-1 text-left text-[14.5px] leading-relaxed text-[var(--text-muted)]">
|
||||
{STAGE_STEPS.map((step, index) => (
|
||||
<li key={step.stage} className="flex items-center gap-2">
|
||||
<span aria-hidden>
|
||||
{index < activeIndex ? "✅" : index === activeIndex ? "🌀" : "⬜"}
|
||||
</span>
|
||||
{step.messages[0]}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<p className="pt-2 text-[12.5px] text-[var(--text-subtle)]">{GENERATION_TIPS[tip]}</p>
|
||||
<Button variant="ghost" size="sm" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
202
components/activities/FillBlank.tsx
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import type { Activity } from "@/lib/schemas";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui";
|
||||
|
||||
import {
|
||||
ActivityHeader,
|
||||
FeedbackPanel,
|
||||
Hint,
|
||||
OptionTile,
|
||||
PromptCard,
|
||||
type ActivityComponentProps,
|
||||
} from "./shared";
|
||||
|
||||
type FillBlankActivity = Extract<Activity, { type: "fill_blank" }>;
|
||||
|
||||
const BLANK = /_{2,}/;
|
||||
|
||||
function normalise(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.normalize("NFKD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^a-z0-9\s]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function levenshtein(a: string, b: string): number {
|
||||
if (a === b) return 0;
|
||||
if (!a.length || !b.length) return Math.max(a.length, b.length);
|
||||
let previous = Array.from({ length: b.length + 1 }, (_, i) => i);
|
||||
for (let i = 1; i <= a.length; i++) {
|
||||
const current = [i];
|
||||
for (let j = 1; j <= b.length; j++) {
|
||||
current[j] = Math.min(
|
||||
previous[j] + 1,
|
||||
current[j - 1] + 1,
|
||||
previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
|
||||
);
|
||||
}
|
||||
previous = current;
|
||||
}
|
||||
return previous[b.length];
|
||||
}
|
||||
|
||||
/** Lenient comparison: case/punctuation-insensitive, one typo allowed, "/" alternatives. */
|
||||
export function isFillBlankCorrect(userValue: string, answer: string): boolean {
|
||||
const user = normalise(userValue);
|
||||
if (!user) return false;
|
||||
const candidates = answer
|
||||
.split("/")
|
||||
.map((part) => normalise(part.replace(/\(.*?\)/g, "")))
|
||||
.filter(Boolean);
|
||||
return candidates.some((candidate) => {
|
||||
if (candidate === user) return true;
|
||||
if (candidate.length >= 5 && user.length >= 4 && levenshtein(candidate, user) <= 1) return true;
|
||||
// accept the answer with/without a trailing unit or article
|
||||
const candidateWords = candidate.split(" ");
|
||||
const userWords = user.split(" ");
|
||||
if (candidateWords.length > 1 && userWords.length === 1) return candidateWords.includes(user);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
export function FillBlank({ activity, active, onAnswered, onNext, isLast }: ActivityComponentProps<FillBlankActivity>) {
|
||||
const [value, setValue] = useState("");
|
||||
const [chosen, setChosen] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<boolean | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const hasBlank = BLANK.test(activity.sentence);
|
||||
const [before, after = ""] = hasBlank ? activity.sentence.split(BLANK) : [activity.sentence, ""];
|
||||
|
||||
useEffect(() => {
|
||||
if (active && !hasBlank && !activity.options) inputRef.current?.focus();
|
||||
}, [active, hasBlank, activity.options]);
|
||||
|
||||
const submit = (answerValue: string) => {
|
||||
if (result !== null) return;
|
||||
const correct = isFillBlankCorrect(answerValue, activity.answer);
|
||||
setChosen(answerValue);
|
||||
setResult(correct);
|
||||
onAnswered(correct, { userAnswer: answerValue });
|
||||
};
|
||||
|
||||
const displayAnswer = chosen ?? "";
|
||||
|
||||
return (
|
||||
<>
|
||||
<ActivityHeader type="fill_blank" topic={activity.topic} />
|
||||
<PromptCard>
|
||||
<p className="text-lg font-semibold leading-loose text-frost-950 sm:text-xl dark:text-frost-50">
|
||||
{before}
|
||||
{activity.options ? (
|
||||
<span
|
||||
className={cn(
|
||||
"mx-1 inline-block min-w-24 rounded-xl border-b-4 px-3 py-0.5 text-center align-baseline",
|
||||
result === null
|
||||
? "border-frost-300 bg-frost-50/80 text-frost-500 dark:bg-white/10 dark:text-frost-200"
|
||||
: result
|
||||
? "border-emerald-400 bg-emerald-50 text-emerald-800 dark:bg-emerald-400/15 dark:text-emerald-100"
|
||||
: "border-rose-400 bg-rose-50 text-rose-800 dark:bg-rose-400/15 dark:text-rose-100",
|
||||
)}
|
||||
>
|
||||
{displayAnswer || "?"}
|
||||
</span>
|
||||
) : (
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={displayAnswer}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !result) {
|
||||
event.preventDefault();
|
||||
submit(value);
|
||||
}
|
||||
event.stopPropagation();
|
||||
}}
|
||||
disabled={result !== null}
|
||||
aria-label="Type the missing word"
|
||||
placeholder="type it"
|
||||
autoComplete="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck={false}
|
||||
className={cn(
|
||||
"mx-1 inline-block rounded-xl border-b-4 bg-white/70 px-2 py-0.5 text-center text-frost-950 outline-none backdrop-blur-lg placeholder:text-frost-300 dark:bg-white/10 dark:text-frost-50 dark:placeholder:text-white/30",
|
||||
result === null
|
||||
? "border-frost-300 focus:border-frost-500"
|
||||
: result
|
||||
? "border-emerald-400 bg-emerald-50 text-emerald-900 dark:bg-emerald-400/15 dark:text-emerald-100"
|
||||
: "border-rose-400 bg-rose-50 text-rose-900 dark:bg-rose-400/15 dark:text-rose-100",
|
||||
)}
|
||||
style={{ width: `${Math.max(5, (displayAnswer.length || 6) + 2)}ch` }}
|
||||
/>
|
||||
)}
|
||||
{after}
|
||||
</p>
|
||||
</PromptCard>
|
||||
|
||||
{activity.options ? (
|
||||
<div className="grid grid-cols-2 gap-2.5" role="group" aria-label="Word bank">
|
||||
{activity.options.map((option) => {
|
||||
const state =
|
||||
result === null
|
||||
? "idle"
|
||||
: normalise(option) === normalise(activity.answer)
|
||||
? "correct"
|
||||
: chosen && normalise(chosen) === normalise(option) && result === false
|
||||
? "wrong"
|
||||
: "dim";
|
||||
return (
|
||||
<OptionTile
|
||||
key={option}
|
||||
state={state}
|
||||
disabled={result !== null}
|
||||
onClick={() => submit(option)}
|
||||
className="justify-center text-center"
|
||||
>
|
||||
{option}
|
||||
</OptionTile>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : result === null ? (
|
||||
<div className="flex gap-2">
|
||||
<Button size="lg" onClick={() => submit(value)} disabled={!value.trim()} className="flex-1">
|
||||
Check answer
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{result === null && !activity.options ? (
|
||||
<Hint>Press Enter to check. Small typos are forgiven.</Hint>
|
||||
) : null}
|
||||
|
||||
{result !== null ? (
|
||||
<FeedbackPanel
|
||||
correct={result}
|
||||
explanation={activity.explanation}
|
||||
xp={activity.xp}
|
||||
isLast={isLast}
|
||||
onNext={onNext}
|
||||
detail={
|
||||
<span>
|
||||
Expected: <span className="font-bold">{activity.answer}</span>
|
||||
{!result && chosen ? (
|
||||
<>
|
||||
{" "}
|
||||
— you wrote <span className="italic">{chosen}</span>
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
92
components/activities/Flashcard.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { useState } from "react";
|
||||
|
||||
import type { Activity } from "@/lib/schemas";
|
||||
import { Button } from "@/components/ui";
|
||||
|
||||
import {
|
||||
ActivityHeader,
|
||||
FeedbackPanel,
|
||||
Hint,
|
||||
PromptCard,
|
||||
QuestionText,
|
||||
type ActivityComponentProps,
|
||||
} from "./shared";
|
||||
|
||||
type FlashcardActivity = Extract<Activity, { type: "flashcard" }>;
|
||||
|
||||
export function Flashcard({ activity, onAnswered, onNext, isLast }: ActivityComponentProps<FlashcardActivity>) {
|
||||
const [flipped, setFlipped] = useState(false);
|
||||
const [result, setResult] = useState<boolean | null>(null);
|
||||
|
||||
const settle = (correct: boolean) => {
|
||||
if (result !== null) return;
|
||||
setResult(correct);
|
||||
onAnswered(correct);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ActivityHeader type="flashcard" topic={activity.topic} />
|
||||
<Hint>{flipped ? "Did you know it before flipping?" : "Tap the card to flip it."}</Hint>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFlipped((value) => !value)}
|
||||
className="group relative block w-full text-left [perspective:1400px]"
|
||||
aria-label={flipped ? "Flip back to the question" : "Reveal the answer"}
|
||||
>
|
||||
<motion.div
|
||||
animate={{ rotateX: flipped ? 180 : 0 }}
|
||||
transition={{ type: "spring", stiffness: 220, damping: 24 }}
|
||||
className="relative min-h-56 w-full [transform-style:preserve-3d]"
|
||||
>
|
||||
<PromptCard className="absolute inset-0 flex flex-col justify-center gap-3 group-hover:border-frost-300 [backface-visibility:hidden]">
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-frost-600 dark:text-frost-200">
|
||||
Question
|
||||
</span>
|
||||
<QuestionText>{activity.front}</QuestionText>
|
||||
<span className="mt-2 text-xs font-semibold text-slate-400 dark:text-slate-400">
|
||||
tap to flip ↻
|
||||
</span>
|
||||
</PromptCard>
|
||||
|
||||
<div className="[transform:rotateX(180deg)] [backface-visibility:hidden]">
|
||||
<div className="card flex min-h-56 flex-col justify-center gap-3 px-5 py-6 sm:px-6">
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-frost-600 dark:text-frost-200">
|
||||
Answer
|
||||
</span>
|
||||
<p className="text-lg font-semibold leading-relaxed text-frost-950 dark:text-frost-50">
|
||||
{activity.back}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</button>
|
||||
|
||||
{flipped && result === null ? (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Button variant="secondary" size="lg" onClick={() => settle(false)}>
|
||||
😅 Not yet
|
||||
</Button>
|
||||
<Button variant="success" size="lg" onClick={() => settle(true)}>
|
||||
🧊 I knew it
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{result !== null ? (
|
||||
<FeedbackPanel
|
||||
correct={result}
|
||||
explanation={activity.explanation}
|
||||
xp={activity.xp}
|
||||
isLast={isLast}
|
||||
headline={result ? "Frozen solid!" : "Keep it on the pile"}
|
||||
onNext={onNext}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
151
components/activities/Matching.tsx
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import type { Activity } from "@/lib/schemas";
|
||||
import { cn, shuffle } from "@/lib/utils";
|
||||
|
||||
import {
|
||||
ActivityHeader,
|
||||
FeedbackPanel,
|
||||
Hint,
|
||||
PromptCard,
|
||||
type ActivityComponentProps,
|
||||
} from "./shared";
|
||||
|
||||
type MatchingActivity = Extract<Activity, { type: "matching" }>;
|
||||
|
||||
export function Matching({ activity, onAnswered, onNext, isLast }: ActivityComponentProps<MatchingActivity>) {
|
||||
const terms = useMemo(
|
||||
() => shuffle(activity.pairs.map((pair, index) => ({ index, text: pair.term }))),
|
||||
[activity],
|
||||
);
|
||||
const definitions = useMemo(
|
||||
() => shuffle(activity.pairs.map((pair, index) => ({ index, text: pair.definition }))),
|
||||
[activity],
|
||||
);
|
||||
|
||||
const [selectedTerm, setSelectedTerm] = useState<number | null>(null);
|
||||
const [matched, setMatched] = useState<number[]>([]);
|
||||
const [misses, setMisses] = useState(0);
|
||||
const [flash, setFlash] = useState<{ term: number; definition: number } | null>(null);
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
const total = activity.pairs.length;
|
||||
const complete = matched.length === total;
|
||||
|
||||
const onDefinitionTap = (definitionIndex: number) => {
|
||||
if (done || selectedTerm === null || matched.includes(definitionIndex)) return;
|
||||
if (selectedTerm === definitionIndex) {
|
||||
const next = [...matched, definitionIndex];
|
||||
setMatched(next);
|
||||
setSelectedTerm(null);
|
||||
if (next.length === total) {
|
||||
const correct = misses === 0;
|
||||
setDone(true);
|
||||
onAnswered(correct);
|
||||
}
|
||||
} else {
|
||||
setMisses((value) => value + 1);
|
||||
setFlash({ term: selectedTerm, definition: definitionIndex });
|
||||
window.setTimeout(() => setFlash(null), 500);
|
||||
setSelectedTerm(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ActivityHeader
|
||||
type="matching"
|
||||
topic={activity.topic}
|
||||
extra={
|
||||
<span className="rounded-full bg-white/70 px-3 py-1 text-xs font-bold text-slate-600 dark:bg-white/10 dark:text-slate-200">
|
||||
{matched.length}/{total} matched
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<PromptCard className="py-4">
|
||||
<p className="text-lg font-bold text-frost-950 dark:text-frost-50">{activity.instruction}</p>
|
||||
<Hint>
|
||||
{done
|
||||
? "All matched!"
|
||||
: selectedTerm === null
|
||||
? "Tap a term on the left, then its match on the right."
|
||||
: "Now tap the matching definition →"}
|
||||
</Hint>
|
||||
</PromptCard>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{terms.map((term) => {
|
||||
const isMatched = matched.includes(term.index);
|
||||
const isSelected = selectedTerm === term.index;
|
||||
const isFlashing = flash?.term === term.index;
|
||||
return (
|
||||
<button
|
||||
key={`term-${term.index}`}
|
||||
type="button"
|
||||
disabled={isMatched || done}
|
||||
onClick={() => setSelectedTerm(isSelected ? null : term.index)}
|
||||
className={cn(
|
||||
"min-h-16 rounded-2xl border px-3 py-2 text-left text-sm font-semibold backdrop-blur-xl transition-all",
|
||||
isMatched
|
||||
? "border-emerald-200/80 bg-emerald-50/85 text-emerald-800 dark:border-emerald-400/40 dark:bg-emerald-400/15 dark:text-emerald-100"
|
||||
: isSelected
|
||||
? "border-accent-400 bg-accent-50/80 text-frost-900 dark:bg-accent-500/20 dark:text-white"
|
||||
: "border-white/60 bg-white/55 text-frost-950 hover:bg-white/80 dark:border-white/12 dark:bg-white/[0.07] dark:text-frost-50",
|
||||
isFlashing && "animate-wiggle border-rose-400 bg-rose-50 dark:bg-rose-400/15",
|
||||
)}
|
||||
>
|
||||
<span className="mr-1">{isMatched ? "✓" : "•"}</span>
|
||||
{term.text}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{definitions.map((definition) => {
|
||||
const isMatched = matched.includes(definition.index);
|
||||
const isFlashing = flash?.definition === definition.index;
|
||||
return (
|
||||
<button
|
||||
key={`def-${definition.index}`}
|
||||
type="button"
|
||||
disabled={isMatched || done || selectedTerm === null}
|
||||
onClick={() => onDefinitionTap(definition.index)}
|
||||
className={cn(
|
||||
"min-h-16 rounded-2xl border-2 px-3 py-2 text-left text-sm transition-all",
|
||||
isMatched
|
||||
? "border-emerald-200/80 bg-emerald-50/85 text-emerald-800 dark:border-emerald-400/40 dark:bg-emerald-400/15 dark:text-emerald-100"
|
||||
: "border-white/60 bg-white/55 text-frost-700 hover:bg-white/80 disabled:opacity-60 dark:border-white/12 dark:bg-white/[0.07] dark:text-frost-200",
|
||||
isFlashing && "animate-wiggle border-rose-400 bg-rose-50 dark:bg-rose-400/15",
|
||||
)}
|
||||
>
|
||||
{definition.text}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{complete ? (
|
||||
<FeedbackPanel
|
||||
correct={misses === 0}
|
||||
explanation={activity.explanation}
|
||||
xp={activity.xp}
|
||||
isLast={isLast}
|
||||
onNext={onNext}
|
||||
headline={misses === 0 ? "Perfect match!" : "Matched — with a few slips"}
|
||||
detail={
|
||||
<span>
|
||||
{misses === 0
|
||||
? "You matched everything first try."
|
||||
: `You made ${misses} wrong ${misses === 1 ? "attempt" : "attempts"}.`}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
100
components/activities/MultipleChoice.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import type { Activity } from "@/lib/schemas";
|
||||
|
||||
import {
|
||||
ActivityHeader,
|
||||
FeedbackPanel,
|
||||
Hint,
|
||||
OptionTile,
|
||||
PromptCard,
|
||||
QuestionText,
|
||||
type ActivityComponentProps,
|
||||
} from "./shared";
|
||||
|
||||
type MultipleChoiceActivity = Extract<Activity, { type: "multiple_choice" }>;
|
||||
|
||||
export function MultipleChoice({
|
||||
activity,
|
||||
active,
|
||||
onAnswered,
|
||||
onNext,
|
||||
isLast,
|
||||
}: ActivityComponentProps<MultipleChoiceActivity>) {
|
||||
const [selected, setSelected] = useState<number | null>(null);
|
||||
const answered = selected !== null;
|
||||
|
||||
const choose = (index: number) => {
|
||||
if (answered) return;
|
||||
setSelected(index);
|
||||
onAnswered(index === activity.correctIndex);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || answered) return;
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target && /^(INPUT|TEXTAREA)$/.test(target.tagName)) return;
|
||||
const index = Number.parseInt(event.key, 10) - 1;
|
||||
if (index >= 0 && index < activity.options.length) {
|
||||
event.preventDefault();
|
||||
choose(index);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [active, answered, activity.options.length]);
|
||||
|
||||
const stateFor = (index: number): "idle" | "correct" | "wrong" | "dim" => {
|
||||
if (!answered) return "idle";
|
||||
if (index === activity.correctIndex) return "correct";
|
||||
if (index === selected) return "wrong";
|
||||
return "dim";
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ActivityHeader type="multiple_choice" topic={activity.topic} />
|
||||
<PromptCard>
|
||||
<QuestionText>{activity.question}</QuestionText>
|
||||
</PromptCard>
|
||||
|
||||
<div className="flex flex-col gap-2.5" role="group" aria-label="Answer options">
|
||||
{activity.options.map((option, index) => (
|
||||
<OptionTile
|
||||
key={option + index}
|
||||
state={stateFor(index)}
|
||||
disabled={answered}
|
||||
onClick={() => choose(index)}
|
||||
shortcut={String(index + 1)}
|
||||
>
|
||||
{option}
|
||||
</OptionTile>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!answered ? <Hint>Tip: press 1–4 on a keyboard.</Hint> : null}
|
||||
|
||||
{answered ? (
|
||||
<FeedbackPanel
|
||||
correct={selected === activity.correctIndex}
|
||||
explanation={activity.explanation}
|
||||
xp={activity.xp}
|
||||
isLast={isLast}
|
||||
onNext={onNext}
|
||||
detail={
|
||||
selected === activity.correctIndex ? null : (
|
||||
<>
|
||||
Correct answer:{" "}
|
||||
<span className="font-bold">{activity.options[activity.correctIndex]}</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
132
components/activities/Ordering.tsx
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
"use client";
|
||||
|
||||
import { Reorder } from "framer-motion";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import type { Activity } from "@/lib/schemas";
|
||||
import { cn, shuffle } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui";
|
||||
|
||||
import {
|
||||
ActivityHeader,
|
||||
FeedbackPanel,
|
||||
Hint,
|
||||
PromptCard,
|
||||
type ActivityComponentProps,
|
||||
} from "./shared";
|
||||
|
||||
type OrderingActivity = Extract<Activity, { type: "ordering" }>;
|
||||
|
||||
export function Ordering({ activity, onAnswered, onNext, isLast }: ActivityComponentProps<OrderingActivity>) {
|
||||
const initial = useMemo(() => shuffle(activity.items), [activity]);
|
||||
const [items, setItems] = useState<string[]>(initial);
|
||||
const [checked, setChecked] = useState(false);
|
||||
const [correct, setCorrect] = useState(false);
|
||||
|
||||
const isRight = (list: string[]) => list.every((item, index) => item === activity.items[index]);
|
||||
|
||||
const check = () => {
|
||||
if (checked) return;
|
||||
const result = isRight(items);
|
||||
setChecked(true);
|
||||
setCorrect(result);
|
||||
onAnswered(result, { userAnswer: items.join(" → ") });
|
||||
};
|
||||
|
||||
const move = (index: number, direction: -1 | 1) => {
|
||||
if (checked) return;
|
||||
const target = index + direction;
|
||||
if (target < 0 || target >= items.length) return;
|
||||
const next = [...items];
|
||||
[next[index], next[target]] = [next[target], next[index]];
|
||||
setItems(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ActivityHeader type="ordering" topic={activity.topic} />
|
||||
<PromptCard className="py-4">
|
||||
<p className="text-lg font-bold text-frost-950 dark:text-frost-50">{activity.instruction}</p>
|
||||
<Hint>
|
||||
{checked ? "Here is the correct order:" : "Drag the cards, or use ▲▼, then check your order."}
|
||||
</Hint>
|
||||
</PromptCard>
|
||||
|
||||
{checked ? (
|
||||
<ol className="flex flex-col gap-2.5">
|
||||
{activity.items.map((item, index) => {
|
||||
const wasRight = items[index] === item;
|
||||
return (
|
||||
<li
|
||||
key={`solution-${item}`}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-2xl border-2 px-4 py-3 text-sm font-semibold",
|
||||
wasRight
|
||||
? "border-emerald-200/80 bg-emerald-50/85 text-emerald-900 dark:border-emerald-400/40 dark:bg-emerald-400/15 dark:text-emerald-100"
|
||||
: "border-rose-200/80 bg-rose-50/85 text-rose-900 dark:border-rose-400/40 dark:bg-rose-400/15 dark:text-rose-100",
|
||||
)}
|
||||
>
|
||||
<span className="grid size-7 shrink-0 place-items-center rounded-full bg-white/70 text-xs font-extrabold dark:bg-white/15">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="flex-1">{item}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
) : (
|
||||
<Reorder.Group axis="y" values={items} onReorder={setItems} className="flex flex-col gap-2.5">
|
||||
{items.map((item, index) => (
|
||||
<Reorder.Item
|
||||
key={item}
|
||||
value={item}
|
||||
className="flex cursor-grab items-center gap-2 rounded-2xl border border-white/60 bg-white/60 px-3 py-3 text-sm font-semibold text-frost-950 shadow-sm backdrop-blur-xl active:cursor-grabbing dark:border-white/12 dark:bg-white/[0.08] dark:text-frost-50"
|
||||
>
|
||||
<span className="grid size-7 shrink-0 place-items-center rounded-full bg-frost-100 text-xs font-extrabold text-frost-700 dark:bg-white/15 dark:text-frost-100">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="flex-1">{item}</span>
|
||||
<span className="flex flex-col">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Move "${item}" up`}
|
||||
onClick={() => move(index, -1)}
|
||||
disabled={index === 0}
|
||||
className="rounded px-1.5 text-xs text-frost-700 transition hover:bg-frost-100 disabled:opacity-30 dark:text-frost-100 dark:hover:bg-white/10"
|
||||
>
|
||||
▲
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Move "${item}" down`}
|
||||
onClick={() => move(index, 1)}
|
||||
disabled={index === items.length - 1}
|
||||
className="rounded px-1.5 text-xs text-frost-700 transition hover:bg-frost-100 disabled:opacity-30 dark:text-frost-100 dark:hover:bg-white/10"
|
||||
>
|
||||
▼
|
||||
</button>
|
||||
</span>
|
||||
<span aria-hidden className="text-slate-400">
|
||||
⠿
|
||||
</span>
|
||||
</Reorder.Item>
|
||||
))}
|
||||
</Reorder.Group>
|
||||
)}
|
||||
|
||||
{!checked ? (
|
||||
<Button size="lg" onClick={check} disabled={items.length < 2}>
|
||||
Check order
|
||||
</Button>
|
||||
) : (
|
||||
<FeedbackPanel
|
||||
correct={correct}
|
||||
explanation={activity.explanation}
|
||||
xp={activity.xp}
|
||||
isLast={isLast}
|
||||
onNext={onNext}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
189
components/activities/ShortAnswer.tsx
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { ApiError, gradeAnswer } from "@/lib/client-api";
|
||||
import type { Activity } from "@/lib/schemas";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button, Spinner } from "@/components/ui";
|
||||
|
||||
import {
|
||||
ActivityHeader,
|
||||
FeedbackPanel,
|
||||
Hint,
|
||||
PromptCard,
|
||||
QuestionText,
|
||||
type ActivityComponentProps,
|
||||
} from "./shared";
|
||||
|
||||
type ShortAnswerActivity = Extract<Activity, { type: "short_answer" }>;
|
||||
|
||||
type GradeState =
|
||||
| { status: "idle" }
|
||||
| { status: "grading" }
|
||||
| { status: "graded"; correct: boolean; feedback: string; missing: string[] }
|
||||
| { status: "error"; message: string }
|
||||
| { status: "self-check" };
|
||||
|
||||
export function ShortAnswer({ activity, active, language, onAnswered, onNext, isLast }: ActivityComponentProps<ShortAnswerActivity>) {
|
||||
const [value, setValue] = useState("");
|
||||
const [state, setState] = useState<GradeState>({ status: "idle" });
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (active && state.status === "idle") {
|
||||
const timer = window.setTimeout(() => textareaRef.current?.focus(), 350);
|
||||
return () => window.clearTimeout(timer);
|
||||
}
|
||||
}, [active, state.status]);
|
||||
|
||||
const grade = async () => {
|
||||
if (!value.trim() || state.status === "grading") return;
|
||||
setState({ status: "grading" });
|
||||
try {
|
||||
const result = await gradeAnswer({
|
||||
question: activity.question,
|
||||
referenceAnswer: activity.referenceAnswer,
|
||||
keyPoints: activity.keyPoints,
|
||||
userAnswer: value.trim(),
|
||||
language,
|
||||
});
|
||||
setState({
|
||||
status: "graded",
|
||||
correct: result.correct,
|
||||
feedback: result.feedback,
|
||||
missing: result.missing,
|
||||
});
|
||||
onAnswered(result.correct, { userAnswer: value.trim() });
|
||||
} catch (error) {
|
||||
setState({
|
||||
status: "error",
|
||||
message:
|
||||
error instanceof ApiError
|
||||
? error.message
|
||||
: "Grading failed. Check that Ollama is reachable and try again.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const selfCheck = (correct: boolean) => {
|
||||
setState({ status: "graded", correct, feedback: "Self-checked — you judged this one yourself.", missing: [] });
|
||||
onAnswered(correct, { userAnswer: value.trim() });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ActivityHeader type="short_answer" topic={activity.topic} />
|
||||
<PromptCard>
|
||||
<QuestionText>{activity.question}</QuestionText>
|
||||
</PromptCard>
|
||||
|
||||
<label className="sr-only" htmlFor={`answer-${activity.id}`}>
|
||||
Your answer
|
||||
</label>
|
||||
<textarea
|
||||
id={`answer-${activity.id}`}
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation();
|
||||
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault();
|
||||
void grade();
|
||||
}
|
||||
}}
|
||||
disabled={state.status === "grading" || state.status === "graded"}
|
||||
rows={3}
|
||||
placeholder="Type your answer in your own words…"
|
||||
className="w-full resize-none rounded-2xl border-2 border-frost-200 bg-white/90 px-4 py-3 text-[15px] font-medium text-frost-950 shadow-sm outline-none transition placeholder:text-slate-400 focus:border-frost-500 disabled:opacity-70 dark:border-white/12 dark:bg-white/[0.07] dark:text-frost-50 dark:placeholder:text-slate-400"
|
||||
/>
|
||||
|
||||
{state.status === "idle" ? (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Hint>Ctrl/⌘ + Enter to submit.</Hint>
|
||||
<Button size="lg" onClick={() => void grade()} disabled={!value.trim()}>
|
||||
Submit answer
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state.status === "grading" ? (
|
||||
<div className="flex items-center gap-3 rounded-2xl border border-white/60 bg-white/50 backdrop-blur-xl px-4 py-3 text-sm font-semibold text-frost-800 dark:border-white/15 dark:bg-white/10 dark:text-frost-100">
|
||||
<Spinner className="size-5" />
|
||||
Grading your answer…
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state.status === "error" ? (
|
||||
<div className="rounded-2xl border-2 border-amber-300 bg-amber-50 p-4 text-sm text-amber-900 dark:border-amber-400/40 dark:bg-amber-400/10 dark:text-amber-100">
|
||||
<p className="font-bold">Couldn't grade that answer</p>
|
||||
<p className="mt-1">{state.message}</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Button size="sm" onClick={() => void grade()}>
|
||||
Retry
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => setState({ status: "self-check" })}>
|
||||
Show answer & self-check
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state.status === "self-check" ? (
|
||||
<div className="card space-y-3 p-4">
|
||||
<p className="text-sm font-bold text-frost-900 dark:text-frost-100">Model answer</p>
|
||||
<p className="text-sm leading-relaxed text-frost-700 dark:text-frost-200">{activity.referenceAnswer}</p>
|
||||
<p className="text-xs text-frost-500 dark:text-frost-400">Key points: {activity.keyPoints.join(" · ")}</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Button variant="secondary" onClick={() => selfCheck(false)}>
|
||||
😅 I missed it
|
||||
</Button>
|
||||
<Button variant="success" onClick={() => selfCheck(true)}>
|
||||
🧊 I had it right
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state.status === "graded" ? (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-2xl border-2 p-3 text-sm font-semibold",
|
||||
state.correct
|
||||
? "border-emerald-300 bg-emerald-50 text-emerald-900 dark:border-emerald-400/40 dark:bg-emerald-400/10 dark:text-emerald-100"
|
||||
: "border-rose-300 bg-rose-50 text-rose-900 dark:border-rose-400/40 dark:bg-rose-400/10 dark:text-rose-100",
|
||||
)}
|
||||
>
|
||||
<p className="font-extrabold uppercase tracking-wide">
|
||||
{state.correct ? "Accepted" : "Needs work"}
|
||||
</p>
|
||||
<p className="mt-1 font-normal">{state.feedback}</p>
|
||||
{state.missing.length ? (
|
||||
<ul className="mt-2 list-inside list-disc font-normal">
|
||||
{state.missing.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{!state.correct ? (
|
||||
<p className="mt-2 font-normal">
|
||||
<span className="font-bold">Model answer: </span>
|
||||
{activity.referenceAnswer}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<FeedbackPanel
|
||||
correct={state.correct}
|
||||
explanation={activity.explanation}
|
||||
xp={activity.xp}
|
||||
isLast={isLast}
|
||||
onNext={onNext}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
90
components/activities/TrueFalse.tsx
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
"use client";
|
||||
|
||||
import { motion, useMotionValue, useTransform } from "framer-motion";
|
||||
import { useState } from "react";
|
||||
|
||||
import type { Activity } from "@/lib/schemas";
|
||||
import { Button } from "@/components/ui";
|
||||
|
||||
import {
|
||||
ActivityHeader,
|
||||
FeedbackPanel,
|
||||
Hint,
|
||||
PromptCard,
|
||||
type ActivityComponentProps,
|
||||
} from "./shared";
|
||||
|
||||
type TrueFalseActivity = Extract<Activity, { type: "true_false" }>;
|
||||
|
||||
export function TrueFalse({ activity, onAnswered, onNext, isLast }: ActivityComponentProps<TrueFalseActivity>) {
|
||||
const [result, setResult] = useState<boolean | null>(null);
|
||||
const x = useMotionValue(0);
|
||||
const trueOpacity = useTransform(x, [30, 110], [0, 1]);
|
||||
const falseOpacity = useTransform(x, [-30, -110], [1, 0]);
|
||||
|
||||
const answer = (value: boolean) => {
|
||||
if (result !== null) return;
|
||||
setResult(value === activity.answer);
|
||||
onAnswered(value === activity.answer, { userAnswer: value ? "true" : "false" });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ActivityHeader type="true_false" topic={activity.topic} />
|
||||
<Hint>
|
||||
{result === null ? "Swipe the card right for true, left for false — or tap a button." : "Nice swipe!"}
|
||||
</Hint>
|
||||
|
||||
<div className="relative">
|
||||
<motion.div
|
||||
drag={result === null ? "x" : false}
|
||||
dragConstraints={{ left: 0, right: 0 }}
|
||||
dragElastic={0.55}
|
||||
style={{ x }}
|
||||
onDragEnd={(_, info) => {
|
||||
if (info.offset.x > 90) answer(true);
|
||||
else if (info.offset.x < -90) answer(false);
|
||||
}}
|
||||
whileTap={{ cursor: "grabbing" }}
|
||||
className="touch-pan-y"
|
||||
>
|
||||
<PromptCard className="relative min-h-44 select-none">
|
||||
<motion.span style={{ opacity: trueOpacity }} className="absolute left-4 top-3 text-sm font-extrabold uppercase tracking-widest text-emerald-500">
|
||||
✓ true
|
||||
</motion.span>
|
||||
<motion.span style={{ opacity: falseOpacity }} className="absolute right-4 top-3 text-sm font-extrabold uppercase tracking-widest text-rose-500">
|
||||
false ✕
|
||||
</motion.span>
|
||||
<p className="pt-6 text-center text-xl font-bold leading-snug text-frost-950 sm:text-2xl dark:text-frost-50">
|
||||
“{activity.statement}”
|
||||
</p>
|
||||
</PromptCard>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{result === null ? (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Button variant="secondary" size="lg" onClick={() => answer(false)} className="border-rose-200 text-rose-700 dark:text-rose-200">
|
||||
✕ False
|
||||
</Button>
|
||||
<Button variant="success" size="lg" onClick={() => answer(true)}>
|
||||
✓ True
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<FeedbackPanel
|
||||
correct={result}
|
||||
explanation={activity.explanation}
|
||||
xp={activity.xp}
|
||||
isLast={isLast}
|
||||
onNext={onNext}
|
||||
detail={
|
||||
<span>
|
||||
This statement is <span className="font-bold">{activity.answer ? "true" : "false"}</span>.
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
44
components/activities/index.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
"use client";
|
||||
|
||||
import { FillBlank } from "./FillBlank";
|
||||
import { Flashcard } from "./Flashcard";
|
||||
import { Matching } from "./Matching";
|
||||
import { MultipleChoice } from "./MultipleChoice";
|
||||
import { Ordering } from "./Ordering";
|
||||
import { ShortAnswer } from "./ShortAnswer";
|
||||
import { TrueFalse } from "./TrueFalse";
|
||||
import { type ActivityComponentProps } from "./shared";
|
||||
|
||||
export type { ActivityComponentProps } from "./shared";
|
||||
export { TYPE_META } from "./shared";
|
||||
|
||||
/**
|
||||
* Renders an activity from JSON based on its `type` field.
|
||||
* Every activity type is a self-contained React component.
|
||||
*/
|
||||
export function ActivityRenderer(props: ActivityComponentProps) {
|
||||
const { activity } = props;
|
||||
|
||||
switch (activity.type) {
|
||||
case "flashcard":
|
||||
return <Flashcard {...props} activity={activity} />;
|
||||
case "multiple_choice":
|
||||
return <MultipleChoice {...props} activity={activity} />;
|
||||
case "true_false":
|
||||
return <TrueFalse {...props} activity={activity} />;
|
||||
case "fill_blank":
|
||||
return <FillBlank {...props} activity={activity} />;
|
||||
case "matching":
|
||||
return <Matching {...props} activity={activity} />;
|
||||
case "ordering":
|
||||
return <Ordering {...props} activity={activity} />;
|
||||
case "short_answer":
|
||||
return <ShortAnswer {...props} activity={activity} />;
|
||||
default: {
|
||||
// Exhaustiveness guard: adding a new activity type without a component
|
||||
// becomes a TypeScript error.
|
||||
const never: never = activity;
|
||||
return <p className="text-sm">Unsupported activity: {JSON.stringify(never)}</p>;
|
||||
}
|
||||
}
|
||||
}
|
||||
204
components/activities/shared.tsx
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
import type { Activity } from "@/lib/schemas";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui";
|
||||
|
||||
export type ActivityComponentProps<A extends Activity = Activity> = {
|
||||
activity: A;
|
||||
/** True when this slide is the one currently filling the screen. */
|
||||
active: boolean;
|
||||
/** Language of the deck, so AI grading answers in the same language. */
|
||||
language?: string;
|
||||
/** Report the result to the feed (once per attempt). */
|
||||
onAnswered: (correct: boolean, meta?: { userAnswer?: string }) => void;
|
||||
/** Scroll the feed to the next slide. */
|
||||
onNext: () => void;
|
||||
isLast: boolean;
|
||||
};
|
||||
|
||||
export const TYPE_META: Record<Activity["type"], { label: string; icon: string; tint: string }> = {
|
||||
flashcard: { label: "Flashcard", icon: "🃏", tint: "tint-sky" },
|
||||
multiple_choice: { label: "Multiple choice", icon: "🔘", tint: "tint-lilac" },
|
||||
true_false: { label: "True or false", icon: "⚖️", tint: "tint-mint" },
|
||||
fill_blank: { label: "Fill the blank", icon: "✏️", tint: "tint-peach" },
|
||||
matching: { label: "Match up", icon: "🧲", tint: "tint-sky" },
|
||||
ordering: { label: "Put in order", icon: "🔢", tint: "tint-lilac" },
|
||||
short_answer: { label: "Short answer", icon: "💬", tint: "tint-rose" },
|
||||
};
|
||||
export function ActivityHeader({
|
||||
type,
|
||||
topic,
|
||||
extra,
|
||||
}: {
|
||||
type: Activity["type"];
|
||||
topic?: string;
|
||||
extra?: React.ReactNode;
|
||||
}) {
|
||||
const meta = TYPE_META[type];
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-semibold ring-1 ring-white/50 backdrop-blur-sm",
|
||||
meta.tint,
|
||||
)}
|
||||
>
|
||||
<span aria-hidden>{meta.icon}</span>
|
||||
{meta.label}
|
||||
</span>
|
||||
{topic ? (
|
||||
<span className="glass-chip rounded-full px-3 py-1 text-xs font-medium text-frost-600 dark:text-frost-300">
|
||||
{topic}
|
||||
</span>
|
||||
) : null}
|
||||
{extra}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PromptCard({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("glass-panel rounded-[1.35rem] px-5 py-6 text-balance sm:px-6", className)}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function QuestionText({ children, className }: { children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<p className={cn("text-xl font-bold leading-snug text-frost-950 sm:text-2xl dark:text-frost-50", className)}>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/** Standard tile used for answer options. */
|
||||
export function OptionTile({
|
||||
children,
|
||||
state = "idle",
|
||||
disabled,
|
||||
onClick,
|
||||
className,
|
||||
shortcut,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
state?: "idle" | "selected" | "correct" | "wrong" | "dim";
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
shortcut?: string;
|
||||
}) {
|
||||
const states: Record<string, string> = {
|
||||
idle: "border-white/60 bg-white/55 hover:bg-white/80 dark:border-white/12 dark:bg-white/[0.07] dark:hover:bg-white/[0.12]",
|
||||
selected: "border-accent-400 bg-accent-50/80 dark:bg-accent-500/20",
|
||||
correct:
|
||||
"border-emerald-300 bg-emerald-50/90 text-emerald-950 dark:border-emerald-400/50 dark:bg-emerald-400/15 dark:text-emerald-50",
|
||||
wrong: "border-rose-300 bg-rose-50/90 text-rose-950 dark:border-rose-400/50 dark:bg-rose-400/15 dark:text-rose-50",
|
||||
dim: "border-white/40 bg-white/35 text-frost-400 dark:border-white/10 dark:bg-white/[0.04] dark:text-frost-500",
|
||||
};
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 rounded-2xl border px-4 py-3.5 text-left text-[15px] font-medium backdrop-blur-xl transition-all duration-200 disabled:cursor-default",
|
||||
states[state],
|
||||
!disabled && "active:scale-[0.99]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{shortcut ? (
|
||||
<span className="grid size-6 shrink-0 place-items-center rounded-lg bg-black/5 text-xs font-bold text-slate-600 dark:bg-white/10 dark:text-slate-300">
|
||||
{shortcut}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="flex-1">{children}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function FeedbackPanel({
|
||||
correct,
|
||||
explanation,
|
||||
xp,
|
||||
onNext,
|
||||
isLast,
|
||||
headline,
|
||||
detail,
|
||||
children,
|
||||
}: {
|
||||
correct: boolean;
|
||||
explanation: string;
|
||||
xp?: number;
|
||||
onNext: () => void;
|
||||
isLast?: boolean;
|
||||
headline?: string;
|
||||
detail?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 18 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ type: "spring", stiffness: 380, damping: 32 }}
|
||||
className={cn(
|
||||
"rounded-3xl border p-4 shadow-lift backdrop-blur-xl",
|
||||
correct
|
||||
? "border-emerald-200/80 bg-gradient-to-b from-emerald-50/95 to-emerald-100/70 dark:border-emerald-400/30 dark:from-emerald-500/15 dark:to-emerald-500/5"
|
||||
: "border-rose-200/80 bg-gradient-to-b from-rose-50/95 to-rose-100/70 dark:border-rose-400/30 dark:from-rose-500/15 dark:to-rose-500/5",
|
||||
)}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-base font-extrabold",
|
||||
correct ? "text-emerald-900 dark:text-emerald-200" : "text-rose-900 dark:text-rose-200",
|
||||
)}
|
||||
>
|
||||
<span aria-hidden>{correct ? "✅" : "💧"}</span>
|
||||
{headline ?? (correct ? "Nice one!" : "Not quite")}
|
||||
</p>
|
||||
{correct && xp ? (
|
||||
<motion.span
|
||||
initial={{ scale: 0.6, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
className="rounded-full bg-emerald-500 px-3 py-1 text-sm font-extrabold text-white shadow"
|
||||
>
|
||||
+{xp} XP
|
||||
</motion.span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{detail ? <div className="mt-2 text-sm text-frost-700 dark:text-frost-200">{detail}</div> : null}
|
||||
|
||||
<p className="mt-2 text-sm leading-relaxed text-frost-700 dark:text-frost-200">
|
||||
<span className="font-bold">Why: </span>
|
||||
{explanation}
|
||||
</p>
|
||||
|
||||
{children}
|
||||
|
||||
<div className="mt-4 flex items-center justify-end">
|
||||
<Button onClick={onNext} size="md" className="min-w-28">
|
||||
{isLast ? "See results" : "Next"}
|
||||
<span aria-hidden>→</span>
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Small hint row under a question, e.g. "Tap a term, then its definition". */
|
||||
export function Hint({ children }: { children: React.ReactNode }) {
|
||||
return <p className="text-sm text-frost-600 dark:text-frost-300">{children}</p>;
|
||||
}
|
||||
239
components/icons.tsx
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* Small inline SVG icons used for the app chrome so that critical controls
|
||||
* don't depend on a system emoji font being installed.
|
||||
*/
|
||||
|
||||
type IconProps = { className?: string; strokeWidth?: number };
|
||||
|
||||
const base = "size-5";
|
||||
|
||||
function Svg({
|
||||
children,
|
||||
className,
|
||||
filled,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
filled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className={className ?? base}
|
||||
fill={filled ? "currentColor" : "none"}
|
||||
stroke={filled ? "none" : "currentColor"}
|
||||
strokeWidth={1.8}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IceCubeIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M12 3.2 19 7v10l-7 3.8L5 17V7l7-3.8Z" />
|
||||
<path d="M5 7l7 3.8L19 7M12 10.8V21" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SnowflakeIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M12 2v20M4.5 6.5l15 11M19.5 6.5l-15 11" />
|
||||
<path d="M12 6l-2.2-2.2M12 6l2.2-2.2M12 18l-2.2 2.2M12 18l2.2 2.2M7 9.2 4.8 8.6M7 14.8l-2.2.6M17 9.2l2.2-.6M17 14.8l2.2.6" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function GearIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<circle cx="12" cy="12" r="3.2" />
|
||||
<path d="M19.4 15a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.7 1.7 0 0 0-1.87-.34 1.7 1.7 0 0 0-1.03 1.56V21a2 2 0 1 1-4 0v-.11A1.7 1.7 0 0 0 8.9 19.3a1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.7 1.7 0 0 0 4.7 15a1.7 1.7 0 0 0-1.56-1.03H3a2 2 0 1 1 0-4h.11A1.7 1.7 0 0 0 4.7 8.9a1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.7 1.7 0 0 0 9 4.7a1.7 1.7 0 0 0 1.03-1.56V3a2 2 0 1 1 4 0v.11A1.7 1.7 0 0 0 15.1 4.7a1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.7 1.7 0 0 0 19.4 9v.03A1.7 1.7 0 0 0 21 10.06H21a2 2 0 1 1 0 4h-.11A1.7 1.7 0 0 0 19.4 15Z" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SunIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function MoonIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M21 12.8A8.5 8.5 0 1 1 11.2 3a6.6 6.6 0 0 0 9.8 9.8Z" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function BoltIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className} filled>
|
||||
<path d="M13.5 2 4 13.2h5.4L9.5 22 19 10.8h-5.4L13.5 2Z" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function FlameIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className} filled>
|
||||
<path d="M12 2s4.5 4.2 4.5 8.2c0 1.2-.5 2.2-1.2 2.9.1-1.6-.5-3.3-1.8-4.6.3 2.6-1 4-2.2 5.1-1 .9-1.8 1.8-1.8 3.2 0 1 .4 1.9 1.1 2.6-2.9-.3-5.1-2.6-5.1-5.5C5.5 9.7 12 8 12 2Z" />
|
||||
<path d="M12 22c2.2 0 4-1.6 4-3.7 0-1.6-.8-2.4-1.7-3.3-.6-.6-1.3-1.3-1.6-2.3-.8.8-1.4 2-1.4 3.2 0 .9-.4 1.4-1 1.8-.5.4-1.1.9-1.1 1.9C9.2 21.1 10.4 22 12 22Z" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SparklesIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M12 3l1.6 4.4L18 9l-4.4 1.6L12 15l-1.6-4.4L6 9l4.4-1.6L12 3Z" />
|
||||
<path d="M18.5 15.5l.8 2.2 2.2.8-2.2.8-.8 2.2-.8-2.2-2.2-.8 2.2-.8.8-2.2Z" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function LayersIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M12 3.5 20 8l-8 4.5L4 8l8-4.5Z" />
|
||||
<path d="m4.6 12.4 7.4 4.2 7.4-4.2M4.6 16.4l7.4 4.2 7.4-4.2" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function FolderIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M3 7.5A1.5 1.5 0 0 1 4.5 6h4l2 2.5h9A1.5 1.5 0 0 1 21 10v8a1.5 1.5 0 0 1-1.5 1.5h-15A1.5 1.5 0 0 1 3 18V7.5Z" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CameraIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M4 8.5A1.5 1.5 0 0 1 5.5 7h1.9l1.1-1.8A1 1 0 0 1 9.4 5h5.2a1 1 0 0 1 .9.5L16.6 7h1.9A1.5 1.5 0 0 1 20 8.5v9A1.5 1.5 0 0 1 18.5 19h-13A1.5 1.5 0 0 1 4 17.5v-9Z" />
|
||||
<circle cx="12" cy="12.8" r="3.2" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlayIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className} filled>
|
||||
<path d="M8 5.5v13l11-6.5-11-6.5Z" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function DownloadIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M12 3v12M7.5 10.5 12 15l4.5-4.5M4 20h16" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function UploadIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M12 16V4M7.5 8.5 12 4l4.5 4.5M4 20h16" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function HomeIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M4 10.5 12 4l8 6.5V19a1 1 0 0 1-1 1h-4v-5H9v5H5a1 1 0 0 1-1-1v-8.5Z" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CheckCircleIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="m8.2 12.4 2.6 2.6 5-5.4" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function AlertTriangleIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M12 4.5 21 19.5H3L12 4.5Z" />
|
||||
<path d="M12 10v4.2M12 17.2v.1" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function XCircleIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="m9 9 6 6M15 9l-6 6" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlusIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ShareIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M12 3v12M8.2 6.8 12 3l3.8 3.8" />
|
||||
<path d="M6 12H5a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-7a1 1 0 0 0-1-1h-1" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChevronLeftIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M14.5 5.5 8 12l6.5 6.5" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function MenuIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M4 7h16M4 12h16M4 17h16" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChevronRightIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="m9.5 5.5 6.5 6.5-6.5 6.5" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function RefreshIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M20 12a8 8 0 1 1-2.4-5.7M20 4v4h-4" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
151
components/ui.tsx
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
"use client";
|
||||
|
||||
import { forwardRef } from "react";
|
||||
|
||||
import { SnowflakeIcon } from "@/components/icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "success";
|
||||
type ButtonSize = "sm" | "md" | "lg";
|
||||
|
||||
/**
|
||||
* ChatGPT-flavoured buttons: pill-shaped, near-black primary in light mode and
|
||||
* white in dark mode, quiet secondaries with hairline borders.
|
||||
*/
|
||||
const VARIANTS: Record<ButtonVariant, string> = {
|
||||
primary: "btn-primary",
|
||||
secondary: "btn-secondary",
|
||||
ghost: "btn-ghost",
|
||||
danger: "btn-primary !bg-rose-600 hover:!bg-rose-700 dark:!bg-rose-500 dark:!text-white dark:hover:!bg-rose-600",
|
||||
success: "btn-primary !bg-emerald-600 hover:!bg-emerald-700 dark:!bg-emerald-500 dark:!text-white dark:hover:!bg-emerald-600",
|
||||
};
|
||||
|
||||
const SIZES: Record<ButtonSize, string> = {
|
||||
sm: "h-9 px-3.5 text-[13px] rounded-full",
|
||||
md: "h-10 px-4 text-sm rounded-full",
|
||||
lg: "h-11 px-5 text-[15px] rounded-full",
|
||||
};
|
||||
|
||||
export type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
||||
{ className, variant = "primary", size = "md", loading, children, disabled, ...props },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
disabled={disabled || loading}
|
||||
className={cn(
|
||||
"inline-flex select-none items-center justify-center whitespace-nowrap font-medium transition disabled:cursor-not-allowed disabled:opacity-40",
|
||||
VARIANTS[variant],
|
||||
SIZES[size],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{loading ? <Spinner className="size-4" /> : null}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
export function Spinner({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={cn("animate-spin", className)} viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeOpacity="0.25" strokeWidth="3" />
|
||||
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Small neutral pill. Colour is reserved for meaning, not decoration. */
|
||||
export function Chip({
|
||||
children,
|
||||
className,
|
||||
tone = "neutral",
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
tone?: "neutral" | "accent" | "success" | "warning" | "danger";
|
||||
}) {
|
||||
const tones: Record<string, string> = {
|
||||
neutral: "bg-[var(--surface-sunken)] text-[var(--text-muted)]",
|
||||
accent: "bg-accent-500/12 text-accent-700 dark:text-accent-200",
|
||||
success: "bg-emerald-500/12 text-emerald-700 dark:text-emerald-300",
|
||||
warning: "bg-amber-500/14 text-amber-800 dark:text-amber-300",
|
||||
danger: "bg-rose-500/12 text-rose-700 dark:text-rose-300",
|
||||
};
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[12px] font-medium leading-none",
|
||||
tones[tone],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProgressBar({
|
||||
value,
|
||||
max,
|
||||
className,
|
||||
label,
|
||||
}: {
|
||||
value: number;
|
||||
max: number;
|
||||
className?: string;
|
||||
label?: string;
|
||||
}) {
|
||||
const percent = max <= 0 ? 0 : Math.min(100, Math.max(0, (value / max) * 100));
|
||||
return (
|
||||
<div
|
||||
className={cn("h-1 w-full overflow-hidden rounded-full bg-[var(--surface-sunken)]", className)}
|
||||
role="progressbar"
|
||||
aria-valuenow={Math.round(percent)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label={label ?? "Progress"}
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full bg-[var(--text)]/75 transition-[width] duration-500 ease-out"
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
title,
|
||||
children,
|
||||
icon,
|
||||
action,
|
||||
}: {
|
||||
title: string;
|
||||
children?: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
action?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="animate-rise flex flex-col items-center px-6 py-16 text-center sm:py-24">
|
||||
<span
|
||||
className="mb-4 grid size-12 place-items-center rounded-2xl border border-[var(--hairline)] bg-[var(--surface-strong)] text-[var(--text-subtle)]"
|
||||
aria-hidden
|
||||
>
|
||||
{icon ?? <SnowflakeIcon className="size-6" />}
|
||||
</span>
|
||||
<h2 className="text-[17px] font-semibold tracking-[-0.01em] text-[var(--text)]">{title}</h2>
|
||||
{children ? (
|
||||
<p className="mt-2 max-w-md text-sm leading-relaxed text-[var(--text-muted)]">{children}</p>
|
||||
) : null}
|
||||
{action ? <div className="mt-6">{action}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -18,6 +18,8 @@ const eslintConfig = [
|
|||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
// Generated at build/dev time by scripts/copy-pdf-worker.mjs.
|
||||
"public/pdf.worker.min.mjs",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
|
|||
442
lib/ai.ts
Normal file
|
|
@ -0,0 +1,442 @@
|
|||
/**
|
||||
* Server-side AI pipeline: vision extraction, activity generation and
|
||||
* short-answer grading. Every step asks for JSON, validates with zod and
|
||||
* retries once with the validation error fed back to the model.
|
||||
*/
|
||||
|
||||
import { OllamaError, ollamaChat, type OllamaMessage } from "./ollama";
|
||||
import { needsLanguageRepair } from "./language";
|
||||
import {
|
||||
buildGenerationUserPrompt,
|
||||
buildGradingUserPrompt,
|
||||
buildRepairPrompt,
|
||||
GENERATION_SYSTEM_PROMPT,
|
||||
GRADING_SYSTEM_PROMPT,
|
||||
VISION_SYSTEM_PROMPT,
|
||||
VISION_USER_INSTRUCTION,
|
||||
} from "./prompts";
|
||||
import {
|
||||
extractedNotesSchema,
|
||||
generatedActivitySchema,
|
||||
gradeResultSchema,
|
||||
type Difficulty,
|
||||
type ExtractedNotes,
|
||||
type GeneratedActivity,
|
||||
type GradeResult,
|
||||
} from "./schemas";
|
||||
import { parseJsonLoose } from "./utils";
|
||||
import type { OllamaAuth } from "./config";
|
||||
|
||||
export type PipelineStage = "vision" | "generation" | "done";
|
||||
|
||||
type JsonStepOptions<T> = {
|
||||
model: string;
|
||||
auth: OllamaAuth;
|
||||
system: string;
|
||||
user: string;
|
||||
images?: string[];
|
||||
/** Validates and can also "tighten" the parsed value. */
|
||||
validate: (value: unknown) => { ok: true; value: T } | { ok: false; error: string };
|
||||
shapeHint: string;
|
||||
label: string;
|
||||
signal?: AbortSignal;
|
||||
temperature?: number;
|
||||
};
|
||||
|
||||
type JsonStepResult<T> = {
|
||||
value: T;
|
||||
/** true when the first attempt failed and the repair round was used. */
|
||||
repaired: boolean;
|
||||
attempts: number;
|
||||
};
|
||||
|
||||
async function runJsonStep<T>({
|
||||
model,
|
||||
auth,
|
||||
system,
|
||||
user,
|
||||
images,
|
||||
validate,
|
||||
shapeHint,
|
||||
label,
|
||||
signal,
|
||||
temperature,
|
||||
}: JsonStepOptions<T>): Promise<JsonStepResult<T>> {
|
||||
const messages: OllamaMessage[] = [
|
||||
{ role: "system", content: system },
|
||||
{ role: "user", content: user, ...(images && images.length ? { images } : {}) },
|
||||
];
|
||||
|
||||
let lastRaw = "";
|
||||
let lastError = "";
|
||||
|
||||
for (let attempt = 1; attempt <= 2; attempt++) {
|
||||
const result = await ollamaChat({
|
||||
model,
|
||||
auth,
|
||||
messages,
|
||||
json: true,
|
||||
signal,
|
||||
temperature: temperature ?? 0.4,
|
||||
});
|
||||
lastRaw = result.content;
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = parseJsonLoose(result.content);
|
||||
} catch (error) {
|
||||
lastError = `Response was not valid JSON: ${error instanceof Error ? error.message : String(error)}`;
|
||||
if (attempt === 1) {
|
||||
messages.push({ role: "assistant", content: result.content });
|
||||
messages.push({
|
||||
role: "user",
|
||||
content: buildRepairPrompt(lastRaw, lastError, shapeHint),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
throw new OllamaError("bad_response", `${label} failed: ${lastError}`);
|
||||
}
|
||||
|
||||
const checked = validate(parsed);
|
||||
if (checked.ok) {
|
||||
return { value: checked.value, repaired: attempt > 1, attempts: attempt };
|
||||
}
|
||||
|
||||
lastError = checked.error;
|
||||
if (attempt === 1) {
|
||||
messages.push({ role: "assistant", content: result.content });
|
||||
messages.push({
|
||||
role: "user",
|
||||
content: buildRepairPrompt(lastRaw, lastError, shapeHint),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
throw new OllamaError(
|
||||
"bad_response",
|
||||
`${label} returned data that did not match the expected format: ${lastError}`,
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Step 1 - vision */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const VISION_SHAPE_HINT = `{"title":"...","subject":"...","language":"English name of the notes' language","summary":"...","notes":"plain text transcription","keyConcepts":["..."],"definitions":[{"term":"...","definition":"..."}],"formulas":[{"name":"...","formula":"..."}],"facts":["..."]}`;
|
||||
|
||||
/**
|
||||
* Read the uploaded images (and any pasted text) into structured notes.
|
||||
* Any pasted text is passed through verbatim as additional note content.
|
||||
*
|
||||
* A small vision model can accidentally translate on hard handwriting, so the
|
||||
* result is checked and — when it clearly came back in another language —
|
||||
* restored by the (stronger, multilingual) text model.
|
||||
*/
|
||||
export async function extractNotes(input: {
|
||||
model: string;
|
||||
/** Model used to restore the language if the transcription was translated. */
|
||||
repairModel: string;
|
||||
auth: OllamaAuth;
|
||||
images: string[];
|
||||
text?: string;
|
||||
signal?: AbortSignal;
|
||||
onProgress?: (message: string) => void;
|
||||
}): Promise<{ notes: ExtractedNotes; repaired: boolean; languageRepaired: boolean }> {
|
||||
const user = input.text?.trim()
|
||||
? `${VISION_USER_INSTRUCTION}\n\nADDITIONAL TEXT THE STUDENT PASTED (treat as notes too):\n${input.text.slice(0, 12_000)}`
|
||||
: VISION_USER_INSTRUCTION;
|
||||
|
||||
const { value, repaired } = await runJsonStep<ExtractedNotes>({
|
||||
model: input.model,
|
||||
auth: input.auth,
|
||||
system: VISION_SYSTEM_PROMPT,
|
||||
user,
|
||||
images: input.images,
|
||||
validate: (parsed) => {
|
||||
const result = extractedNotesSchema.safeParse(parsed);
|
||||
if (!result.success) return { ok: false, error: summariseZodError(result.error) };
|
||||
if (!result.data.notes.trim()) return { ok: false, error: "The notes field is empty." };
|
||||
return { ok: true, value: result.data };
|
||||
},
|
||||
shapeHint: VISION_SHAPE_HINT,
|
||||
label: "Reading your notes",
|
||||
signal: input.signal,
|
||||
temperature: 0.2,
|
||||
});
|
||||
|
||||
const check = needsLanguageRepair({ language: value.language, text: value.notes });
|
||||
if (!check.suspicious) return { notes: value, repaired, languageRepaired: false };
|
||||
|
||||
console.warn(`[freezmo] restoring the notes' language: ${check.reason}`);
|
||||
input.onProgress?.(`Restoring the notes' language (${value.language ?? "original"})…`);
|
||||
try {
|
||||
const restored = await restoreLanguage({
|
||||
model: input.repairModel,
|
||||
auth: input.auth,
|
||||
language: value.language ?? "the original language of the notes",
|
||||
extraction: value,
|
||||
signal: input.signal,
|
||||
});
|
||||
return { notes: restored, repaired, languageRepaired: true };
|
||||
} catch (error) {
|
||||
// The guard is best-effort: a failure must never break generation.
|
||||
console.warn("[freezmo] language restore failed, using the original transcription", error);
|
||||
return { notes: value, repaired, languageRepaired: false };
|
||||
}
|
||||
}
|
||||
|
||||
const RESTORE_SYSTEM_PROMPT = `You are Freezmo's language restorer. A student's study notes were wrongly translated out of their original language (usually into English) before reaching you.
|
||||
|
||||
Your job: rewrite the JSON you receive back into the ORIGINAL language, keeping every fact, number, formula, name and definition exactly as it is.
|
||||
|
||||
Rules:
|
||||
- Translate ONLY what was wrongly translated. Do not add, remove, summarise or explain anything.
|
||||
- Keep the same JSON keys and the same array structure. Keep the same number of items in every array.
|
||||
- Keep technical terms, units and formulas as the original notes used them.
|
||||
- Keep proper nouns as they are unless the original language would inflect them.
|
||||
- Output JSON only, no markdown fences, no commentary.`;
|
||||
|
||||
/**
|
||||
* Ask the text model to put a translated transcription back into the language
|
||||
* the vision model reported. Returns the original value if the repair is
|
||||
* unusable.
|
||||
*/
|
||||
export async function restoreLanguage(input: {
|
||||
model: string;
|
||||
auth: OllamaAuth;
|
||||
language: string;
|
||||
extraction: ExtractedNotes;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<ExtractedNotes> {
|
||||
const value = await runJsonStep<ExtractedNotes>({
|
||||
model: input.model,
|
||||
auth: input.auth,
|
||||
system: RESTORE_SYSTEM_PROMPT,
|
||||
user: [
|
||||
`ORIGINAL LANGUAGE: ${input.language}`,
|
||||
"",
|
||||
"JSON TO RESTORE:",
|
||||
JSON.stringify(input.extraction).slice(0, 40_000),
|
||||
"",
|
||||
"Return the same JSON with the text fields written in the original language.",
|
||||
].join("\n"),
|
||||
validate: (parsed) => {
|
||||
const result = extractedNotesSchema.safeParse(parsed);
|
||||
if (!result.success) return { ok: false, error: summariseZodError(result.error) };
|
||||
if (!result.data.notes.trim()) return { ok: false, error: "The notes field is empty." };
|
||||
return { ok: true, value: result.data };
|
||||
},
|
||||
shapeHint: VISION_SHAPE_HINT,
|
||||
label: "Restoring the notes' language",
|
||||
signal: input.signal,
|
||||
temperature: 0.1,
|
||||
});
|
||||
return value.value;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Step 2 - generation */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const GENERATION_SHAPE_HINT = `{"title":"...","subject":"...","language":"English name of the language used","summary":"...","activities":[{"type":"multiple_choice","topic":"...","question":"...","options":["...","...","...","..."],"correctIndex":0,"explanation":"..."}, ...]}`;
|
||||
|
||||
const VALID_TYPES = [
|
||||
"flashcard",
|
||||
"multiple_choice",
|
||||
"true_false",
|
||||
"fill_blank",
|
||||
"matching",
|
||||
"ordering",
|
||||
"short_answer",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Be forgiving: validate each activity on its own and keep the good ones.
|
||||
* Only if too few survive does the step get retried.
|
||||
*/
|
||||
function validateDeck(parsed: unknown, minimum: number, requested: number) {
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
return { ok: false as const, error: "Expected a JSON object at the top level." };
|
||||
}
|
||||
const record = parsed as Record<string, unknown>;
|
||||
const title = typeof record.title === "string" && record.title.trim() ? record.title.trim() : "Untitled deck";
|
||||
const subject = typeof record.subject === "string" ? record.subject.trim() : undefined;
|
||||
const summary = typeof record.summary === "string" ? record.summary.trim() : undefined;
|
||||
const language = typeof record.language === "string" && record.language.trim() ? record.language.trim() : undefined;
|
||||
|
||||
const rawActivities = Array.isArray(record.activities)
|
||||
? record.activities
|
||||
: Array.isArray(record.items)
|
||||
? record.items
|
||||
: null;
|
||||
|
||||
if (!rawActivities) {
|
||||
return { ok: false as const, error: `Missing "activities" array. Expected an array of ${requested} activity objects.` };
|
||||
}
|
||||
|
||||
const activities: GeneratedActivity[] = [];
|
||||
const rejected: string[] = [];
|
||||
|
||||
rawActivities.slice(0, 24).forEach((item, index) => {
|
||||
const result = generatedActivitySchema.safeParse(item);
|
||||
if (result.success) {
|
||||
activities.push(sanitiseActivity(result.data));
|
||||
} else {
|
||||
const type = (item as { type?: unknown } | null)?.type;
|
||||
rejected.push(`#${index + 1} (${typeof type === "string" ? type : "no type"}): ${summariseZodError(result.error)}`);
|
||||
}
|
||||
});
|
||||
|
||||
if (activities.length < minimum) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: `Only ${activities.length} valid activities were found, at least ${minimum} are needed. Rejected: ${rejected.slice(0, 3).join("; ") || "none"}. Valid types are: ${VALID_TYPES.join(", ")}.`,
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true as const, value: { title, subject, summary, language, activities } };
|
||||
}
|
||||
|
||||
export async function generateActivities(input: {
|
||||
model: string;
|
||||
auth: OllamaAuth;
|
||||
notes: string;
|
||||
difficulty: Difficulty;
|
||||
count: number;
|
||||
language?: string;
|
||||
existing?: { title: string; types: string[]; count: number };
|
||||
focus?: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<{
|
||||
deck: { title: string; subject?: string; summary?: string; language?: string; activities: GeneratedActivity[] };
|
||||
requested: number;
|
||||
}> {
|
||||
const requested = Math.min(Math.max(input.count, 4), 18);
|
||||
|
||||
const { value } = await runJsonStep<{
|
||||
title: string;
|
||||
subject?: string;
|
||||
summary?: string;
|
||||
language?: string;
|
||||
activities: GeneratedActivity[];
|
||||
}>({
|
||||
model: input.model,
|
||||
auth: input.auth,
|
||||
system: GENERATION_SYSTEM_PROMPT,
|
||||
user: buildGenerationUserPrompt({
|
||||
notes: input.notes,
|
||||
difficulty: input.difficulty,
|
||||
count: requested,
|
||||
language: input.language,
|
||||
existing: input.existing,
|
||||
focus: input.focus,
|
||||
}),
|
||||
validate: (parsed) => validateDeck(parsed, Math.max(4, Math.ceil(requested * 0.5)), requested),
|
||||
shapeHint: GENERATION_SHAPE_HINT,
|
||||
label: "Building your activities",
|
||||
signal: input.signal,
|
||||
temperature: 0.7,
|
||||
});
|
||||
|
||||
return { deck: value, requested };
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Grading */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const GRADE_SHAPE_HINT = `{"correct":true,"score":85,"feedback":"short feedback","missing":["key point missed"]}`;
|
||||
|
||||
export async function gradeShortAnswer(input: {
|
||||
model: string;
|
||||
auth: OllamaAuth;
|
||||
question: string;
|
||||
referenceAnswer: string;
|
||||
keyPoints: string[];
|
||||
userAnswer: string;
|
||||
language?: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<GradeResult> {
|
||||
const { value } = await runJsonStep<GradeResult>({
|
||||
model: input.model,
|
||||
auth: input.auth,
|
||||
system: GRADING_SYSTEM_PROMPT,
|
||||
user: buildGradingUserPrompt(input),
|
||||
validate: (parsed) => {
|
||||
const result = gradeResultSchema.safeParse(parsed);
|
||||
if (!result.success) return { ok: false, error: summariseZodError(result.error) };
|
||||
return { ok: true, value: result.data };
|
||||
},
|
||||
shapeHint: GRADE_SHAPE_HINT,
|
||||
label: "Grading your answer",
|
||||
signal: input.signal,
|
||||
temperature: 0.1,
|
||||
});
|
||||
return value;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Small fixes so the UI can trust the data: ordering items must be unique
|
||||
* (they are used as React keys and are ambiguous otherwise), fill_blank
|
||||
* sentences must actually contain a blank, and word banks must contain
|
||||
* the answer.
|
||||
*/
|
||||
function sanitiseActivity(activity: GeneratedActivity): GeneratedActivity {
|
||||
switch (activity.type) {
|
||||
case "ordering": {
|
||||
const seen = new Set<string>();
|
||||
const items = activity.items.filter((item) => {
|
||||
const key = item.trim().toLowerCase();
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
return items.length === activity.items.length ? activity : { ...activity, items };
|
||||
}
|
||||
case "fill_blank": {
|
||||
if (/_{2,}/.test(activity.sentence)) return activity;
|
||||
return { ...activity, sentence: `${activity.sentence.trim()} ____` };
|
||||
}
|
||||
case "matching": {
|
||||
const seen = new Set<string>();
|
||||
const pairs = activity.pairs.filter((pair) => {
|
||||
const key = pair.term.trim().toLowerCase();
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
return pairs.length === activity.pairs.length ? activity : { ...activity, pairs };
|
||||
}
|
||||
default:
|
||||
return activity;
|
||||
}
|
||||
}
|
||||
|
||||
/** Compact, human-readable zod error for the repair prompt. */
|
||||
function summariseZodError(error: { issues: Array<{ path: PropertyKey[]; message: string }> }): string {
|
||||
return error.issues
|
||||
.slice(0, 6)
|
||||
.map((issue) => `${issue.path.map(String).join(".") || "(root)"}: ${issue.message}`)
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
/** Turn the structured extraction into the plain text the generator reads. */
|
||||
export function notesToText(notes: ExtractedNotes): string {
|
||||
const parts: string[] = [];
|
||||
parts.push(`TITLE: ${notes.title}`);
|
||||
if (notes.subject) parts.push(`SUBJECT: ${notes.subject}`);
|
||||
if (notes.language) parts.push(`LANGUAGE: ${notes.language}`);
|
||||
if (notes.summary) parts.push(`SUMMARY: ${notes.summary}`);
|
||||
parts.push("", "TRANSCRIPTION:", notes.notes);
|
||||
if (notes.keyConcepts.length) parts.push("", "KEY CONCEPTS:", ...notes.keyConcepts.map((c) => `- ${c}`));
|
||||
if (notes.definitions.length)
|
||||
parts.push("", "DEFINITIONS:", ...notes.definitions.map((d) => `- ${d.term}: ${d.definition}`));
|
||||
if (notes.formulas.length)
|
||||
parts.push("", "FORMULAS:", ...notes.formulas.map((f) => `- ${f.name}: ${f.formula}`));
|
||||
if (notes.facts.length) parts.push("", "FACTS:", ...notes.facts.map((f) => `- ${f}`));
|
||||
return parts.join("\n").slice(0, 48_000);
|
||||
}
|
||||
209
lib/client-api.ts
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
"use client";
|
||||
|
||||
import type { Activity, Difficulty } from "./schemas";
|
||||
import { authHeaders } from "./settings";
|
||||
|
||||
export type GenerateInput = {
|
||||
images?: string[];
|
||||
text?: string;
|
||||
difficulty: Difficulty;
|
||||
count?: number;
|
||||
focus?: string;
|
||||
language?: string;
|
||||
mode?: "create" | "more";
|
||||
existing?: { title: string; types: string[]; count: number };
|
||||
};
|
||||
|
||||
export type StreamedDeck = {
|
||||
title: string;
|
||||
subject?: string;
|
||||
summary?: string;
|
||||
language?: string;
|
||||
sourceText: string;
|
||||
difficulty: Difficulty;
|
||||
activities: Activity[];
|
||||
};
|
||||
|
||||
export type GenerateProgress = {
|
||||
stage: "vision" | "generation" | "done";
|
||||
message: string;
|
||||
};
|
||||
|
||||
type StreamEvent =
|
||||
| { type: "stage"; stage: "vision" | "generation" | "done"; message: string }
|
||||
| { type: "deck"; deck: StreamedDeck; meta: Record<string, unknown> }
|
||||
| { type: "error"; error: string; kind?: string };
|
||||
|
||||
export class ApiError extends Error {
|
||||
kind: string;
|
||||
constructor(message: string, kind = "unknown") {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.kind = kind;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls POST /api/generate and consumes the NDJSON progress stream.
|
||||
* Progress events arrive between the vision and generation steps.
|
||||
*/
|
||||
export async function generateDeck(
|
||||
input: GenerateInput,
|
||||
options: { onProgress?: (progress: GenerateProgress) => void; signal?: AbortSignal } = {},
|
||||
): Promise<{ deck: StreamedDeck; meta: Record<string, unknown> }> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch("/api/generate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...authHeaders() },
|
||||
body: JSON.stringify(input),
|
||||
signal: options.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
throw new ApiError("Generation was cancelled.", "aborted");
|
||||
}
|
||||
throw new ApiError("Could not reach the Freezmo server. Is it still running?", "network");
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as { error?: string; kind?: string } | null;
|
||||
throw new ApiError(body?.error ?? `The server returned HTTP ${response.status}.`, body?.kind);
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new ApiError("The server sent an empty response.", "bad_response");
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let deck: StreamedDeck | null = null;
|
||||
let meta: Record<string, unknown> = {};
|
||||
let streamError: ApiError | null = null;
|
||||
let sawActivity = false;
|
||||
|
||||
const handleLine = (line: string) => {
|
||||
if (!line.trim()) return;
|
||||
let event: StreamEvent;
|
||||
try {
|
||||
event = JSON.parse(line) as StreamEvent;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (event.type === "stage") {
|
||||
options.onProgress?.({ stage: event.stage, message: event.message });
|
||||
} else if (event.type === "deck") {
|
||||
deck = event.deck;
|
||||
meta = event.meta ?? {};
|
||||
sawActivity = true;
|
||||
options.onProgress?.({ stage: "done", message: "Almost there…" });
|
||||
} else if (event.type === "error") {
|
||||
streamError = new ApiError(event.error, event.kind);
|
||||
}
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
let newline = buffer.indexOf("\n");
|
||||
while (newline !== -1) {
|
||||
handleLine(buffer.slice(0, newline));
|
||||
buffer = buffer.slice(newline + 1);
|
||||
newline = buffer.indexOf("\n");
|
||||
}
|
||||
}
|
||||
handleLine(buffer);
|
||||
|
||||
if (streamError) throw streamError;
|
||||
if (!deck) {
|
||||
throw new ApiError(
|
||||
sawActivity
|
||||
? "The server finished without sending a deck."
|
||||
: "No deck came back from the server. Check your Ollama settings and try again.",
|
||||
"bad_response",
|
||||
);
|
||||
}
|
||||
|
||||
const valid = (deck as StreamedDeck).activities?.filter(
|
||||
(activity) => activity && typeof activity.type === "string" && typeof activity.id === "string",
|
||||
);
|
||||
if (!valid || valid.length === 0) {
|
||||
throw new ApiError("The generated deck had no usable activities. Try again.", "bad_response");
|
||||
}
|
||||
|
||||
return { deck: { ...(deck as StreamedDeck), activities: valid }, meta };
|
||||
}
|
||||
|
||||
export async function gradeAnswer(input: {
|
||||
question: string;
|
||||
referenceAnswer: string;
|
||||
keyPoints: string[];
|
||||
userAnswer: string;
|
||||
language?: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<{ correct: boolean; score: number; feedback: string; missing: string[] }> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch("/api/grade", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...authHeaders() },
|
||||
body: JSON.stringify(input),
|
||||
signal: input.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
throw new ApiError("Grading was cancelled.", "aborted");
|
||||
}
|
||||
throw new ApiError("Could not reach the Freezmo server.", "network");
|
||||
}
|
||||
|
||||
const body = (await response.json().catch(() => null)) as
|
||||
| { correct: boolean; score: number; feedback: string; missing?: string[]; error?: string; kind?: string }
|
||||
| null;
|
||||
|
||||
if (!response.ok || !body || body.error) {
|
||||
throw new ApiError(body?.error ?? `Grading failed with HTTP ${response.status}.`, body?.kind);
|
||||
}
|
||||
|
||||
return {
|
||||
correct: Boolean(body.correct),
|
||||
score: Math.max(0, Math.min(100, Math.round(body.score))),
|
||||
feedback: body.feedback,
|
||||
missing: body.missing ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export type HealthModel = {
|
||||
configured: string;
|
||||
resolved: string;
|
||||
available: boolean | null;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
export type Health = {
|
||||
ok: boolean;
|
||||
reachable: boolean;
|
||||
mode: "local-daemon" | "direct-cloud";
|
||||
authMode: "user-key" | "server" | "local";
|
||||
needsKey: boolean;
|
||||
keyAccepted: boolean | null;
|
||||
baseUrl: string;
|
||||
hasApiKey: boolean;
|
||||
vision: HealthModel;
|
||||
text: HealthModel;
|
||||
latencyMs: number | null;
|
||||
modelCount: number | null;
|
||||
error: string | null;
|
||||
kind?: string;
|
||||
checkedAt: string;
|
||||
};
|
||||
|
||||
export async function fetchHealth(): Promise<Health> {
|
||||
try {
|
||||
const response = await fetch("/api/health", { cache: "no-store", headers: authHeaders() });
|
||||
return (await response.json()) as Health;
|
||||
} catch {
|
||||
throw new ApiError("Could not reach the Freezmo server.", "network");
|
||||
}
|
||||
}
|
||||
56
lib/confetti.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
"use client";
|
||||
|
||||
/** Confetti helper that respects prefers-reduced-motion. */
|
||||
|
||||
export function prefersReducedMotion(): boolean {
|
||||
if (typeof window === "undefined" || !window.matchMedia) return false;
|
||||
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
}
|
||||
|
||||
export async function celebrate(intensity: "normal" | "big" = "normal"): Promise<void> {
|
||||
if (prefersReducedMotion()) return;
|
||||
try {
|
||||
const confetti = (await import("canvas-confetti")).default;
|
||||
// Muted, low-saturation palette to match the calmer design.
|
||||
const colors = ["#aac6da", "#7ea6c2", "#5c88a8", "#e4e9f0", "#ffffff", "#cfd8e3"];
|
||||
const count = intensity === "big" ? 130 : 60;
|
||||
|
||||
confetti({
|
||||
particleCount: count,
|
||||
spread: intensity === "big" ? 110 : 75,
|
||||
startVelocity: intensity === "big" ? 48 : 38,
|
||||
origin: { y: 0.65 },
|
||||
colors,
|
||||
scalar: 0.95,
|
||||
disableForReducedMotion: true,
|
||||
});
|
||||
|
||||
if (intensity === "big") {
|
||||
setTimeout(() => {
|
||||
confetti({ particleCount: 45, angle: 60, spread: 55, origin: { x: 0, y: 0.75 }, colors });
|
||||
confetti({ particleCount: 45, angle: 120, spread: 55, origin: { x: 1, y: 0.75 }, colors });
|
||||
}, 180);
|
||||
}
|
||||
} catch {
|
||||
/* confetti is a nice-to-have */
|
||||
}
|
||||
}
|
||||
|
||||
export function burstAt(x: number, y: number): void {
|
||||
if (prefersReducedMotion()) return;
|
||||
void (async () => {
|
||||
try {
|
||||
const confetti = (await import("canvas-confetti")).default;
|
||||
confetti({
|
||||
particleCount: 40,
|
||||
spread: 55,
|
||||
startVelocity: 26,
|
||||
origin: { x: x / window.innerWidth, y: y / window.innerHeight },
|
||||
colors: ["#7ea6c2", "#aac6da", "#cfd8e3", "#ffffff"],
|
||||
disableForReducedMotion: true,
|
||||
});
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
})();
|
||||
}
|
||||
180
lib/config.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
/**
|
||||
* Server-side configuration, read from the environment on every request so
|
||||
* that editing `.env.local` only requires a dev-server restart.
|
||||
*
|
||||
* Never import this into a client component: it would leak the API key.
|
||||
*/
|
||||
|
||||
import { sanitiseApiKey } from "./http-guard";
|
||||
|
||||
export type AuthMode = "user-key" | "server" | "local";
|
||||
|
||||
export type ServerConfig = {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
authMode: AuthMode;
|
||||
visionModel: string;
|
||||
textModel: string;
|
||||
/** True when talking straight to https://ollama.com (needs an API key). */
|
||||
directCloud: boolean;
|
||||
timeoutMs: number;
|
||||
healthTimeoutMs: number;
|
||||
/** `false` sends `think: false`; `null` omits the field entirely. */
|
||||
think: boolean | null;
|
||||
defaultActivityCount: number;
|
||||
rateLimits: {
|
||||
windowMs: number;
|
||||
generate: number;
|
||||
grade: number;
|
||||
health: number;
|
||||
};
|
||||
};
|
||||
|
||||
/** What a single request should use to talk to Ollama. */
|
||||
export type OllamaAuth = {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
directCloud: boolean;
|
||||
authMode: AuthMode;
|
||||
};
|
||||
|
||||
function positiveInt(value: string | undefined, fallback: number): number {
|
||||
const parsed = Number.parseInt(value ?? "", 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function trimTrailingSlash(url: string): string {
|
||||
return url.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export function getServerConfig(): ServerConfig {
|
||||
const baseUrl = trimTrailingSlash(
|
||||
process.env.OLLAMA_BASE_URL?.trim() || "http://localhost:11434",
|
||||
);
|
||||
const apiKey = process.env.OLLAMA_API_KEY?.trim() || "";
|
||||
|
||||
const rawAuthMode = process.env.OLLAMA_AUTH?.trim().toLowerCase();
|
||||
const authMode: AuthMode =
|
||||
rawAuthMode === "local" || rawAuthMode === "server" || rawAuthMode === "user-key"
|
||||
? rawAuthMode
|
||||
: rawAuthMode === "none"
|
||||
? "local"
|
||||
: "user-key";
|
||||
|
||||
let directCloud = false;
|
||||
try {
|
||||
const host = new URL(baseUrl).hostname.toLowerCase();
|
||||
directCloud = host === "ollama.com" || host.endsWith(".ollama.com");
|
||||
} catch {
|
||||
directCloud = false;
|
||||
}
|
||||
|
||||
const thinkRaw = process.env.OLLAMA_THINK?.trim().toLowerCase();
|
||||
const think =
|
||||
thinkRaw === "false" ? false : thinkRaw === "true" ? true : null;
|
||||
|
||||
const count = Number.parseInt(
|
||||
process.env.FREEZMO_DEFAULT_ACTIVITY_COUNT ?? "",
|
||||
10,
|
||||
);
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
apiKey,
|
||||
authMode,
|
||||
visionModel: process.env.VISION_MODEL?.trim() || "gemma4:31b-cloud",
|
||||
textModel: process.env.TEXT_MODEL?.trim() || "gpt-oss:120b-cloud",
|
||||
directCloud,
|
||||
timeoutMs: Number.parseInt(process.env.OLLAMA_TIMEOUT_MS ?? "", 10) || 300_000,
|
||||
healthTimeoutMs:
|
||||
Number.parseInt(process.env.OLLAMA_HEALTH_TIMEOUT_MS ?? "", 10) || 8_000,
|
||||
think,
|
||||
defaultActivityCount: Number.isFinite(count) ? Math.min(Math.max(count, 6), 20) : 10,
|
||||
rateLimits: {
|
||||
windowMs: positiveInt(process.env.RATE_LIMIT_WINDOW_MS, 300_000),
|
||||
generate: positiveInt(process.env.RATE_LIMIT_GENERATE, 12),
|
||||
grade: positiveInt(process.env.RATE_LIMIT_GRADE, 40),
|
||||
health: positiveInt(process.env.RATE_LIMIT_HEALTH, 90),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable explanation for every auth configuration problem, so the UI
|
||||
* never has to show a bare "unauthorised".
|
||||
*/
|
||||
export const AUTH_ERROR_MESSAGES: Record<"missing_key" | "server_key_missing" | "local_with_cloud_url", string> = {
|
||||
missing_key:
|
||||
"Add your Ollama API key first — open the settings dialog (⚙️) and paste a key from ollama.com/settings/keys.",
|
||||
server_key_missing:
|
||||
"This server is configured to use its own Ollama API key (OLLAMA_AUTH=server), but OLLAMA_API_KEY is empty in .env.local.",
|
||||
local_with_cloud_url:
|
||||
"This server is set to OLLAMA_AUTH=local but points at https://ollama.com. A signed-in local daemon can't be used against the cloud URL — either set OLLAMA_AUTH=user-key, or point OLLAMA_BASE_URL at http://localhost:11434.",
|
||||
};
|
||||
|
||||
export function authErrorKind(error: "missing_key" | "server_key_missing" | "local_with_cloud_url"): string {
|
||||
if (error === "missing_key") return "needs_key";
|
||||
return "config";
|
||||
}
|
||||
|
||||
/** Header a browser uses to hand over its own key for a single request. */
|
||||
export const API_KEY_HEADER = "x-ollama-key";
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Works out which credentials a request may use.
|
||||
*
|
||||
* - `user-key` (default): the key must come from the browser. Nothing is
|
||||
* stored server-side, ever — it is used for this request and dropped.
|
||||
* - `server`: the key comes from `.env.local` (single-user installs).
|
||||
* - `local`: neither; a local daemon signed in with `ollama signin`.
|
||||
*/
|
||||
export function resolveAuth(
|
||||
config: ServerConfig,
|
||||
request: Request,
|
||||
): { auth: OllamaAuth } | { error: "missing_key" | "server_key_missing" | "local_with_cloud_url" } {
|
||||
const headerKey = sanitiseApiKey(request.headers.get(API_KEY_HEADER));
|
||||
|
||||
if (config.authMode === "local") {
|
||||
// A daemon without sign-in can't talk to the cloud API: catch the
|
||||
// misconfiguration here instead of returning a confusing 401 later.
|
||||
if (config.directCloud) return { error: "local_with_cloud_url" };
|
||||
return {
|
||||
auth: { baseUrl: config.baseUrl, apiKey: "", directCloud: config.directCloud, authMode: "local" },
|
||||
};
|
||||
}
|
||||
|
||||
if (config.authMode === "server") {
|
||||
if (!config.apiKey) return { error: "server_key_missing" };
|
||||
return {
|
||||
auth: { baseUrl: config.baseUrl, apiKey: config.apiKey, directCloud: config.directCloud, authMode: "server" },
|
||||
};
|
||||
}
|
||||
|
||||
if (!headerKey) return { error: "missing_key" };
|
||||
return {
|
||||
auth: { baseUrl: config.baseUrl, apiKey: headerKey, directCloud: config.directCloud, authMode: "user-key" },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cloud models are addressed as `<model>:cloud` (or `<model>-cloud`) by a
|
||||
* local daemon, but the direct cloud API uses the plain library name
|
||||
* (`gemma4:31b-cloud` -> `gemma4:31b`, `glm-5.3-flash:cloud` -> `glm-5.3-flash`).
|
||||
* Normalise so the same .env works in both modes.
|
||||
*/
|
||||
export function resolveModelName(model: string, directCloud: boolean): string {
|
||||
if (!directCloud) return model;
|
||||
if (model.endsWith(":cloud")) return model.slice(0, -":cloud".length);
|
||||
if (model.endsWith("-cloud")) return model.slice(0, -"-cloud".length);
|
||||
return model;
|
||||
}
|
||||
|
||||
/** Strip local/daemon routing suffixes so model names can be compared. */
|
||||
export function canonicalModelName(model: string): string {
|
||||
return model
|
||||
.replace(/:cloud$/, "")
|
||||
.replace(/-cloud$/, "")
|
||||
.toLowerCase();
|
||||
}
|
||||
18
lib/deck-ops.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
"use client";
|
||||
|
||||
import type { Activity, Difficulty, GeneratedActivity } from "./schemas";
|
||||
import { uid } from "./utils";
|
||||
import { xpForActivity } from "./xp";
|
||||
|
||||
/** Turn freshly generated activities into stored ones (id + XP). */
|
||||
export function materialiseActivities(activities: GeneratedActivity[], difficulty: Difficulty): Activity[] {
|
||||
return activities.map((activity) => ({
|
||||
...activity,
|
||||
id: uid("act"),
|
||||
xp: xpForActivity(activity.type, difficulty),
|
||||
}));
|
||||
}
|
||||
|
||||
export function activityTypes(activities: Activity[]): string[] {
|
||||
return Array.from(new Set(activities.map((activity) => activity.type)));
|
||||
}
|
||||
476
lib/documents.ts
Normal file
|
|
@ -0,0 +1,476 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* Client-side extraction for the note formats people actually use:
|
||||
*
|
||||
* .pdf text layer, or rendered pages when scanned
|
||||
* .docx text + embedded images
|
||||
* .pptx slide text, speaker notes + embedded images
|
||||
* .odt / .odp / .ods LibreOffice documents (content.xml)
|
||||
* .txt .md .csv .rtf-lite plain text
|
||||
*
|
||||
* Everything runs in the browser: the original file never leaves the device,
|
||||
* only the extracted text (and any page images for the vision model) is sent
|
||||
* to the API routes. Photos uploaded as images keep working exactly as before.
|
||||
*/
|
||||
|
||||
import { prepareImageFromBlob, type PreparedImage } from "./image";
|
||||
|
||||
export const MAX_DOCUMENT_BYTES = 40 * 1024 * 1024; // 40 MB per file
|
||||
/** How many rendered page images one document may contribute. */
|
||||
export const MAX_DOCUMENT_IMAGES = 10;
|
||||
/** Characters kept per document (long books get truncated). */
|
||||
export const MAX_DOCUMENT_CHARS = 60_000;
|
||||
/** Pages below this much text are treated as scanned and rendered as images. */
|
||||
const SCANNED_PAGE_CHAR_THRESHOLD = 60;
|
||||
const PDF_RENDER_SCALE = 2;
|
||||
|
||||
export const ACCEPTED_DOCUMENT_EXTENSIONS = [
|
||||
"pdf",
|
||||
"docx",
|
||||
"pptx",
|
||||
"odt",
|
||||
"odp",
|
||||
"ods",
|
||||
"txt",
|
||||
"md",
|
||||
"markdown",
|
||||
"csv",
|
||||
"tsv",
|
||||
"log",
|
||||
] as const;
|
||||
|
||||
export const DOCUMENT_ACCEPT_ATTR = [
|
||||
...ACCEPTED_DOCUMENT_EXTENSIONS.map((extension) => `.${extension}`),
|
||||
"application/pdf",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
"application/vnd.oasis.opendocument.text",
|
||||
"application/vnd.oasis.opendocument.presentation",
|
||||
"application/vnd.oasis.opendocument.spreadsheet",
|
||||
"text/plain",
|
||||
"text/markdown",
|
||||
"text/csv",
|
||||
].join(",");
|
||||
|
||||
export type DocumentKind = "pdf" | "docx" | "pptx" | "opendocument" | "text";
|
||||
|
||||
export type ExtractedDocument = {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: DocumentKind;
|
||||
/** Extracted text, already truncated to MAX_DOCUMENT_CHARS. */
|
||||
text: string;
|
||||
/** Page-rendered or embedded images (JPEG data URLs, ready to send). */
|
||||
images: PreparedImage[];
|
||||
words: number;
|
||||
characters: number;
|
||||
pages?: number;
|
||||
truncated: boolean;
|
||||
imagesTruncated: boolean;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
export type DocumentProgress = {
|
||||
/** Human readable stage, e.g. "Reading page 4 of 12". */
|
||||
message: string;
|
||||
/** 0..1 where known. */
|
||||
progress?: number;
|
||||
};
|
||||
|
||||
function extensionOf(name: string): string {
|
||||
const index = name.lastIndexOf(".");
|
||||
return index === -1 ? "" : name.slice(index + 1).toLowerCase();
|
||||
}
|
||||
|
||||
export function documentKindOf(file: File): DocumentKind | null {
|
||||
const extension = extensionOf(file.name);
|
||||
switch (extension) {
|
||||
case "pdf":
|
||||
return "pdf";
|
||||
case "docx":
|
||||
return "docx";
|
||||
case "pptx":
|
||||
return "pptx";
|
||||
case "odt":
|
||||
case "odp":
|
||||
case "ods":
|
||||
return "opendocument";
|
||||
case "txt":
|
||||
case "md":
|
||||
case "markdown":
|
||||
case "csv":
|
||||
case "tsv":
|
||||
case "log":
|
||||
return "text";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isDocumentFile(file: File): boolean {
|
||||
return documentKindOf(file) !== null;
|
||||
}
|
||||
|
||||
export function describeDocumentError(file: File): string | null {
|
||||
if (!isDocumentFile(file)) {
|
||||
return `"${file.name}" is not a supported document (use PDF, DOCX, PPTX, ODT/ODP or text).`;
|
||||
}
|
||||
if (file.size > MAX_DOCUMENT_BYTES) {
|
||||
return `"${file.name}" is larger than 40 MB.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const KIND_LABELS: Record<DocumentKind, string> = {
|
||||
pdf: "PDF",
|
||||
docx: "Word",
|
||||
pptx: "PowerPoint",
|
||||
opendocument: "OpenDocument",
|
||||
text: "Text",
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Shared helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function cleanText(raw: string): string {
|
||||
return raw
|
||||
.replace(/\r\n?/g, "\n")
|
||||
.replace(/\u00a0/g, " ")
|
||||
.replace(/[ \t]+\n/g, "\n")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.replace(/[ \t]{2,}/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function countWords(text: string): number {
|
||||
return text.split(/\s+/).filter(Boolean).length;
|
||||
}
|
||||
|
||||
function withLimits(
|
||||
name: string,
|
||||
kind: DocumentKind,
|
||||
text: string,
|
||||
images: PreparedImage[],
|
||||
extra: { pages?: number; warnings?: string[]; imagesTruncated?: boolean },
|
||||
): ExtractedDocument {
|
||||
const cleaned = cleanText(text);
|
||||
const truncated = cleaned.length > MAX_DOCUMENT_CHARS;
|
||||
const finalText = truncated ? cleaned.slice(0, MAX_DOCUMENT_CHARS) : cleaned;
|
||||
return {
|
||||
id: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
name,
|
||||
kind,
|
||||
text: finalText,
|
||||
images,
|
||||
words: countWords(finalText),
|
||||
characters: finalText.length,
|
||||
pages: extra.pages,
|
||||
truncated,
|
||||
imagesTruncated: extra.imagesTruncated ?? false,
|
||||
warnings: extra.warnings ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* PDF */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
type PdfPage = {
|
||||
getTextContent: () => Promise<{ items: Array<{ str?: string; transform?: number[] }> }>;
|
||||
getViewport: (options: { scale: number }) => { width: number; height: number };
|
||||
render: (options: { canvasContext: CanvasRenderingContext2D; viewport: unknown }) => { promise: Promise<void> };
|
||||
cleanup?: () => void;
|
||||
};
|
||||
|
||||
/** Join pdf.js text items, inserting line breaks when the baseline jumps. */
|
||||
function textFromPdfItems(items: Array<{ str?: string; transform?: number[] }>): string {
|
||||
let output = "";
|
||||
let lastY: number | null = null;
|
||||
for (const item of items) {
|
||||
const value = item.str ?? "";
|
||||
const y = item.transform?.[5];
|
||||
if (lastY !== null && typeof y === "number" && Math.abs(y - lastY) > 2) {
|
||||
output += "\n";
|
||||
}
|
||||
output += value;
|
||||
if (value && !value.endsWith(" ")) output += " ";
|
||||
if (typeof y === "number") lastY = y;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
async function extractPdf(
|
||||
file: File,
|
||||
onProgress?: (update: DocumentProgress) => void,
|
||||
): Promise<ExtractedDocument> {
|
||||
// Loaded lazily so pdf.js (and its worker) never hits the initial bundle.
|
||||
const pdfjs = await import("pdfjs-dist");
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = "/pdf.worker.min.mjs";
|
||||
|
||||
const data = new Uint8Array(await file.arrayBuffer());
|
||||
const document = await pdfjs.getDocument({ data }).promise;
|
||||
|
||||
const pages: string[] = [];
|
||||
const images: PreparedImage[] = [];
|
||||
const warnings: string[] = [];
|
||||
let imagesTruncated = false;
|
||||
|
||||
const totalPages = Math.min(document.numPages, 80);
|
||||
if (document.numPages > totalPages) {
|
||||
warnings.push(`Only the first ${totalPages} of ${document.numPages} pages were read.`);
|
||||
}
|
||||
|
||||
for (let pageNumber = 1; pageNumber <= totalPages; pageNumber++) {
|
||||
onProgress?.({
|
||||
message: `Reading page ${pageNumber} of ${totalPages}…`,
|
||||
progress: pageNumber / totalPages,
|
||||
});
|
||||
|
||||
const page = (await document.getPage(pageNumber)) as unknown as PdfPage;
|
||||
let pageText = "";
|
||||
try {
|
||||
const content = await page.getTextContent();
|
||||
pageText = textFromPdfItems(content.items);
|
||||
} catch {
|
||||
warnings.push(`Page ${pageNumber} text could not be extracted.`);
|
||||
}
|
||||
|
||||
if (pageText.trim().length >= SCANNED_PAGE_CHAR_THRESHOLD) {
|
||||
pages.push(`--- page ${pageNumber} ---\n${pageText.trim()}`);
|
||||
} else if (images.length < MAX_DOCUMENT_IMAGES) {
|
||||
// Scanned or image-only page: render it so the vision model can read it.
|
||||
try {
|
||||
const viewport = page.getViewport({ scale: PDF_RENDER_SCALE });
|
||||
const canvas = window.document.createElement("canvas");
|
||||
canvas.width = Math.floor(viewport.width);
|
||||
canvas.height = Math.floor(viewport.height);
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) throw new Error("no 2d context");
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
await page.render({ canvasContext: context, viewport }).promise;
|
||||
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, "image/jpeg", 0.9));
|
||||
if (blob) {
|
||||
images.push(await prepareImageFromBlob(blob, `${file.name} p${pageNumber}`));
|
||||
}
|
||||
} catch {
|
||||
warnings.push(`Page ${pageNumber} looks scanned but could not be rendered.`);
|
||||
}
|
||||
} else {
|
||||
imagesTruncated = true;
|
||||
}
|
||||
|
||||
if (pageText.trim().length < SCANNED_PAGE_CHAR_THRESHOLD) {
|
||||
// Nothing to add to the text; the image carries the content.
|
||||
imagesTruncated = images.length >= MAX_DOCUMENT_IMAGES;
|
||||
}
|
||||
|
||||
page.cleanup?.();
|
||||
}
|
||||
|
||||
if (images.length > 0) {
|
||||
warnings.push(
|
||||
images.length === 1
|
||||
? "1 scanned page was turned into an image for the vision model."
|
||||
: `${images.length} scanned pages were turned into images for the vision model.`,
|
||||
);
|
||||
}
|
||||
|
||||
return withLimits(file.name, "pdf", pages.join("\n\n"), images, {
|
||||
pages: document.numPages,
|
||||
warnings,
|
||||
imagesTruncated,
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* DOCX */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
async function extractDocx(file: File): Promise<ExtractedDocument> {
|
||||
const mammoth = await import("mammoth");
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const warnings: string[] = [];
|
||||
const images: PreparedImage[] = [];
|
||||
|
||||
const textResult = await mammoth.extractRawText({ arrayBuffer });
|
||||
for (const message of textResult.messages.slice(0, 3)) {
|
||||
if (message.type === "warning") warnings.push(message.message);
|
||||
}
|
||||
|
||||
// Pull out embedded images (diagrams, pasted screenshots) for the vision step.
|
||||
try {
|
||||
const htmlResult = await mammoth.convertToHtml(
|
||||
{ arrayBuffer },
|
||||
{
|
||||
convertImage: mammoth.images.imgElement(async (image: { read: (encoding: string) => Promise<string>; contentType: string }) => {
|
||||
if (images.length >= MAX_DOCUMENT_IMAGES) return { src: "" };
|
||||
try {
|
||||
const base64 = await image.read("base64");
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
const blob = new Blob([bytes], { type: image.contentType || "image/png" });
|
||||
images.push(await prepareImageFromBlob(blob, `${file.name} image ${images.length + 1}`));
|
||||
} catch {
|
||||
/* skip unreadable images */
|
||||
}
|
||||
return { src: "" };
|
||||
}),
|
||||
},
|
||||
);
|
||||
void htmlResult;
|
||||
} catch {
|
||||
warnings.push("Embedded images could not be extracted (text was still read).");
|
||||
}
|
||||
|
||||
const imagesTruncated = images.length >= MAX_DOCUMENT_IMAGES;
|
||||
if (images.length > 0) {
|
||||
warnings.push(`${images.length} embedded image${images.length === 1 ? "" : "s"} sent to the vision model.`);
|
||||
}
|
||||
|
||||
return withLimits(file.name, "docx", textResult.value, images, { warnings, imagesTruncated });
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* PPTX */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const XML_TEXT_RUN = /<a:t[^>]*>([\s\S]*?)<\/a:t>/g;
|
||||
const XML_PARAGRAPH = /<\/a:p>/g;
|
||||
|
||||
function decodeXmlEntities(value: string): string {
|
||||
return value
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&#(\d+);/g, (_, code: string) => String.fromCodePoint(Number(code)))
|
||||
.replace(/&#x([0-9a-f]+);/gi, (_, code: string) => String.fromCodePoint(Number.parseInt(code, 16)))
|
||||
.replace(/&/g, "&");
|
||||
}
|
||||
|
||||
function textFromSlideXml(xml: string): string {
|
||||
const withBreaks = xml.replace(XML_PARAGRAPH, "\n");
|
||||
const parts: string[] = [];
|
||||
for (const match of withBreaks.matchAll(XML_TEXT_RUN)) {
|
||||
parts.push(decodeXmlEntities(match[1]));
|
||||
}
|
||||
return cleanText(parts.join(" "));
|
||||
}
|
||||
|
||||
async function extractOoxmlPackage(
|
||||
file: File,
|
||||
kind: "pptx" | "opendocument",
|
||||
): Promise<ExtractedDocument> {
|
||||
const JSZip = (await import("jszip")).default;
|
||||
const zip = await JSZip.loadAsync(await file.arrayBuffer());
|
||||
const warnings: string[] = [];
|
||||
const images: PreparedImage[] = [];
|
||||
const sections: string[] = [];
|
||||
|
||||
if (kind === "pptx") {
|
||||
const slideNames = Object.keys(zip.files)
|
||||
.filter((name) => /^ppt\/slides\/slide\d+\.xml$/.test(name))
|
||||
.sort((a, b) => {
|
||||
const na = Number(a.match(/(\d+)/)?.[1] ?? 0);
|
||||
const nb = Number(b.match(/(\d+)/)?.[1] ?? 0);
|
||||
return na - nb;
|
||||
});
|
||||
|
||||
for (const [index, name] of slideNames.entries()) {
|
||||
const xml = await zip.file(name)?.async("string");
|
||||
if (!xml) continue;
|
||||
const body = textFromSlideXml(xml);
|
||||
const notesName = `ppt/notesSlides/notesSlide${index + 1}.xml`;
|
||||
const notesXml = zip.file(notesName) ? await zip.file(notesName)?.async("string") : null;
|
||||
const notes = notesXml ? textFromSlideXml(notesXml) : "";
|
||||
const chunk = [`--- slide ${index + 1} ---`, body || "(no text on this slide)"];
|
||||
if (notes) chunk.push(`Speaker notes: ${notes}`);
|
||||
sections.push(chunk.join("\n"));
|
||||
}
|
||||
|
||||
const media = Object.keys(zip.files).filter(
|
||||
(name) => /^ppt\/media\/.+\.(png|jpe?g|gif|webp|bmp)$/i.test(name) && !name.includes("/_rels/"),
|
||||
);
|
||||
for (const name of media) {
|
||||
if (images.length >= MAX_DOCUMENT_IMAGES) break;
|
||||
try {
|
||||
const blob = await zip.file(name)?.async("blob");
|
||||
if (blob) images.push(await prepareImageFromBlob(blob, `${file.name} ${name.split("/").pop()}`));
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
}
|
||||
if (media.length > images.length) {
|
||||
warnings.push(`Only ${images.length} of ${media.length} slide images were kept.`);
|
||||
}
|
||||
} else {
|
||||
const content = await zip.file("content.xml")?.async("string");
|
||||
if (!content) throw new Error("This OpenDocument file has no readable content.");
|
||||
const text = decodeXmlEntities(
|
||||
content
|
||||
.replace(/<text:p[^>]*>/g, "\n")
|
||||
.replace(/<text:h[^>]*>/g, "\n")
|
||||
.replace(/<\/text:p>/g, "\n")
|
||||
.replace(/<\/text:h>/g, "\n")
|
||||
.replace(/<[^>]+>/g, ""),
|
||||
);
|
||||
sections.push(text);
|
||||
}
|
||||
|
||||
return withLimits(file.name, kind, sections.join("\n\n"), images, {
|
||||
pages: kind === "pptx" ? sections.length : undefined,
|
||||
warnings,
|
||||
imagesTruncated: images.length >= MAX_DOCUMENT_IMAGES,
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Plain text */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
async function extractPlainText(file: File): Promise<ExtractedDocument> {
|
||||
const text = await file.text();
|
||||
return withLimits(file.name, "text", text, [], {});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Dispatcher */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export async function extractDocument(
|
||||
file: File,
|
||||
onProgress?: (update: DocumentProgress) => void,
|
||||
): Promise<ExtractedDocument> {
|
||||
const kind = documentKindOf(file);
|
||||
if (!kind) throw new Error(`"${file.name}" is not a supported document.`);
|
||||
|
||||
switch (kind) {
|
||||
case "pdf":
|
||||
return extractPdf(file, onProgress);
|
||||
case "docx":
|
||||
onProgress?.({ message: "Reading the Word document…" });
|
||||
return extractDocx(file);
|
||||
case "pptx":
|
||||
onProgress?.({ message: "Reading the slides…" });
|
||||
return extractOoxmlPackage(file, "pptx");
|
||||
case "opendocument":
|
||||
onProgress?.({ message: "Reading the OpenDocument file…" });
|
||||
return extractOoxmlPackage(file, "opendocument");
|
||||
case "text":
|
||||
onProgress?.({ message: "Reading the text file…" });
|
||||
return extractPlainText(file);
|
||||
}
|
||||
}
|
||||
|
||||
/** Merge several documents into one text block with file headers. */
|
||||
export function documentsToText(documents: ExtractedDocument[]): string {
|
||||
return documents
|
||||
.filter((document) => document.text.trim().length > 0)
|
||||
.map((document) => `### ${document.name}\n${document.text.trim()}`)
|
||||
.join("\n\n")
|
||||
.slice(0, MAX_DOCUMENT_CHARS * 2);
|
||||
}
|
||||
153
lib/folders.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* Folder library, stored next to the decks in localStorage.
|
||||
*
|
||||
* Folders are pure organisation: deleting one never deletes decks, it just
|
||||
* moves them back to "Unorganized". The virtual folder `UNORGANIZED_ID` is
|
||||
* never persisted — it is simply every deck with no `folderId`.
|
||||
*/
|
||||
|
||||
import { folderSchema, UNORGANIZED_ID, type Folder } from "./schemas";
|
||||
import { uid } from "./utils";
|
||||
|
||||
const STORAGE_KEY = "freezmo.folders.v1";
|
||||
const EMPTY: Folder[] = [];
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
let folders: Folder[] | null = null;
|
||||
let cached: Folder[] = EMPTY;
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
function read(): Folder[] {
|
||||
if (typeof window === "undefined") return EMPTY;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return EMPTY;
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return EMPTY;
|
||||
const valid: Folder[] = [];
|
||||
for (const entry of parsed) {
|
||||
const result = folderSchema.safeParse(entry);
|
||||
if (result.success) valid.push(result.data);
|
||||
else console.warn("[freezmo] skipping invalid folder", result.error.issues);
|
||||
}
|
||||
return valid;
|
||||
} catch (error) {
|
||||
console.warn("[freezmo] could not read folders", error);
|
||||
return EMPTY;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureLoaded(): void {
|
||||
if (folders !== null) return;
|
||||
folders = read();
|
||||
cached = folders;
|
||||
}
|
||||
|
||||
function write(next: Folder[]): void {
|
||||
folders = next;
|
||||
cached = next;
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
} catch (error) {
|
||||
console.warn("[freezmo] could not save folders", error);
|
||||
}
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
export const folderStore = {
|
||||
subscribe(listener: Listener): () => void {
|
||||
ensureLoaded();
|
||||
listeners.add(listener);
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
if (event.key === STORAGE_KEY) {
|
||||
folders = null;
|
||||
ensureLoaded();
|
||||
listeners.forEach((l) => l());
|
||||
}
|
||||
};
|
||||
window.addEventListener("storage", onStorage);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
window.removeEventListener("storage", onStorage);
|
||||
};
|
||||
},
|
||||
getSnapshot(): Folder[] {
|
||||
ensureLoaded();
|
||||
return cached;
|
||||
},
|
||||
getServerSnapshot(): Folder[] {
|
||||
return EMPTY;
|
||||
},
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Queries */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function listFolders(): Folder[] {
|
||||
return [...folderStore.getSnapshot()].sort((a, b) =>
|
||||
a.name.localeCompare(b.name, undefined, { sensitivity: "base" }),
|
||||
);
|
||||
}
|
||||
|
||||
export function getFolder(id: string | undefined): Folder | undefined {
|
||||
if (!id || id === UNORGANIZED_ID) return undefined;
|
||||
return folderStore.getSnapshot().find((folder) => folder.id === id);
|
||||
}
|
||||
|
||||
export function folderName(id: string | undefined): string {
|
||||
if (!id || id === UNORGANIZED_ID) return "Unorganized";
|
||||
return getFolder(id)?.name ?? "Unorganized";
|
||||
}
|
||||
|
||||
export function findFolderByName(name: string): Folder | undefined {
|
||||
const wanted = name.trim().toLowerCase();
|
||||
return folderStore.getSnapshot().find((folder) => folder.name.trim().toLowerCase() === wanted);
|
||||
}
|
||||
|
||||
/** Case-insensitive uniqueness; returns the existing folder when it matches. */
|
||||
export function createFolder(name: string): { folder: Folder } | { error: string } {
|
||||
const trimmed = name.trim().replace(/\s+/g, " ");
|
||||
if (!trimmed) return { error: "Give the folder a name." };
|
||||
if (trimmed.length > 60) return { error: "That name is a bit long (60 characters max)." };
|
||||
|
||||
const existing = findFolderByName(trimmed);
|
||||
if (existing) return { folder: existing };
|
||||
|
||||
const folder: Folder = { id: uid("fold"), name: trimmed, createdAt: Date.now() };
|
||||
ensureLoaded();
|
||||
write([...folderStore.getSnapshot(), folder]);
|
||||
return { folder };
|
||||
}
|
||||
|
||||
export function renameFolder(id: string, name: string): { ok: true } | { error: string } {
|
||||
const trimmed = name.trim().replace(/\s+/g, " ");
|
||||
if (!trimmed) return { error: "Give the folder a name." };
|
||||
const clash = folderStore
|
||||
.getSnapshot()
|
||||
.find((folder) => folder.id !== id && folder.name.trim().toLowerCase() === trimmed.toLowerCase());
|
||||
if (clash) return { error: "Another folder already has that name." };
|
||||
|
||||
ensureLoaded();
|
||||
write(folderStore.getSnapshot().map((folder) => (folder.id === id ? { ...folder, name: trimmed } : folder)));
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** Removes the folder. Decks inside it are handed back to the caller to re-home. */
|
||||
export function removeFolder(id: string): void {
|
||||
ensureLoaded();
|
||||
write(folderStore.getSnapshot().filter((folder) => folder.id !== id));
|
||||
}
|
||||
|
||||
/** Deleting a folder must never delete decks: callers pair this with unfileDecks(). */
|
||||
export function hasFolder(id: string): boolean {
|
||||
return Boolean(getFolder(id));
|
||||
}
|
||||
|
||||
export function emptyFolder(): void {
|
||||
ensureLoaded();
|
||||
write([]);
|
||||
}
|
||||
79
lib/http-guard.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
/**
|
||||
* Small server-side guards shared by the API routes.
|
||||
*
|
||||
* Freezmo is a self-hosted app with no accounts, so the realistic threats are
|
||||
* (a) a hostile page in the user's browser poking the local server, (b) huge
|
||||
* bodies exhausting memory, and (c) odd values ending up in outbound headers.
|
||||
*/
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(bytes < 10 * 1024 * 1024 ? 1 : 0)} MB`;
|
||||
}
|
||||
|
||||
/** Rejects requests whose body is larger than `maxBytes` (Content-Length check). */
|
||||
export function rejectOversizedBody(request: Request, maxBytes: number): Response | null {
|
||||
const header = request.headers.get("content-length");
|
||||
if (!header) return null;
|
||||
const length = Number.parseInt(header, 10);
|
||||
if (Number.isFinite(length) && length > maxBytes) {
|
||||
return Response.json(
|
||||
{
|
||||
error: `Request body is too large (${formatBytes(length)}). Limit is ${formatBytes(maxBytes)}.`,
|
||||
kind: "too_large",
|
||||
},
|
||||
{ status: 413 },
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same-origin check for state-changing requests.
|
||||
*
|
||||
* There is no cookie-based auth here, so CSRF can't steal a session — but a
|
||||
* malicious website could still make the user's browser fire requests at a
|
||||
* LAN-hosted Freezmo (and burn their rate limit or, in `server` auth mode,
|
||||
* their credits). Browsers always send `Origin` on cross-origin POSTs and
|
||||
* cannot forge it, so a mismatch is a reliable signal. Requests without an
|
||||
* `Origin` (curl, scripts, native apps) are allowed.
|
||||
*/
|
||||
export function rejectCrossOrigin(request: Request): Response | null {
|
||||
const origin = request.headers.get("origin");
|
||||
if (!origin) return null;
|
||||
|
||||
let originHost: string;
|
||||
try {
|
||||
originHost = new URL(origin).host;
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid Origin header.", kind: "forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const host = request.headers.get("host");
|
||||
if (host && originHost === host) return null;
|
||||
|
||||
// Next dev/prod behind a proxy may rewrite Host; trust x-forwarded-host too.
|
||||
const forwardedHost = request.headers.get("x-forwarded-host");
|
||||
if (forwardedHost && originHost === forwardedHost) return null;
|
||||
|
||||
// Allow the loopback pair (localhost vs 127.0.0.1) for convenience.
|
||||
const loopback = new Set(["localhost", "127.0.0.1", "[::1]", "0.0.0.0"]);
|
||||
const bare = (value: string) => value.replace(/:\d+$/, "");
|
||||
if (loopback.has(bare(originHost)) && loopback.has(bare(host ?? ""))) return null;
|
||||
|
||||
return Response.json(
|
||||
{ error: "Cross-origin requests are not allowed.", kind: "forbidden" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* API keys are pasted by humans, so clean them before they go into a header:
|
||||
* trim, drop control characters/whitespace (header injection), cap the length.
|
||||
*/
|
||||
export function sanitiseApiKey(value: string | null | undefined): string {
|
||||
if (!value) return "";
|
||||
return value
|
||||
.replace(/[\u0000-\u001F\u007F-\u009F\s]/g, "")
|
||||
.slice(0, 512);
|
||||
}
|
||||
131
lib/image.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
"use client";
|
||||
|
||||
/** Client-side image helpers: validation, resizing and compression. */
|
||||
|
||||
export const MAX_IMAGE_DIMENSION = 1600;
|
||||
export const JPEG_QUALITY = 0.82;
|
||||
export const MAX_FILE_BYTES = 20 * 1024 * 1024; // 20 MB per source file
|
||||
export const MAX_IMAGES = 8;
|
||||
export const ACCEPTED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif", "image/heic", "image/heif"];
|
||||
|
||||
export type PreparedImage = {
|
||||
id: string;
|
||||
dataUrl: string;
|
||||
name: string;
|
||||
width: number;
|
||||
height: number;
|
||||
bytes: number;
|
||||
};
|
||||
|
||||
export function isImageFile(file: File): boolean {
|
||||
if (file.type.startsWith("image/")) return true;
|
||||
return /\.(jpe?g|png|webp|gif|heic|heif|bmp)$/i.test(file.name);
|
||||
}
|
||||
|
||||
export function describeFileError(file: File): string | null {
|
||||
if (!isImageFile(file)) return `"${file.name}" is not an image.`;
|
||||
if (file.size > MAX_FILE_BYTES) return `"${file.name}" is larger than 20 MB.`;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Rough byte length of a base64 data URL. */
|
||||
export function dataUrlBytes(dataUrl: string): number {
|
||||
const index = dataUrl.indexOf(",");
|
||||
if (index === -1) return dataUrl.length;
|
||||
return Math.floor(((dataUrl.length - index - 1) * 3) / 4);
|
||||
}
|
||||
|
||||
async function loadBitmap(blob: Blob, fallbackName: string): Promise<{ width: number; height: number; draw: (ctx: CanvasRenderingContext2D, w: number, h: number) => void }> {
|
||||
// Prefer createImageBitmap: it respects EXIF orientation and is fast.
|
||||
if (typeof createImageBitmap === "function") {
|
||||
try {
|
||||
const bitmap = await createImageBitmap(blob, { imageOrientation: "from-image" });
|
||||
return {
|
||||
width: bitmap.width,
|
||||
height: bitmap.height,
|
||||
draw: (ctx, w, h) => ctx.drawImage(bitmap, 0, 0, w, h),
|
||||
};
|
||||
} catch {
|
||||
/* fall through to the <img> path (Safari/HEIC edge cases) */
|
||||
}
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
try {
|
||||
const image = await new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const element = new Image();
|
||||
element.onload = () => resolve(element);
|
||||
element.onerror = () => reject(new Error(`Could not read "${fallbackName}".`));
|
||||
element.src = url;
|
||||
});
|
||||
return {
|
||||
width: image.naturalWidth || image.width,
|
||||
height: image.naturalHeight || image.height,
|
||||
draw: (ctx, w, h) => ctx.drawImage(image, 0, 0, w, h),
|
||||
};
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize to max 1600px and re-encode as JPEG to keep uploads small.
|
||||
* Accepts any image blob (photos, canvas output from PDF pages, images
|
||||
* embedded in documents).
|
||||
*/
|
||||
export async function prepareImageFromBlob(blob: Blob, name = "image.jpg"): Promise<PreparedImage> {
|
||||
const bitmap = await loadBitmap(blob, name);
|
||||
const scale = Math.min(1, MAX_IMAGE_DIMENSION / Math.max(bitmap.width, bitmap.height));
|
||||
const width = Math.max(1, Math.round(bitmap.width * scale));
|
||||
const height = Math.max(1, Math.round(bitmap.height * scale));
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("Your browser could not process this image.");
|
||||
ctx.fillStyle = "#ffffff"; // flatten transparency before JPEG encoding
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
bitmap.draw(ctx, width, height);
|
||||
|
||||
const dataUrl = canvas.toDataURL("image/jpeg", JPEG_QUALITY);
|
||||
|
||||
return {
|
||||
id: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
dataUrl,
|
||||
name,
|
||||
width,
|
||||
height,
|
||||
bytes: dataUrlBytes(dataUrl),
|
||||
};
|
||||
}
|
||||
|
||||
/** Resize to max 1600px and re-encode as JPEG to keep uploads small. */
|
||||
export async function prepareImage(file: File): Promise<PreparedImage> {
|
||||
return prepareImageFromBlob(file, file.name || "photo.jpg");
|
||||
}
|
||||
|
||||
/** Extract image files from a paste or drop event. */
|
||||
export function imageFilesFromDataTransfer(data: DataTransfer | null): File[] {
|
||||
if (!data) return [];
|
||||
const files: File[] = [];
|
||||
if (data.files?.length) {
|
||||
for (const file of Array.from(data.files)) if (isImageFile(file)) files.push(file);
|
||||
}
|
||||
if (data.items?.length) {
|
||||
for (const item of Array.from(data.items)) {
|
||||
if (item.kind !== "file") continue;
|
||||
const file = item.getAsFile();
|
||||
if (file && isImageFile(file) && !files.some((f) => f.name === file.name && f.size === file.size)) {
|
||||
files.push(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
174
lib/language.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
/**
|
||||
* Cheap, dependency-free language checks used to protect the notes' language.
|
||||
*
|
||||
* The vision model is instructed never to translate, but a small model can
|
||||
* still slip into English on hard handwriting. When that happens we re-run the
|
||||
* transcription through the (much stronger, multilingual) text model.
|
||||
*
|
||||
* These helpers are heuristic on purpose: they are only used to decide whether
|
||||
* one extra repair call is worth it, and a false positive costs a single call
|
||||
* while a false negative would hand the student an English deck.
|
||||
*/
|
||||
|
||||
export type Script =
|
||||
| "latin"
|
||||
| "cyrillic"
|
||||
| "greek"
|
||||
| "han"
|
||||
| "kana"
|
||||
| "hangul"
|
||||
| "arabic"
|
||||
| "hebrew"
|
||||
| "devanagari"
|
||||
| "thai"
|
||||
| "unknown";
|
||||
|
||||
const SCRIPT_RANGES: Array<{ script: Script; test: RegExp }> = [
|
||||
{ script: "cyrillic", test: /[\u0400-\u04FF]/ },
|
||||
{ script: "greek", test: /[\u0370-\u03FF\u1F00-\u1FFF]/ },
|
||||
{ script: "hangul", test: /[\uAC00-\uD7AF\u1100-\u11FF]/ },
|
||||
{ script: "kana", test: /[\u3040-\u30FF]/ },
|
||||
{ script: "han", test: /[\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF]/ },
|
||||
{ script: "arabic", test: /[\u0600-\u06FF\u0750-\u077F]/ },
|
||||
{ script: "hebrew", test: /[\u0590-\u05FF]/ },
|
||||
{ script: "devanagari", test: /[\u0900-\u097F]/ },
|
||||
{ script: "thai", test: /[\u0E00-\u0E7F]/ },
|
||||
{ script: "latin", test: /[A-Za-z\u00C0-\u024F]/ },
|
||||
];
|
||||
|
||||
/** Language names/codes we know, mapped to the script they are written in. */
|
||||
const LANGUAGE_SCRIPTS: Record<string, Script> = {
|
||||
english: "latin", en: "latin",
|
||||
czech: "latin", cs: "latin",
|
||||
slovak: "latin", sk: "latin",
|
||||
polish: "latin", pl: "latin",
|
||||
german: "latin", de: "latin",
|
||||
french: "latin", fr: "latin",
|
||||
spanish: "latin", es: "latin",
|
||||
italian: "latin", it: "latin",
|
||||
portuguese: "latin", pt: "latin",
|
||||
dutch: "latin", nl: "latin",
|
||||
swedish: "latin", sv: "latin",
|
||||
norwegian: "latin", no: "latin",
|
||||
danish: "latin", da: "latin",
|
||||
finnish: "latin", fi: "latin",
|
||||
hungarian: "latin", hu: "latin",
|
||||
romanian: "latin", ro: "latin",
|
||||
turkish: "latin", tr: "latin",
|
||||
croatian: "latin", hr: "latin",
|
||||
slovenian: "latin", sl: "latin",
|
||||
serbian: "cyrillic", sr: "cyrillic",
|
||||
russian: "cyrillic", ru: "cyrillic",
|
||||
ukrainian: "cyrillic", uk: "cyrillic",
|
||||
bulgarian: "cyrillic", bg: "cyrillic",
|
||||
belarusian: "cyrillic", be: "cyrillic",
|
||||
macedonian: "cyrillic", mk: "cyrillic",
|
||||
greek: "greek", el: "greek",
|
||||
chinese: "han", mandarin: "han", cantonese: "han", zh: "han",
|
||||
japanese: "kana", ja: "kana",
|
||||
korean: "hangul", ko: "hangul",
|
||||
arabic: "arabic", ar: "arabic",
|
||||
persian: "arabic", farsi: "arabic", urdu: "arabic",
|
||||
hebrew: "hebrew", he: "hebrew",
|
||||
hindi: "devanagari", marathi: "devanagari", nepali: "devanagari",
|
||||
thai: "thai",
|
||||
};
|
||||
|
||||
/** Characters that practically always appear in real text of these languages. */
|
||||
const DISTINCTIVE_CHARS: Record<string, RegExp> = {
|
||||
czech: /[ěščřžýáíéúůťďňĚŠČŘŽÝÁÍÉÚŮŤĎŇ]/,
|
||||
slovak: /[ľščťžýáíéúôäňĺ༊ȍŽÝÁÍÉ]/,
|
||||
polish: /[ąćęłńóśźżĄĆĘŁŃÓŚŹŻ]/,
|
||||
hungarian: /[őűáéíóöüŐŰÁÉÍÓÖÜ]/,
|
||||
romanian: /[ăâîșțĂÂÎȘȚ]/,
|
||||
turkish: /[ğışöüçĞİŞÖÜÇ]/,
|
||||
croatian: /[čćđšžČĆĐŠŽ]/,
|
||||
slovenian: /[蚞ȊŽ]/,
|
||||
german: /[äöüßÄÖÜ]/,
|
||||
french: /[éèêëàâçîïôûùüÿœæÉÈÊËÀÂÇÎÏÔÛÙŸŒÆ]/,
|
||||
spanish: /[áéíóúñü¡¿ÁÉÍÓÚÑÜ]/,
|
||||
portuguese: /[áâãàçéêíóôõúüÁÂÃÀÇÉÊÍÓÔÕÚ]/,
|
||||
italian: /[àèéìòùÀÈÉÌÒÙ]/,
|
||||
swedish: /[åäöÅÄÖ]/,
|
||||
norwegian: /[æøåÆØÅ]/,
|
||||
danish: /[æøåÆØÅ]/,
|
||||
finnish: /[äöåÄÖÅ]/,
|
||||
dutch: /[ëïéèöüËÏÉÈÖÜ]/,
|
||||
vietnamese: /[ăâđêôơưàảãáạằẳẵắặầẩẫấậèẻẽéẹềểễếệ]/,
|
||||
latvian: /[āčēģīķļņšūž]/,
|
||||
lithuanian: /[ąčęėįšųūž]/,
|
||||
estonian: /[õäöüšžÕÄÖÜŠŽ]/,
|
||||
icelandic: /[áéíóúýþæöðÁÉÍÓÚÝÞÆÖÐ]/,
|
||||
};
|
||||
|
||||
const ENGLISH_STOPWORDS =
|
||||
/\b(the|and|that|with|from|this|which|are|was|were|have|has|for|not|but|you|your|they|their|into|than|then)\b/gi;
|
||||
|
||||
/** Normalise "Czech", "cs-CZ", "česky" to a lookup key. */
|
||||
function languageKey(language: string | undefined): string {
|
||||
if (!language) return "";
|
||||
return language.trim().toLowerCase().replace(/[_.]/g, "-").split("-")[0].replace(/[^a-z]/g, "");
|
||||
}
|
||||
|
||||
export function expectedScript(language: string | undefined): Script | null {
|
||||
const key = languageKey(language);
|
||||
if (!key) return null;
|
||||
return LANGUAGE_SCRIPTS[key] ?? null;
|
||||
}
|
||||
|
||||
/** The script that most non-whitespace characters belong to. */
|
||||
export function dominantScript(text: string): Script {
|
||||
const counts = new Map<Script, number>();
|
||||
for (const { script, test } of SCRIPT_RANGES) {
|
||||
const matches = text.match(new RegExp(test.source, "g"));
|
||||
if (matches) counts.set(script, matches.length);
|
||||
}
|
||||
let best: Script = "unknown";
|
||||
let bestCount = 0;
|
||||
for (const [script, count] of counts) {
|
||||
if (count > bestCount) {
|
||||
best = script;
|
||||
bestCount = count;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function looksEnglish(text: string, words: number): boolean {
|
||||
const hits = (text.match(ENGLISH_STOPWORDS) ?? []).length;
|
||||
return words >= 40 && hits >= Math.max(4, Math.round(words / 30));
|
||||
}
|
||||
|
||||
export type LanguageCheck = { suspicious: boolean; reason?: string };
|
||||
|
||||
/**
|
||||
* Decides whether the transcription probably got translated away from the
|
||||
* language the vision model reported.
|
||||
*/
|
||||
export function needsLanguageRepair(input: { language?: string; text: string }): LanguageCheck {
|
||||
const { language, text } = input;
|
||||
const key = languageKey(language);
|
||||
const words = text.split(/\s+/).filter(Boolean).length;
|
||||
if (words < 40) return { suspicious: false }; // too short to judge reliably
|
||||
if (key === "english" || key === "en") return { suspicious: false };
|
||||
|
||||
const expected = expectedScript(language);
|
||||
const actual = dominantScript(text);
|
||||
|
||||
if (expected && expected !== "unknown" && actual !== "unknown" && expected !== actual) {
|
||||
return {
|
||||
suspicious: true,
|
||||
reason: `notes were declared as ${language} (${expected}) but are written in ${actual}`,
|
||||
};
|
||||
}
|
||||
|
||||
const distinctive = DISTINCTIVE_CHARS[key];
|
||||
if (expected === "latin" && distinctive && !distinctive.test(text) && looksEnglish(text, words)) {
|
||||
return {
|
||||
suspicious: true,
|
||||
reason: `notes were declared as ${language} but read like English`,
|
||||
};
|
||||
}
|
||||
|
||||
return { suspicious: false };
|
||||
}
|
||||
72
lib/nav.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* Folder selection lives outside React so the sidebar (desktop) and the folder
|
||||
* pills (mobile) can drive the same view.
|
||||
*/
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
export const ALL_FOLDERS = "all";
|
||||
|
||||
let selected = ALL_FOLDERS;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
export const folderSelection = {
|
||||
subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
getSnapshot(): string {
|
||||
return selected;
|
||||
},
|
||||
getServerSnapshot(): string {
|
||||
return ALL_FOLDERS;
|
||||
},
|
||||
set(id: string): void {
|
||||
if (selected === id) return;
|
||||
selected = id;
|
||||
listeners.forEach((listener) => listener());
|
||||
},
|
||||
};
|
||||
|
||||
/* Mobile drawer ---------------------------------------------------------- */
|
||||
|
||||
let drawerOpen = false;
|
||||
const drawerListeners = new Set<() => void>();
|
||||
|
||||
export const sidebarOpen = {
|
||||
subscribe(listener: () => void): () => void {
|
||||
drawerListeners.add(listener);
|
||||
return () => drawerListeners.delete(listener);
|
||||
},
|
||||
getSnapshot(): boolean {
|
||||
return drawerOpen;
|
||||
},
|
||||
getServerSnapshot(): boolean {
|
||||
return false;
|
||||
},
|
||||
set(value: boolean): void {
|
||||
drawerOpen = value;
|
||||
drawerListeners.forEach((listener) => listener());
|
||||
},
|
||||
toggle(): void {
|
||||
sidebarOpen.set(!drawerOpen);
|
||||
},
|
||||
};
|
||||
|
||||
export function useSidebarOpen(): boolean {
|
||||
return useSyncExternalStore(
|
||||
sidebarOpen.subscribe,
|
||||
sidebarOpen.getSnapshot,
|
||||
sidebarOpen.getServerSnapshot,
|
||||
);
|
||||
}
|
||||
|
||||
export function useSelectedFolder(): string {
|
||||
return useSyncExternalStore(
|
||||
folderSelection.subscribe,
|
||||
folderSelection.getSnapshot,
|
||||
folderSelection.getServerSnapshot,
|
||||
);
|
||||
}
|
||||
327
lib/ollama.ts
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
/**
|
||||
* Minimal server-side Ollama HTTP client.
|
||||
*
|
||||
* Works with both supported setups:
|
||||
* 1. a local daemon (http://localhost:11434) that proxies :cloud models
|
||||
* after `ollama signin`
|
||||
* 2. the direct cloud API (https://ollama.com) with a Bearer API key
|
||||
*
|
||||
* This module must only ever be imported from server code (route handlers).
|
||||
*/
|
||||
|
||||
import { getServerConfig, resolveModelName, type OllamaAuth } from "./config";
|
||||
|
||||
export type OllamaMessage = {
|
||||
role: "system" | "user" | "assistant";
|
||||
content: string;
|
||||
/** Base64 strings (no data: prefix) or full data URLs; Ollama accepts both. */
|
||||
images?: string[];
|
||||
};
|
||||
|
||||
export type OllamaErrorKind =
|
||||
| "unreachable"
|
||||
| "timeout"
|
||||
| "auth"
|
||||
| "no_credits"
|
||||
| "model_missing"
|
||||
| "http"
|
||||
| "bad_response";
|
||||
|
||||
export class OllamaError extends Error {
|
||||
kind: OllamaErrorKind;
|
||||
status?: number;
|
||||
detail?: string;
|
||||
|
||||
constructor(kind: OllamaErrorKind, message: string, options?: { status?: number; detail?: string }) {
|
||||
super(message);
|
||||
this.name = "OllamaError";
|
||||
this.kind = kind;
|
||||
this.status = options?.status;
|
||||
this.detail = options?.detail;
|
||||
}
|
||||
}
|
||||
|
||||
export function friendlyErrorMessage(error: unknown, baseUrl?: string): string {
|
||||
if (error instanceof OllamaError) {
|
||||
switch (error.kind) {
|
||||
case "unreachable":
|
||||
return `Ollama isn't reachable at ${baseUrl ?? "the configured URL"}. Check your settings and make sure the daemon is running.`;
|
||||
case "timeout":
|
||||
return "Ollama took too long to answer. Try fewer/smaller images, a faster model, or retry.";
|
||||
case "auth":
|
||||
return "Ollama rejected the request. If you are using https://ollama.com, check OLLAMA_API_KEY (create one at ollama.com/settings/keys).";
|
||||
case "no_credits":
|
||||
return error.message;
|
||||
case "model_missing":
|
||||
return error.message;
|
||||
default:
|
||||
return error.message || "Ollama returned an unexpected response.";
|
||||
}
|
||||
}
|
||||
if (error instanceof Error) return error.message;
|
||||
return "Something went wrong while talking to Ollama.";
|
||||
}
|
||||
|
||||
type ChatOptions = {
|
||||
model: string;
|
||||
messages: OllamaMessage[];
|
||||
/** Credentials for this request (browser key, server key or local daemon). */
|
||||
auth: OllamaAuth;
|
||||
/** Ask Ollama to constrain the output to valid JSON. */
|
||||
json?: boolean;
|
||||
signal?: AbortSignal;
|
||||
timeoutMs?: number;
|
||||
temperature?: number;
|
||||
};
|
||||
|
||||
export type ChatResult = {
|
||||
content: string;
|
||||
model: string;
|
||||
raw: unknown;
|
||||
};
|
||||
|
||||
async function fetchWithTimeout(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
timeoutMs: number,
|
||||
external?: AbortSignal,
|
||||
): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const onAbort = () => controller.abort();
|
||||
external?.addEventListener("abort", onAbort);
|
||||
|
||||
try {
|
||||
return await fetch(url, { ...init, signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
external?.removeEventListener("abort", onAbort);
|
||||
}
|
||||
}
|
||||
|
||||
export async function ollamaChat(options: ChatOptions): Promise<ChatResult> {
|
||||
const config = getServerConfig();
|
||||
const { baseUrl, apiKey, directCloud } = options.auth;
|
||||
const model = resolveModelName(options.model, directCloud);
|
||||
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
model,
|
||||
messages: options.messages,
|
||||
stream: false,
|
||||
};
|
||||
if (options.json) body.format = "json";
|
||||
if (typeof options.temperature === "number") {
|
||||
body.options = { temperature: options.temperature };
|
||||
}
|
||||
if (config.think !== null) body.think = config.think;
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetchWithTimeout(
|
||||
`${baseUrl}/api/chat`,
|
||||
{ method: "POST", headers, body: JSON.stringify(body), cache: "no-store" },
|
||||
options.timeoutMs ?? config.timeoutMs,
|
||||
options.signal,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
throw new OllamaError("timeout", "The request to Ollama timed out.");
|
||||
}
|
||||
throw new OllamaError(
|
||||
"unreachable",
|
||||
`Could not connect to Ollama at ${baseUrl}.`,
|
||||
{ detail: error instanceof Error ? error.message : String(error) },
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => "");
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new OllamaError("auth", "Ollama refused the request (unauthorised).", {
|
||||
status: response.status,
|
||||
detail: text.slice(0, 500),
|
||||
});
|
||||
}
|
||||
if (response.status === 402) {
|
||||
throw new OllamaError(
|
||||
"no_credits",
|
||||
`Your Ollama account can't use "${model}": this model is not included in your plan. Add usage credits at ollama.com/settings, or switch to a free-tier model in .env.local.`,
|
||||
{ status: response.status, detail: text.slice(0, 500) },
|
||||
);
|
||||
}
|
||||
if (response.status === 404) {
|
||||
throw new OllamaError(
|
||||
"model_missing",
|
||||
`Ollama could not find the model "${model}". Run \`ollama pull ${model}\` or pick another model in .env.local.`,
|
||||
{ status: response.status, detail: text.slice(0, 500) },
|
||||
);
|
||||
}
|
||||
throw new OllamaError(
|
||||
"http",
|
||||
`Ollama responded with HTTP ${response.status}.`,
|
||||
{ status: response.status, detail: text.slice(0, 500) },
|
||||
);
|
||||
}
|
||||
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
throw new OllamaError("bad_response", "Ollama sent a response that wasn't JSON.");
|
||||
}
|
||||
|
||||
const content = extractContent(payload);
|
||||
if (content === null) {
|
||||
throw new OllamaError("bad_response", "Ollama sent a response with no message content.", {
|
||||
detail: JSON.stringify(payload).slice(0, 500),
|
||||
});
|
||||
}
|
||||
|
||||
return { content, model, raw: payload };
|
||||
}
|
||||
|
||||
function extractContent(payload: unknown): string | null {
|
||||
if (!payload || typeof payload !== "object") return null;
|
||||
const record = payload as Record<string, unknown>;
|
||||
const message = record.message;
|
||||
if (message && typeof message === "object") {
|
||||
const content = (message as Record<string, unknown>).content;
|
||||
if (typeof content === "string") return content;
|
||||
}
|
||||
if (typeof record.response === "string") return record.response;
|
||||
return null;
|
||||
}
|
||||
|
||||
export type OllamaModelInfo = { name: string; model: string; size?: number };
|
||||
|
||||
/** List the models known to the given Ollama instance (or the cloud). */
|
||||
export async function ollamaListModels(auth: OllamaAuth, timeoutMs?: number): Promise<OllamaModelInfo[]> {
|
||||
const config = getServerConfig();
|
||||
const { baseUrl, apiKey } = auth;
|
||||
const headers: Record<string, string> = {};
|
||||
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetchWithTimeout(
|
||||
`${baseUrl}/api/tags`,
|
||||
{ headers, cache: "no-store" },
|
||||
timeoutMs ?? config.healthTimeoutMs,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
throw new OllamaError("timeout", "Timed out while listing Ollama models.");
|
||||
}
|
||||
throw new OllamaError("unreachable", `Could not connect to Ollama at ${baseUrl}.`, {
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new OllamaError("auth", "Ollama refused the request (unauthorised).", {
|
||||
status: response.status,
|
||||
});
|
||||
}
|
||||
throw new OllamaError("http", `Ollama responded with HTTP ${response.status} while listing models.`, {
|
||||
status: response.status,
|
||||
});
|
||||
}
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as
|
||||
| { models?: Array<{ name?: string; model?: string; size?: number }> }
|
||||
| null;
|
||||
|
||||
return (payload?.models ?? []).map((entry) => ({
|
||||
name: entry.name ?? entry.model ?? "unknown",
|
||||
model: entry.model ?? entry.name ?? "unknown",
|
||||
size: entry.size,
|
||||
}));
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* API key verification */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export type KeyProbe = {
|
||||
/** true = accepted, false = rejected, null = could not tell. */
|
||||
valid: boolean | null;
|
||||
reason?: "invalid" | "no_credits" | "model_missing" | "unreachable";
|
||||
detail?: string;
|
||||
};
|
||||
|
||||
type CachedProbe = KeyProbe & { at: number };
|
||||
|
||||
const probeCacheStore = globalThis as unknown as { __freezmoKeyProbes?: Map<string, CachedProbe> };
|
||||
const probeCache: Map<string, CachedProbe> = (probeCacheStore.__freezmoKeyProbes ??= new Map());
|
||||
const PROBE_TTL_MS = 5 * 60_000;
|
||||
|
||||
/**
|
||||
* Model listing is public on ollama.com, so the only trustworthy way to check
|
||||
* a key is a real (tiny) chat request: 1 output token, cached for a few minutes.
|
||||
* Interpreted statuses:
|
||||
* 200 key works
|
||||
* 401 / 403 key rejected
|
||||
* 402 key works but the plan has no access to that model
|
||||
* 404 model unknown — the key isn't the problem
|
||||
*/
|
||||
export async function ollamaVerifyKey(auth: OllamaAuth, model: string, timeoutMs = 20_000): Promise<KeyProbe> {
|
||||
if (!auth.apiKey) return { valid: null };
|
||||
if (process.env.OLLAMA_VERIFY_KEY?.trim().toLowerCase() === "false") return { valid: null };
|
||||
|
||||
const cacheKey = `${auth.baseUrl}|${model}|${hashKey(auth.apiKey)}|${auth.authMode}`;
|
||||
const cached = probeCache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.at < PROBE_TTL_MS) {
|
||||
return { valid: cached.valid, reason: cached.reason, detail: cached.detail };
|
||||
}
|
||||
|
||||
const resolved = resolveModelName(model, auth.directCloud);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetchWithTimeout(
|
||||
`${auth.baseUrl}/api/chat`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${auth.apiKey}`,
|
||||
},
|
||||
cache: "no-store",
|
||||
body: JSON.stringify({
|
||||
model: resolved,
|
||||
messages: [{ role: "user", content: "ping" }],
|
||||
stream: false,
|
||||
options: { num_predict: 1 },
|
||||
}),
|
||||
},
|
||||
timeoutMs,
|
||||
);
|
||||
} catch {
|
||||
return { valid: null, reason: "unreachable" };
|
||||
}
|
||||
|
||||
const detail = (await response.text().catch(() => "")).slice(0, 300);
|
||||
let probe: KeyProbe;
|
||||
if (response.ok) probe = { valid: true };
|
||||
else if (response.status === 401 || response.status === 403) probe = { valid: false, reason: "invalid", detail };
|
||||
else if (response.status === 402) probe = { valid: true, reason: "no_credits", detail };
|
||||
else if (response.status === 404) probe = { valid: null, reason: "model_missing", detail };
|
||||
else probe = { valid: null, detail: `HTTP ${response.status}` };
|
||||
|
||||
probeCache.set(cacheKey, { ...probe, at: Date.now() });
|
||||
return probe;
|
||||
}
|
||||
|
||||
/** The key itself is never kept — only a hash, so the cache can't leak it. */
|
||||
function hashKey(key: string): string {
|
||||
// Small non-cryptographic hash is enough to bucket cache entries.
|
||||
let hash = 2166136261;
|
||||
for (let i = 0; i < key.length; i++) {
|
||||
hash ^= key.charCodeAt(i);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return (hash >>> 0).toString(36);
|
||||
}
|
||||
212
lib/prompts.ts
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
/**
|
||||
* Every prompt used by Freezmo lives here so they are easy to review and tune.
|
||||
*
|
||||
* Golden rules baked into the prompts:
|
||||
* - only use information found in the student's own notes
|
||||
* - keep everything in the ORIGINAL LANGUAGE of the notes (never translate)
|
||||
* - never invent facts, definitions, formulas or dates
|
||||
* - vary the activity types
|
||||
* - write a short explanation for every activity
|
||||
* - output JSON only, matching the documented shape
|
||||
*/
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Shared language rules */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const LANGUAGE_RULES = `LANGUAGE RULES (critical):
|
||||
- Detect the language the notes are written in.
|
||||
- Write EVERYTHING you produce in that SAME language: titles, summaries, transcriptions, questions, options, statements, definitions, reference answers, key points, explanations and feedback.
|
||||
- Never translate. A Czech deck stays Czech, a German deck stays German, a mixed deck stays mixed.
|
||||
- Keep diacritics and national characters exactly as they are (e.g. ěščřžýáíé, ü ö ä ß, ą ę ł, á é í ó ú).
|
||||
- Technical terms that are normally kept in English (e.g. "ATP", "machine learning", "Krebs cycle" in a Czech text) stay as the notes wrote them.
|
||||
- If the notes are mostly in one language with a few foreign terms, use the dominant language.
|
||||
- Numerals, units, dates and formulas are language neutral: copy them unchanged.`;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Step 1 - vision / transcription */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export const VISION_SYSTEM_PROMPT = `You are Freezmo's study-note transcriber. You receive photos or scans of a student's own study notes (notebooks, slides, whiteboards, printed handouts) and sometimes the text of a document (PDF, Word, PowerPoint).
|
||||
|
||||
Your job:
|
||||
1. Read ALL text in the images, including handwriting, diagrams labels, tables, formulas and margin notes.
|
||||
2. If several images are given, treat them as one set of notes. Do not duplicate content and do not merge unrelated facts.
|
||||
3. Transcribe the material faithfully into clean, structured plain text. Use short headings, bullet lines, and "term: definition" pairs where the notes do that.
|
||||
4. Preserve exact numbers, units, names, dates and formulas. If a symbol is ambiguous, transcribe your best reading and mark it with "(?)".
|
||||
5. Extract the key concepts, definitions, formulas and standalone facts that are good candidates for study activities.
|
||||
|
||||
${LANGUAGE_RULES}
|
||||
|
||||
Hard rules:
|
||||
- ONLY use information that is actually visible in the images or the text the student provided. Never add outside knowledge, never "correct" the notes with facts of your own, never invent a definition.
|
||||
- If an image is unreadable or contains no study material, say so in the summary and leave the arrays empty.
|
||||
- Do not include the student's personal data, doodles or irrelevant text.
|
||||
- Output JSON only. No markdown fences, no commentary before or after.
|
||||
|
||||
Required JSON shape:
|
||||
{
|
||||
"title": "short descriptive deck title, max 10 words, in the notes' language",
|
||||
"subject": "one or two words, e.g. Biology, Calculus, History",
|
||||
"language": "English name of the language the notes are written in, e.g. Czech",
|
||||
"summary": "one or two sentences describing what these notes cover, in the notes' language",
|
||||
"notes": "the full clean transcription as plain text, using newlines and '-' bullets",
|
||||
"keyConcepts": ["concept 1", "concept 2"],
|
||||
"definitions": [{ "term": "term", "definition": "definition exactly as the notes state it" }],
|
||||
"formulas": [{ "name": "what it is for", "formula": "the formula" }],
|
||||
"facts": ["standalone fact worth memorising"]
|
||||
}
|
||||
`
|
||||
|
||||
export const VISION_USER_INSTRUCTION = `Transcribe these notes and extract the study material. Keep the original language of the notes — do not translate. JSON only, findings only from the notes.`;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Step 2 - activity generation */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export const ACTIVITY_JSON_SPEC = `Each activity is one JSON object with a "type" field. Exactly these seven types exist:
|
||||
|
||||
1. flashcard - tap to flip
|
||||
{ "type": "flashcard", "topic": "short label", "front": "question or term", "back": "answer or definition", "explanation": "why this matters" }
|
||||
|
||||
2. multiple_choice - exactly 4 options, exactly one correct
|
||||
{ "type": "multiple_choice", "topic": "short label", "question": "...", "options": ["A", "B", "C", "D"], "correctIndex": 0, "explanation": "why the correct option is right and the others are wrong" }
|
||||
Put the correct answer at a varied position across the deck (not always 0!).
|
||||
|
||||
3. true_false - one statement
|
||||
{ "type": "true_false", "topic": "short label", "statement": "...", "answer": true, "explanation": "why it is true/false" }
|
||||
Roughly half of them should be false, and false statements must be subtly wrong (a swapped term, a wrong number), never absurd.
|
||||
|
||||
4. fill_blank - the sentence contains the placeholder "____"
|
||||
{ "type": "fill_blank", "topic": "short label", "sentence": "Mitochondria produce ____ for the cell.", "answer": "ATP", "options": ["ATP", "DNA", "glucose", "water"], "explanation": "..." }
|
||||
"options" is optional but preferred: 3-6 plausible choices including the answer.
|
||||
|
||||
5. matching - 3 to 6 term/definition pairs
|
||||
{ "type": "matching", "topic": "short label", "instruction": "Match each term to its definition.", "pairs": [{ "term": "Osmosis", "definition": "diffusion of water across a membrane" }], "explanation": "..." }
|
||||
|
||||
6. ordering - 3 to 7 items given in the CORRECT order (the app shuffles them)
|
||||
{ "type": "ordering", "topic": "short label", "instruction": "Put the stages of mitosis in order.", "items": ["Prophase", "Metaphase", "Anaphase", "Telophase"], "explanation": "..." }
|
||||
|
||||
7. short_answer - typed answer, graded by AI
|
||||
{ "type": "short_answer", "topic": "short label", "question": "...", "referenceAnswer": "a model answer in one or two sentences", "keyPoints": ["idea that must appear", "second idea"], "explanation": "..." }
|
||||
|
||||
Every activity must also have "topic" (2-4 words) and "explanation" (one or two sentences, plain language).`;
|
||||
|
||||
export const GENERATION_SYSTEM_PROMPT = `You are Freezmo's activity designer. You turn a student's own notes into a deck of short, playful, interactive study activities.
|
||||
|
||||
${LANGUAGE_RULES}
|
||||
|
||||
Hard rules:
|
||||
- Use ONLY information contained in the provided notes. Never invent facts, definitions, numbers, dates or formulas. If the notes do not contain enough material for the requested number of activities, create fewer - quality over quantity.
|
||||
- Every explanation must be answerable from the notes; do not introduce outside knowledge.
|
||||
- Vary the activity types. Use a healthy mix of all seven types below, and never put more than two activities of the same type in a row.
|
||||
- Cover the whole notes, not just the first paragraph. Spread activities across the different topics, definitions, formulas and facts.
|
||||
- Make the wrong options plausible (common mix-ups from the same notes), never silly filler.
|
||||
- Keep questions short enough to read on a phone screen. No walls of text.
|
||||
- Never reference "the notes", "the text above" or "the student". Speak directly to the learner.
|
||||
- Match the requested difficulty: easy = recall and simple recognition; medium = understanding and application; hard = analysis, edge cases and multi-step reasoning.
|
||||
- Output JSON only. No markdown fences, no commentary before or after.
|
||||
|
||||
${ACTIVITY_JSON_SPEC}
|
||||
|
||||
Required JSON shape:
|
||||
{
|
||||
"title": "short deck title, max 10 words, in the notes' language",
|
||||
"subject": "one or two words",
|
||||
"language": "English name of the language you wrote in",
|
||||
"summary": "one sentence about what this deck covers",
|
||||
"activities": [ ...activity objects... ]
|
||||
}`;
|
||||
|
||||
export type GenerationRequest = {
|
||||
notes: string;
|
||||
difficulty: "easy" | "medium" | "hard";
|
||||
count: number;
|
||||
/** Language the output must be written in (from the vision step or detected). */
|
||||
language?: string;
|
||||
/** Set when adding more activities to an existing deck. */
|
||||
existing?: { title: string; types: string[]; count: number };
|
||||
/** Extra instructions from the student (topic hint). */
|
||||
focus?: string;
|
||||
};
|
||||
|
||||
export function buildGenerationUserPrompt({
|
||||
notes,
|
||||
difficulty,
|
||||
count,
|
||||
language,
|
||||
existing,
|
||||
focus,
|
||||
}: GenerationRequest): string {
|
||||
const lines: string[] = [
|
||||
`Create a study deck with ${count} activities at ${difficulty} difficulty.`,
|
||||
language
|
||||
? `LANGUAGE: write everything in ${language}. Do not translate into English.`
|
||||
: "LANGUAGE: detect the language of the notes below and write everything in that same language.",
|
||||
focus ? `The student especially wants to practise: ${focus}` : "",
|
||||
existing
|
||||
? `This is an addition to the existing deck "${existing.title}" which already has ${existing.count} activities (types used so far: ${existing.types.join(", ") || "none"}). Generate ${count} NEW activities on material that was not already covered, and prefer types that are underrepresented.`
|
||||
: "",
|
||||
"",
|
||||
"=== STUDENT NOTES (the only allowed source of facts) ===",
|
||||
notes.slice(0, 48_000),
|
||||
"=== END OF NOTES ===",
|
||||
"",
|
||||
"Respond with the JSON object only.",
|
||||
];
|
||||
return lines.filter(Boolean).join("\n");
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Short-answer grading */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export const GRADING_SYSTEM_PROMPT = `You are a fair but strict study grader. You compare a student's typed answer with a reference answer and the required key points.
|
||||
|
||||
Rules:
|
||||
- Grade on meaning, not wording. Synonyms, different but correct order, and minor spelling mistakes are fine.
|
||||
- A correct answer must cover all key points. Partial answers are incorrect (correct: false) but should be encouraged in the feedback.
|
||||
- LANGUAGE: reply in the same language as the QUESTION and the STUDENT ANSWER. Look at the actual words of the question before writing anything. If the question is in English, your feedback is in English; if it is in Czech, your feedback is in Czech; and so on. If a LANGUAGE instruction is given below, it always wins. Never answer in a language that does not appear in the question or the answer.
|
||||
- Never reveal that you are an AI. Never mention JSON.
|
||||
- Feedback: one or two sentences, max 300 characters. If something is missing, name it plainly, in that language.
|
||||
- Output JSON only:
|
||||
{ "correct": true|false, "score": 0-100, "feedback": "short feedback", "missing": ["key point the student missed"] }`;
|
||||
|
||||
export function buildGradingUserPrompt(input: {
|
||||
question: string;
|
||||
referenceAnswer: string;
|
||||
keyPoints: string[];
|
||||
userAnswer: string;
|
||||
language?: string;
|
||||
}): string {
|
||||
return [
|
||||
`QUESTION: ${input.question}`,
|
||||
`REFERENCE ANSWER: ${input.referenceAnswer}`,
|
||||
`KEY POINTS: ${input.keyPoints.join(" | ")}`,
|
||||
`STUDENT ANSWER: ${input.userAnswer}`,
|
||||
input.language
|
||||
? `LANGUAGE: write "feedback" and "missing" in ${input.language}, the language of the question.`
|
||||
: `LANGUAGE: write "feedback" and "missing" in the language of the QUESTION and STUDENT ANSWER above. Do not switch languages.`,
|
||||
"",
|
||||
"Grade the student answer. Output JSON only.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Retry helper */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function buildRepairPrompt(raw: string, error: string, shapeHint: string): string {
|
||||
return [
|
||||
"Your previous reply could not be used because it did not match the required JSON shape.",
|
||||
"",
|
||||
`VALIDATION ERROR: ${error}`,
|
||||
"",
|
||||
"PREVIOUS REPLY:",
|
||||
raw.slice(0, 4000),
|
||||
"",
|
||||
`Return a corrected version as JSON only, matching exactly this shape:`,
|
||||
shapeHint,
|
||||
"Do not add markdown fences or any text outside the JSON. Keep the original language of the notes.",
|
||||
].join("\n");
|
||||
}
|
||||
106
lib/rate-limit.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
/**
|
||||
* In-memory sliding-window rate limiter.
|
||||
*
|
||||
* Deliberately dependency-free and good enough for a self-hosted, single
|
||||
* instance app: each key (route + client IP) gets N requests per window.
|
||||
* Counters live in memory and reset on restart, and a busy instance cannot
|
||||
* share state with others — fine for Freezmo, documented in the README.
|
||||
*/
|
||||
|
||||
type Hits = { timestamps: number[] };
|
||||
|
||||
/**
|
||||
* Kept on globalThis so dev-mode hot reloads (and any module re-evaluation)
|
||||
* don't silently reset everybody's counters.
|
||||
*/
|
||||
const globalStore = globalThis as unknown as { __freezmoRateLimit?: Map<string, Hits> };
|
||||
const buckets: Map<string, Hits> = (globalStore.__freezmoRateLimit ??= new Map());
|
||||
const MAX_BUCKETS = 5_000;
|
||||
|
||||
export type RateLimitResult = {
|
||||
allowed: boolean;
|
||||
limit: number;
|
||||
remaining: number;
|
||||
/** Seconds until the oldest hit falls out of the window (0 when allowed). */
|
||||
retryAfter: number;
|
||||
resetAt: number;
|
||||
};
|
||||
|
||||
export function rateLimit(key: string, limit: number, windowMs: number): RateLimitResult {
|
||||
const now = Date.now();
|
||||
const windowStart = now - windowMs;
|
||||
|
||||
let bucket = buckets.get(key);
|
||||
if (!bucket) {
|
||||
if (buckets.size >= MAX_BUCKETS) prune(now, windowMs);
|
||||
bucket = { timestamps: [] };
|
||||
buckets.set(key, bucket);
|
||||
}
|
||||
|
||||
bucket.timestamps = bucket.timestamps.filter((timestamp) => timestamp > windowStart);
|
||||
|
||||
if (bucket.timestamps.length >= limit) {
|
||||
const oldest = bucket.timestamps[0];
|
||||
const retryAfter = Math.max(1, Math.ceil((oldest + windowMs - now) / 1000));
|
||||
return {
|
||||
allowed: false,
|
||||
limit,
|
||||
remaining: 0,
|
||||
retryAfter,
|
||||
resetAt: oldest + windowMs,
|
||||
};
|
||||
}
|
||||
|
||||
bucket.timestamps.push(now);
|
||||
return {
|
||||
allowed: true,
|
||||
limit,
|
||||
remaining: Math.max(0, limit - bucket.timestamps.length),
|
||||
retryAfter: 0,
|
||||
resetAt: now + windowMs,
|
||||
};
|
||||
}
|
||||
|
||||
/** Drop stale buckets so long-running servers don't grow without bound. */
|
||||
function prune(now: number, windowMs: number): void {
|
||||
const cutoff = now - windowMs;
|
||||
for (const [key, bucket] of buckets) {
|
||||
if (bucket.timestamps.length === 0 || bucket.timestamps[bucket.timestamps.length - 1] < cutoff) {
|
||||
buckets.delete(key);
|
||||
}
|
||||
if (buckets.size < MAX_BUCKETS / 2) break;
|
||||
}
|
||||
}
|
||||
|
||||
/** Best-effort client identity: proxy headers first, then a shared bucket. */
|
||||
export function clientKey(request: Request): string {
|
||||
const forwarded = request.headers.get("x-forwarded-for");
|
||||
if (forwarded) {
|
||||
const first = forwarded.split(",")[0]?.trim();
|
||||
if (first) return first;
|
||||
}
|
||||
return (
|
||||
request.headers.get("x-real-ip") ??
|
||||
request.headers.get("cf-connecting-ip") ??
|
||||
"local"
|
||||
);
|
||||
}
|
||||
|
||||
export function rateLimitResponse(result: RateLimitResult, message?: string): Response {
|
||||
return Response.json(
|
||||
{
|
||||
error:
|
||||
message ??
|
||||
`Too many requests — this app allows ${result.limit} per window. Try again in ${result.retryAfter}s.`,
|
||||
kind: "rate_limited",
|
||||
retryAfter: result.retryAfter,
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
"Retry-After": String(result.retryAfter),
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
193
lib/schemas.ts
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Zod schemas shared by the API routes (server) and the UI (client).
|
||||
* Everything the model returns is validated against these.
|
||||
*/
|
||||
|
||||
export const difficultySchema = z.enum(["easy", "medium", "hard"]);
|
||||
export type Difficulty = z.infer<typeof difficultySchema>;
|
||||
|
||||
const baseActivity = {
|
||||
/** Short label shown in the feed chrome, e.g. "Cell biology". */
|
||||
topic: z.string().min(1).max(120).optional(),
|
||||
explanation: z.string().min(1).max(600),
|
||||
};
|
||||
|
||||
export const flashcardSchema = z.object({
|
||||
type: z.literal("flashcard"),
|
||||
front: z.string().min(1).max(400),
|
||||
back: z.string().min(1).max(1200),
|
||||
...baseActivity,
|
||||
});
|
||||
|
||||
export const multipleChoiceSchema = z.object({
|
||||
type: z.literal("multiple_choice"),
|
||||
question: z.string().min(1).max(600),
|
||||
options: z.array(z.string().min(1).max(300)).length(4),
|
||||
correctIndex: z.number().int().min(0).max(3),
|
||||
...baseActivity,
|
||||
});
|
||||
|
||||
export const trueFalseSchema = z.object({
|
||||
type: z.literal("true_false"),
|
||||
statement: z.string().min(1).max(600),
|
||||
answer: z.boolean(),
|
||||
...baseActivity,
|
||||
});
|
||||
|
||||
export const fillBlankSchema = z.object({
|
||||
type: z.literal("fill_blank"),
|
||||
/** Must contain a `____` placeholder. */
|
||||
sentence: z.string().min(1).max(600),
|
||||
answer: z.string().min(1).max(200),
|
||||
/** Optional word bank; when present it is shown as tappable choices. */
|
||||
options: z.array(z.string().min(1).max(200)).min(2).max(6).optional(),
|
||||
...baseActivity,
|
||||
});
|
||||
|
||||
export const matchingSchema = z.object({
|
||||
type: z.literal("matching"),
|
||||
instruction: z.string().min(1).max(300),
|
||||
pairs: z
|
||||
.array(
|
||||
z.object({
|
||||
term: z.string().min(1).max(200),
|
||||
definition: z.string().min(1).max(300),
|
||||
}),
|
||||
)
|
||||
.min(3)
|
||||
.max(6),
|
||||
...baseActivity,
|
||||
});
|
||||
|
||||
export const orderingSchema = z.object({
|
||||
type: z.literal("ordering"),
|
||||
instruction: z.string().min(1).max(300),
|
||||
/** Items in the CORRECT order; the UI shuffles them. */
|
||||
items: z.array(z.string().min(1).max(300)).min(3).max(7),
|
||||
...baseActivity,
|
||||
});
|
||||
|
||||
export const shortAnswerSchema = z.object({
|
||||
type: z.literal("short_answer"),
|
||||
question: z.string().min(1).max(600),
|
||||
referenceAnswer: z.string().min(1).max(1200),
|
||||
/** Ideas the grader should look for. */
|
||||
keyPoints: z.array(z.string().min(1).max(200)).min(1).max(6),
|
||||
...baseActivity,
|
||||
});
|
||||
|
||||
/**
|
||||
* Activities as produced by the model (no id / xp yet - the server adds them).
|
||||
*/
|
||||
export const generatedActivitySchema = z.discriminatedUnion("type", [
|
||||
flashcardSchema,
|
||||
multipleChoiceSchema,
|
||||
trueFalseSchema,
|
||||
fillBlankSchema,
|
||||
matchingSchema,
|
||||
orderingSchema,
|
||||
shortAnswerSchema,
|
||||
]);
|
||||
export type GeneratedActivity = z.infer<typeof generatedActivitySchema>;
|
||||
|
||||
export const generatedDeckSchema = z.object({
|
||||
title: z.string().min(1).max(120),
|
||||
subject: z.string().max(80).optional(),
|
||||
summary: z.string().max(400).optional(),
|
||||
/** Language of the generated content, e.g. "Czech" or "cs". */
|
||||
language: z.string().max(60).optional(),
|
||||
activities: z.array(generatedActivitySchema).min(4).max(20),
|
||||
});
|
||||
export type GeneratedDeck = z.infer<typeof generatedDeckSchema>;
|
||||
|
||||
/** What the vision step must return. */
|
||||
export const extractedNotesSchema = z.object({
|
||||
title: z.string().min(1).max(120),
|
||||
subject: z.string().max(80).optional(),
|
||||
summary: z.string().max(400).optional(),
|
||||
/**
|
||||
* Language of the notes (English name or BCP-47 code). Everything the app
|
||||
* generates afterwards must be written in this language.
|
||||
*/
|
||||
language: z.string().min(2).max(60).default("the same language as the notes"),
|
||||
/** Clean, structured transcription of everything relevant to study. */
|
||||
notes: z.string().min(1),
|
||||
keyConcepts: z.array(z.string().min(1).max(300)).min(1).max(40),
|
||||
definitions: z
|
||||
.array(z.object({ term: z.string().min(1).max(200), definition: z.string().min(1).max(600) }))
|
||||
.max(30)
|
||||
.default([]),
|
||||
formulas: z
|
||||
.array(z.object({ name: z.string().min(1).max(200), formula: z.string().min(1).max(400) }))
|
||||
.max(30)
|
||||
.default([]),
|
||||
facts: z.array(z.string().min(1).max(400)).max(40).default([]),
|
||||
});
|
||||
export type ExtractedNotes = z.infer<typeof extractedNotesSchema>;
|
||||
|
||||
/** Grading response for short answers. */
|
||||
export const gradeResultSchema = z.object({
|
||||
correct: z.boolean(),
|
||||
score: z.number().min(0).max(100),
|
||||
feedback: z.string().min(1).max(600),
|
||||
missing: z.array(z.string().max(200)).max(6).default([]),
|
||||
});export type GradeResult = z.infer<typeof gradeResultSchema>;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Client-side deck types (activity + id/xp, plus storage metadata) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export type Activity = GeneratedActivity & { id: string; xp: number };
|
||||
|
||||
export type Folder = {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
export const folderSchema: z.ZodType<Folder> = z.object({
|
||||
id: z.string().min(1).max(80),
|
||||
name: z.string().min(1).max(60),
|
||||
createdAt: z.number(),
|
||||
});
|
||||
|
||||
/** Virtual folder that holds every deck without a folder of its own. */
|
||||
export const UNORGANIZED_ID = "unorganized";
|
||||
|
||||
export type Deck = {
|
||||
id: string;
|
||||
title: string;
|
||||
subject?: string;
|
||||
summary?: string;
|
||||
/** Language the deck is written in (kept for grading, regenerate, add-more). */
|
||||
language?: string;
|
||||
/** Folder this deck lives in; undefined means "Unorganized". */
|
||||
folderId?: string;
|
||||
/** Text extracted from the user's notes - reused by regenerate/add-more. */
|
||||
sourceText: string;
|
||||
difficulty: Difficulty;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
activities: Activity[];
|
||||
attempts: number;
|
||||
/** Best result as a 0-100 percentage. */
|
||||
bestScore?: number;
|
||||
};
|
||||
|
||||
export const deckSchema: z.ZodType<Deck> = z.object({
|
||||
id: z.string(),
|
||||
title: z.string(),
|
||||
subject: z.string().optional(),
|
||||
summary: z.string().optional(),
|
||||
language: z.string().optional(),
|
||||
folderId: z.string().optional(),
|
||||
sourceText: z.string(),
|
||||
difficulty: difficultySchema,
|
||||
createdAt: z.number(),
|
||||
updatedAt: z.number(),
|
||||
activities: z.array(generatedActivitySchema.and(z.object({ id: z.string(), xp: z.number() }))).min(1),
|
||||
attempts: z.number(),
|
||||
bestScore: z.number().optional(),
|
||||
});
|
||||
84
lib/settings.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* Browser-side settings, including the user's own Ollama API key.
|
||||
*
|
||||
* The key lives ONLY here, in this browser's localStorage. It is never sent
|
||||
* anywhere except to Freezmo's own API routes on this server (in the
|
||||
* `x-ollama-key` header), which forward it to Ollama for that single request
|
||||
* and never write it to disk, a log, a cookie or the environment.
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = "freezmo.settings.v1";
|
||||
|
||||
export type BrowserSettings = {
|
||||
/**
|
||||
* The user's own Ollama API key. Nothing else is stored here — the endpoint
|
||||
* and model names always come from the server's .env, never from the client.
|
||||
*/
|
||||
apiKey: string;
|
||||
};
|
||||
|
||||
const EMPTY: BrowserSettings = { apiKey: "" };
|
||||
|
||||
let cached: BrowserSettings | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function read(): BrowserSettings {
|
||||
if (typeof window === "undefined") return EMPTY;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return EMPTY;
|
||||
const parsed = JSON.parse(raw) as Partial<BrowserSettings>;
|
||||
return { apiKey: typeof parsed.apiKey === "string" ? parsed.apiKey.slice(0, 512) : "" };
|
||||
} catch {
|
||||
return EMPTY;
|
||||
}
|
||||
}
|
||||
|
||||
function write(next: BrowserSettings): void {
|
||||
cached = next;
|
||||
try {
|
||||
if (next.apiKey) {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify({ apiKey: next.apiKey }));
|
||||
} else {
|
||||
window.localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[freezmo] could not save settings (storage disabled?)", error);
|
||||
}
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
export const settingsStore = {
|
||||
subscribe(listener: () => void): () => void {
|
||||
if (cached === null) cached = read();
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
getSnapshot(): BrowserSettings {
|
||||
if (cached === null) cached = read();
|
||||
return cached;
|
||||
},
|
||||
getServerSnapshot(): BrowserSettings {
|
||||
return EMPTY;
|
||||
},
|
||||
};
|
||||
|
||||
export function getApiKey(): string {
|
||||
return settingsStore.getSnapshot().apiKey;
|
||||
}
|
||||
|
||||
export function saveSettings(next: BrowserSettings): void {
|
||||
write(next);
|
||||
}
|
||||
|
||||
export function clearApiKey(): void {
|
||||
write({ apiKey: "" });
|
||||
}
|
||||
|
||||
/** Header used to hand the key to our own API routes for one request. */
|
||||
export function authHeaders(): Record<string, string> {
|
||||
const { apiKey } = settingsStore.getSnapshot();
|
||||
return apiKey ? { "x-ollama-key": apiKey } : {};
|
||||
}
|
||||
263
lib/storage.ts
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* Deck library backed by localStorage. No accounts, no server state.
|
||||
* Exposes a tiny observable store so every screen stays in sync.
|
||||
*/
|
||||
|
||||
import { deckSchema, UNORGANIZED_ID, type Activity, type Deck } from "./schemas";
|
||||
import { createFolder, findFolderByName, getFolder, removeFolder } from "./folders";
|
||||
import { uid } from "./utils";
|
||||
|
||||
const STORAGE_KEY = "freezmo.decks.v1";
|
||||
const EMPTY: Deck[] = [];
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
let decks: Deck[] | null = null;
|
||||
let cached: Deck[] = EMPTY;
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
function read(): Deck[] {
|
||||
if (typeof window === "undefined") return EMPTY;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return EMPTY;
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return EMPTY;
|
||||
const valid: Deck[] = [];
|
||||
for (const entry of parsed) {
|
||||
const result = deckSchema.safeParse(entry);
|
||||
if (result.success) valid.push(result.data);
|
||||
else console.warn("[freezmo] skipping invalid deck in storage", result.error.issues);
|
||||
}
|
||||
return valid;
|
||||
} catch (error) {
|
||||
console.warn("[freezmo] could not read the deck library", error);
|
||||
return EMPTY;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureLoaded(): void {
|
||||
if (decks !== null) return;
|
||||
decks = read();
|
||||
cached = decks;
|
||||
}
|
||||
|
||||
function write(next: Deck[]): void {
|
||||
decks = next;
|
||||
cached = next;
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
} catch (error) {
|
||||
console.warn("[freezmo] could not save the deck library (storage full?)", error);
|
||||
}
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
export const deckStore = {
|
||||
subscribe(listener: Listener): () => void {
|
||||
ensureLoaded();
|
||||
listeners.add(listener);
|
||||
// Another tab may have changed the library.
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
if (event.key === STORAGE_KEY) {
|
||||
decks = null;
|
||||
ensureLoaded();
|
||||
listeners.forEach((l) => l());
|
||||
}
|
||||
};
|
||||
window.addEventListener("storage", onStorage);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
window.removeEventListener("storage", onStorage);
|
||||
};
|
||||
},
|
||||
getSnapshot(): Deck[] {
|
||||
ensureLoaded();
|
||||
return cached;
|
||||
},
|
||||
getServerSnapshot(): Deck[] {
|
||||
return EMPTY;
|
||||
},
|
||||
};
|
||||
|
||||
/** Newest first. */
|
||||
export function sortDecks(list: Deck[]): Deck[] {
|
||||
return [...list].sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
}
|
||||
|
||||
export function getDeck(id: string): Deck | undefined {
|
||||
return deckStore.getSnapshot().find((deck) => deck.id === id);
|
||||
}
|
||||
|
||||
export function upsertDeck(deck: Deck): void {
|
||||
ensureLoaded();
|
||||
const current = deckStore.getSnapshot();
|
||||
const index = current.findIndex((entry) => entry.id === deck.id);
|
||||
const next = index === -1 ? [deck, ...current] : current.map((entry) => (entry.id === deck.id ? deck : entry));
|
||||
write(next);
|
||||
}
|
||||
|
||||
export function renameDeck(id: string, title: string): void {
|
||||
ensureLoaded();
|
||||
write(deckStore.getSnapshot().map((deck) => (deck.id === id ? { ...deck, title, updatedAt: Date.now() } : deck)));
|
||||
}
|
||||
|
||||
export function deleteDeck(id: string): void {
|
||||
ensureLoaded();
|
||||
write(deckStore.getSnapshot().filter((deck) => deck.id !== id));
|
||||
}
|
||||
|
||||
export function recordAttempt(
|
||||
id: string,
|
||||
result: { scorePercent: number; correct: number; total: number },
|
||||
): void {
|
||||
ensureLoaded();
|
||||
write(
|
||||
deckStore.getSnapshot().map((deck) =>
|
||||
deck.id === id
|
||||
? {
|
||||
...deck,
|
||||
attempts: deck.attempts + 1,
|
||||
bestScore: Math.max(deck.bestScore ?? 0, Math.round(result.scorePercent)),
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
: deck,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Move a deck into a folder (pass undefined/UNORGANIZED_ID for "Unorganized"). */
|
||||
export function moveDeck(id: string, folderId: string | undefined): void {
|
||||
ensureLoaded();
|
||||
const target = !folderId || folderId === UNORGANIZED_ID ? undefined : folderId;
|
||||
write(
|
||||
deckStore.getSnapshot().map((deck) =>
|
||||
deck.id === id ? { ...deck, folderId: target, updatedAt: Date.now() } : deck,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Used when a folder is deleted: every deck inside goes back to Unorganized. */
|
||||
export function unfileDecks(folderId: string): void {
|
||||
ensureLoaded();
|
||||
write(
|
||||
deckStore.getSnapshot().map((deck) =>
|
||||
deck.folderId === folderId ? { ...deck, folderId: undefined, updatedAt: Date.now() } : deck,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Deletes a folder for good — its decks always survive in "Unorganized". */
|
||||
export function deleteFolder(folderId: string): void {
|
||||
unfileDecks(folderId);
|
||||
removeFolder(folderId);
|
||||
}
|
||||
|
||||
export function countDecksInFolder(folderId: string): number {
|
||||
ensureLoaded();
|
||||
if (folderId === UNORGANIZED_ID) return deckStore.getSnapshot().filter((deck) => !deck.folderId).length;
|
||||
return deckStore.getSnapshot().filter((deck) => deck.folderId === folderId).length;
|
||||
}
|
||||
|
||||
export function appendActivities(id: string, activities: Activity[], title?: string): void {
|
||||
ensureLoaded();
|
||||
write(
|
||||
deckStore.getSnapshot().map((deck) =>
|
||||
deck.id === id
|
||||
? {
|
||||
...deck,
|
||||
title: title ?? deck.title,
|
||||
activities: [...deck.activities, ...activities],
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
: deck,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Import / export */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export type ExportBundle = {
|
||||
app: "freezmo";
|
||||
version: 1;
|
||||
exportedAt: string;
|
||||
/** Folder name travels with the deck so imports land in the right place. */
|
||||
folder?: string | null;
|
||||
deck: Deck;
|
||||
};
|
||||
|
||||
export function exportDeck(deck: Deck): string {
|
||||
const bundle: ExportBundle = {
|
||||
app: "freezmo",
|
||||
version: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
folder: folderNameOf(deck),
|
||||
deck,
|
||||
};
|
||||
return JSON.stringify(bundle, null, 2);
|
||||
}
|
||||
|
||||
function folderNameOf(deck: Deck): string | null {
|
||||
if (!deck.folderId) return null;
|
||||
return getFolder(deck.folderId)?.name ?? null;
|
||||
}
|
||||
|
||||
export function downloadDeck(deck: Deck): void {
|
||||
const blob = new Blob([exportDeck(deck)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = `${deck.title.replace(/[^a-z0-9]+/gi, "-").toLowerCase() || "freezmo-deck"}.json`;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/** Accepts a Freezmo export bundle or a bare deck object. */
|
||||
export function importDeck(raw: string): { deck: Deck } | { error: string } {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return { error: "That file is not valid JSON." };
|
||||
}
|
||||
|
||||
const bundle = parsed && typeof parsed === "object" && "deck" in parsed ? (parsed as { folder?: unknown }) : null;
|
||||
const candidate = bundle ? (parsed as { deck: unknown }).deck : parsed;
|
||||
|
||||
const result = deckSchema.safeParse(candidate);
|
||||
if (!result.success) {
|
||||
return { error: "That file doesn't look like a Freezmo deck." };
|
||||
}
|
||||
|
||||
// Recreate the exported folder (or reuse an existing one with the same name).
|
||||
let folderId: string | undefined;
|
||||
const folderName = typeof bundle?.folder === "string" ? bundle.folder.trim() : "";
|
||||
if (folderName) {
|
||||
const existing = findFolderByName(folderName);
|
||||
if (existing) {
|
||||
folderId = existing.id;
|
||||
} else {
|
||||
const created = createFolder(folderName);
|
||||
folderId = "folder" in created ? created.folder.id : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const deck: Deck = {
|
||||
...result.data,
|
||||
folderId,
|
||||
id: uid("deck"),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
attempts: 0,
|
||||
bestScore: undefined,
|
||||
};
|
||||
upsertDeck(deck);
|
||||
return { deck };
|
||||
}
|
||||
13
lib/ui-events.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* Tiny event bus so any screen can open the settings dialog (which lives in
|
||||
* the header) without prop drilling.
|
||||
*/
|
||||
|
||||
export const OPEN_SETTINGS_EVENT = "freezmo:open-settings";
|
||||
|
||||
export function openSettings(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
window.dispatchEvent(new CustomEvent(OPEN_SETTINGS_EVENT));
|
||||
}
|
||||
39
lib/use-health.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
||||
|
||||
import { fetchHealth, type Health } from "./client-api";
|
||||
import { settingsStore } from "./settings";
|
||||
|
||||
/**
|
||||
* Health status for the current browser (re-checks whenever the API key
|
||||
* changes). Used by the settings dialog and the "add your key" banner.
|
||||
*/
|
||||
export function useHealth() {
|
||||
const apiKey = useSyncExternalStore(
|
||||
settingsStore.subscribe,
|
||||
() => settingsStore.getSnapshot().apiKey,
|
||||
() => "",
|
||||
);
|
||||
const [health, setHealth] = useState<Health | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
setHealth(await fetchHealth());
|
||||
} catch (loadError) {
|
||||
setError(loadError instanceof Error ? loadError.message : "Could not reach the Freezmo server.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh, apiKey]);
|
||||
|
||||
return { health, loading, error, refresh, apiKey };
|
||||
}
|
||||
59
lib/utils.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
export function cn(...parts: Array<string | false | null | undefined>): string {
|
||||
return parts.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
export function uid(prefix = "id"): string {
|
||||
const rand =
|
||||
typeof crypto !== "undefined" && "randomUUID" in crypto
|
||||
? crypto.randomUUID().slice(0, 8)
|
||||
: Math.random().toString(36).slice(2, 10);
|
||||
return `${prefix}_${Date.now().toString(36)}_${rand}`;
|
||||
}
|
||||
|
||||
/** Deterministic-ish shuffle (Fisher-Yates). Returns a new array. */
|
||||
export function shuffle<T>(input: readonly T[]): T[] {
|
||||
const out = [...input];
|
||||
for (let i = out.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[out[i], out[j]] = [out[j], out[i]];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function formatDate(timestamp: number): string {
|
||||
try {
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
}).format(new Date(timestamp));
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove ```json fences and stray prose around a JSON object. */
|
||||
export function stripFences(text: string): string {
|
||||
let value = text.trim();
|
||||
const fence = value.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
||||
if (fence) value = fence[1].trim();
|
||||
if (value.startsWith("{") || value.startsWith("[")) return value;
|
||||
const start = value.indexOf("{");
|
||||
const end = value.lastIndexOf("}");
|
||||
if (start !== -1 && end > start) return value.slice(start, end + 1);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function parseJsonLoose(text: string): unknown {
|
||||
const cleaned = stripFences(text);
|
||||
try {
|
||||
return JSON.parse(cleaned);
|
||||
} catch {
|
||||
// Last resort: tolerate trailing commas from chatty models.
|
||||
return JSON.parse(cleaned.replace(/,\s*([}\]])/g, "$1"));
|
||||
}
|
||||
}
|
||||
|
||||
export function truncate(value: string, max: number): string {
|
||||
return value.length > max ? `${value.slice(0, max - 1)}…` : value;
|
||||
}
|
||||
26
lib/xp.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import type { Difficulty } from "./schemas";
|
||||
|
||||
/**
|
||||
* Base XP for a correct answer. Harder decks are worth more, and the
|
||||
* multi-part activity types get a small bonus.
|
||||
*/
|
||||
export const XP_BY_DIFFICULTY: Record<Difficulty, number> = {
|
||||
easy: 10,
|
||||
medium: 15,
|
||||
hard: 20,
|
||||
};
|
||||
|
||||
const TYPE_BONUS: Record<string, number> = {
|
||||
matching: 5,
|
||||
ordering: 5,
|
||||
short_answer: 5,
|
||||
};
|
||||
|
||||
export function xpForActivity(type: string, difficulty: Difficulty): number {
|
||||
return XP_BY_DIFFICULTY[difficulty] + (TYPE_BONUS[type] ?? 0);
|
||||
}
|
||||
|
||||
/** Consecutive-correct streak multiplier (capped at 3x). */
|
||||
export function streakMultiplier(streak: number): number {
|
||||
return Math.min(1 + Math.max(0, streak) * 0.1, 3);
|
||||
}
|
||||
|
|
@ -1,7 +1,22 @@
|
|||
import type { NextConfig } from "next";
|
||||
|
||||
/**
|
||||
* Freezmo is meant to be opened from a phone on the same network, so the dev
|
||||
* server (which binds 0.0.0.0:3207) should accept dev-asset requests coming
|
||||
* from private hostnames. Production builds are not affected by this option.
|
||||
*
|
||||
* Add your own hostname here if Next still warns about a cross-origin request.
|
||||
*/
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
allowedDevOrigins: [
|
||||
"127.0.0.1",
|
||||
"localhost",
|
||||
"*.local",
|
||||
"**.local",
|
||||
"192.168.*.*",
|
||||
"10.*.*.*",
|
||||
"*.ts.net", // Tailscale
|
||||
],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
|
|
|||
566
package-lock.json
generated
|
|
@ -8,13 +8,20 @@
|
|||
"name": "freezmo",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"framer-motion": "^12.23.24",
|
||||
"jszip": "^3.10.2",
|
||||
"mammoth": "^1.13.0",
|
||||
"next": "15.5.27",
|
||||
"pdfjs-dist": "^6.4.299",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0"
|
||||
"react-dom": "19.1.0",
|
||||
"zod": "^4.1.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
|
|
@ -879,6 +886,271 @@
|
|||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.10.tgz",
|
||||
"integrity": "sha512-V+qptzsGnPMFxEbhwc6eDNMe5eDrfiBIp8qGpuRjAVIX4Kn2zxvwQ1Xco6Fwe+3tYIe3iLW8LM9D+SvjyJhqbQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"workspaces": [
|
||||
"e2e/*"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas-android-arm64": "1.0.10",
|
||||
"@napi-rs/canvas-darwin-arm64": "1.0.10",
|
||||
"@napi-rs/canvas-darwin-x64": "1.0.10",
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": "1.0.10",
|
||||
"@napi-rs/canvas-linux-arm64-gnu": "1.0.10",
|
||||
"@napi-rs/canvas-linux-arm64-musl": "1.0.10",
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": "1.0.10",
|
||||
"@napi-rs/canvas-linux-x64-gnu": "1.0.10",
|
||||
"@napi-rs/canvas-linux-x64-musl": "1.0.10",
|
||||
"@napi-rs/canvas-win32-arm64-msvc": "1.0.10",
|
||||
"@napi-rs/canvas-win32-x64-msvc": "1.0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-android-arm64": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.10.tgz",
|
||||
"integrity": "sha512-0sDMvme+7fj6bHERUaBs5SvEyWaORc91VUkaB4RrtG7p6dBrBNGy1of8cyEKDxygd1FlHtW+Thunj8lSixzY7A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-arm64": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.10.tgz",
|
||||
"integrity": "sha512-pdHL1TEfFMrnchRYqmPWXKN7hR4XtOYmzJqnh4XhpfWD4/eeSokOGVzrdFP/iQ3sfr2rf0939gOGq9uCp8BGmg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-x64": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.10.tgz",
|
||||
"integrity": "sha512-iuuk5plGxAcxurxv2ycF2yR20y5zvaBZ4heQdhPpg3EMFD21PhlY1V+YwSRmYRWzbvKL474UYdw3WIg2qfafyw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.10.tgz",
|
||||
"integrity": "sha512-kH7GFR5Unpm77pKSEsmkqvzeQCRD7/MQ7upmeLLjg4XpQ62dX/R6THFsp6vkF1Pkco2aKUq9KhoLM4W5twdk7g==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.10.tgz",
|
||||
"integrity": "sha512-0/Hj7IwM9pmzdwxiqkp4YLJ8dKqASBrWQ3Y7dWUSU/4dgvCL9O6YNvX7xVUJjonQvdD3IE9ESS/nXa5srQqoVw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.10.tgz",
|
||||
"integrity": "sha512-fmjKM0P2MLF9O6XzCQsRjhB+jlLzaf6tFQU36hMPMsYup+VThYvpjjhqNLOc/Ee3tHUE3r13H2BSJx/yXz4pFA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.10.tgz",
|
||||
"integrity": "sha512-O/BUrGwrs3pVP9PpD/3++ZiOpjHMJlmiB7aBgswP8rbe4egPfiFkFxmcCczoLvZa72TaQX5lJ8Udedb6rc/Hvg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.10.tgz",
|
||||
"integrity": "sha512-48HkZPQeAN/R+9NPpY64tceoyCUW5xYYtHKZnC+BG11qiihXJCbH+xfbgGU+OdYp1Q4s84HDl9ILU0KBK6SBOQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-musl": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.10.tgz",
|
||||
"integrity": "sha512-QYHVi7WZ9v+Wm9OQyHLMTnJ8QiBPVX94BgbCHFJC/4bCFMfvX7fRzo4FJPgkRP0ISz3ulYNow/FCbQOPjzhYjg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.10.tgz",
|
||||
"integrity": "sha512-bWK+YajM/8sL0mMgLtug2s063FVBPzFV4QAn987CQomMKlmzrBY6Trz43KY+24hq7IOa3qNhUAgP/XMEF50ZEQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.10.tgz",
|
||||
"integrity": "sha512-9WtzW85PaIhtB27iXaR/9RrLCqHLbqD3Z8Q+kh17NjFxwQXFXU5vWbYH4vEH+drM307TTyK3hidZgpt09G5T3g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.4.tgz",
|
||||
|
|
@ -1422,6 +1694,13 @@
|
|||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/canvas-confetti": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/canvas-confetti/-/canvas-confetti-1.9.0.tgz",
|
||||
"integrity": "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||
|
|
@ -2109,6 +2388,15 @@
|
|||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@xmldom/xmldom": {
|
||||
"version": "0.8.15",
|
||||
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz",
|
||||
"integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
|
||||
|
|
@ -2402,6 +2690,26 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.21",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
|
||||
|
|
@ -2506,6 +2814,16 @@
|
|||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/canvas-confetti": {
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/canvas-confetti/-/canvas-confetti-1.9.4.tgz",
|
||||
"integrity": "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==",
|
||||
"license": "ISC",
|
||||
"funding": {
|
||||
"type": "donate",
|
||||
"url": "https://www.paypal.me/kirilvatev"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
|
|
@ -2556,6 +2874,12 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/core-util-is": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
|
|
@ -2710,6 +3034,12 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dingbat-to-unicode": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.2.tgz",
|
||||
"integrity": "sha512-R0bRerYzy/EZP6QzD3Hkl6JjUDA1Mnn+pd928w0nWjPzihnbAR6dcjGJGiRyznj5E4S1duEKzWrTqw02DPcdzg==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/doctrine": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
|
||||
|
|
@ -2723,6 +3053,15 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/duck": {
|
||||
"version": "0.1.12",
|
||||
"resolved": "https://registry.npmjs.org/duck/-/duck-0.1.12.tgz",
|
||||
"integrity": "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==",
|
||||
"license": "BSD",
|
||||
"dependencies": {
|
||||
"underscore": "^1.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
|
|
@ -3520,6 +3859,33 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/framer-motion": {
|
||||
"version": "12.43.0",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.43.0.tgz",
|
||||
"integrity": "sha512-1eaL3RvR/kAlbG7UYcpMptEyzPoENO0c6w7ZnB3/hh2vSAz/6uGAFn6fdoqTBguNstf3MsFhJHsD/0DHiclG+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-dom": "^12.43.0",
|
||||
"motion-utils": "^12.39.0",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/is-prop-valid": "*",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@emotion/is-prop-valid": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
|
|
@ -3811,6 +4177,12 @@
|
|||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/immediate": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
|
|
@ -3838,6 +4210,12 @@
|
|||
"node": ">=0.8.19"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/internal-slot": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
|
||||
|
|
@ -4393,6 +4771,18 @@
|
|||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip": {
|
||||
"version": "3.10.2",
|
||||
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.2.tgz",
|
||||
"integrity": "sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==",
|
||||
"license": "(MIT OR GPL-3.0-or-later)",
|
||||
"dependencies": {
|
||||
"lie": "~3.3.0",
|
||||
"pako": "~1.0.2",
|
||||
"readable-stream": "~2.3.6",
|
||||
"setimmediate": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
|
|
@ -4437,6 +4827,15 @@
|
|||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lie": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"immediate": "~3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||
|
|
@ -4746,6 +5145,17 @@
|
|||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/lop": {
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmjs.org/lop/-/lop-0.4.2.tgz",
|
||||
"integrity": "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"duck": "^0.1.12",
|
||||
"option": "~0.2.1",
|
||||
"underscore": "^1.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
|
|
@ -4756,6 +5166,37 @@
|
|||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/mammoth": {
|
||||
"version": "1.13.0",
|
||||
"resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.13.0.tgz",
|
||||
"integrity": "sha512-2cawcYFcP7ISfXbBcL9lmVgTNWo88v/52ND1gnywHtVT/pX0//puPnf0iMPV9ZgmuvNOE+zkIhDgZ9M2c2dDDg==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"@xmldom/xmldom": "^0.8.6",
|
||||
"argparse": "~1.0.3",
|
||||
"base64-js": "^1.5.1",
|
||||
"dingbat-to-unicode": "^1.0.1",
|
||||
"jszip": "^3.7.1",
|
||||
"lop": "^0.4.2",
|
||||
"underscore": "^1.13.1",
|
||||
"xmlbuilder": "^10.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"mammoth": "bin/mammoth"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mammoth/node_modules/argparse": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
|
||||
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sprintf-js": "~1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
|
|
@ -4813,6 +5254,21 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-dom": {
|
||||
"version": "12.43.0",
|
||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.43.0.tgz",
|
||||
"integrity": "sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-utils": "^12.39.0"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-utils": {
|
||||
"version": "12.39.0",
|
||||
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz",
|
||||
"integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
|
|
@ -5093,6 +5549,12 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/option": {
|
||||
"version": "0.2.4",
|
||||
"resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz",
|
||||
"integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/optionator": {
|
||||
"version": "0.9.4",
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||
|
|
@ -5162,6 +5624,12 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
|
|
@ -5202,6 +5670,18 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pdfjs-dist": {
|
||||
"version": "6.4.299",
|
||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.4.299.tgz",
|
||||
"integrity": "sha512-AVl138zALtfaAPvADulE0PZThbYzCBS79nL4pOSL/6Sm/4AH5A21BD9VHt97OlCuzJuCpmeZtAtkinisF4Vb1g==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=22.13.0 || >=24"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas": "^1.0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
|
|
@ -5270,6 +5750,12 @@
|
|||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
|
|
@ -5341,6 +5827,27 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
"isarray": "~1.0.0",
|
||||
"process-nextick-args": "~2.0.0",
|
||||
"safe-buffer": "~5.1.1",
|
||||
"string_decoder": "~1.1.1",
|
||||
"util-deprecate": "~1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream/node_modules/isarray": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/reflect.getprototypeof": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||
|
|
@ -5484,6 +5991,12 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safe-push-apply": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
|
||||
|
|
@ -5587,6 +6100,12 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/setimmediate": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sharp": {
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz",
|
||||
|
|
@ -5745,6 +6264,12 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sprintf-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
||||
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/stable-hash": {
|
||||
"version": "0.0.5",
|
||||
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
|
||||
|
|
@ -5766,6 +6291,15 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string.prototype.includes": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
|
||||
|
|
@ -6189,6 +6723,12 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/underscore": {
|
||||
"version": "1.13.8",
|
||||
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz",
|
||||
"integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
|
|
@ -6244,6 +6784,12 @@
|
|||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
|
@ -6359,6 +6905,15 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlbuilder": {
|
||||
"version": "10.1.1",
|
||||
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz",
|
||||
"integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
|
|
@ -6371,6 +6926,15 @@
|
|||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.6.5",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz",
|
||||
"integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
25
package.json
|
|
@ -3,25 +3,34 @@
|
|||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build --turbopack",
|
||||
"start": "next start",
|
||||
"predev": "node scripts/copy-pdf-worker.mjs",
|
||||
"dev": "next dev -H 0.0.0.0 -p 3207",
|
||||
"prebuild": "node scripts/copy-pdf-worker.mjs",
|
||||
"build": "next build",
|
||||
"start": "next start -H 0.0.0.0 -p 3207",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"framer-motion": "^12.23.24",
|
||||
"jszip": "^3.10.2",
|
||||
"mammoth": "^1.13.0",
|
||||
"next": "15.5.27",
|
||||
"pdfjs-dist": "^6.4.299",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"next": "15.5.27"
|
||||
"zod": "^4.1.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5",
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"tailwindcss": "^4",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.5.27",
|
||||
"@eslint/eslintrc": "^3"
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
BIN
public/icons/apple-touch-icon-152.png
Normal file
|
After Width: | Height: | Size: 4 KiB |
BIN
public/icons/apple-touch-icon-167.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
public/icons/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
public/icons/favicon-32.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
public/icons/icon-192.png
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
BIN
public/icons/icon-512.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
public/icons/maskable-512.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
public/icons/splash-1170x2532.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
public/icons/splash-1179x2556.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
public/icons/splash-1284x2778.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
public/icons/splash-1290x2796.png
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
public/icons/splash-1536x2048.png
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
public/icons/splash-1668x2388.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
public/icons/splash-750x1334.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
public/icons/splash-828x1792.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
97
public/sw.js
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
/**
|
||||
* Freezmo service worker (production only).
|
||||
*
|
||||
* Strategy, deliberately conservative:
|
||||
* - API routes are never cached and never intercepted.
|
||||
* - Navigations: network first, falling back to the cached shell, then /offline.
|
||||
* - /_next/static + icons: stale-while-revalidate (fast, self-healing).
|
||||
* - Everything else: straight to the network.
|
||||
*
|
||||
* Bumping CACHE_VERSION is how a new deploy invalidates old assets.
|
||||
*/
|
||||
const CACHE_VERSION = "freezmo-v2";
|
||||
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
|
||||
const ASSET_CACHE = `${CACHE_VERSION}-assets`;
|
||||
|
||||
const SHELL_ASSETS = [
|
||||
"/",
|
||||
"/offline",
|
||||
"/manifest.webmanifest",
|
||||
"/icons/icon-192.png",
|
||||
"/icons/icon-512.png",
|
||||
"/icons/apple-touch-icon.png",
|
||||
];
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
const cache = await caches.open(SHELL_CACHE);
|
||||
await Promise.allSettled(SHELL_ASSETS.map((url) => cache.add(new Request(url, { cache: "reload" }))));
|
||||
await self.skipWaiting();
|
||||
})(),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
const names = await caches.keys();
|
||||
await Promise.all(
|
||||
names.filter((name) => !name.startsWith(CACHE_VERSION)).map((name) => caches.delete(name)),
|
||||
);
|
||||
await self.clients.claim();
|
||||
})(),
|
||||
);
|
||||
});
|
||||
|
||||
function isCacheableAsset(url) {
|
||||
return (
|
||||
url.pathname.startsWith("/_next/static/") ||
|
||||
url.pathname.startsWith("/icons/") ||
|
||||
url.pathname === "/manifest.webmanifest" ||
|
||||
/\.(?:css|js|png|jpg|jpeg|svg|webp|woff2?)$/.test(url.pathname)
|
||||
);
|
||||
}
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const request = event.request;
|
||||
if (request.method !== "GET") return;
|
||||
|
||||
const url = new URL(request.url);
|
||||
if (url.origin !== self.location.origin) return;
|
||||
// Never touch the API: keys, notes and generated decks must always be live.
|
||||
if (url.pathname.startsWith("/api/")) return;
|
||||
|
||||
if (request.mode === "navigate") {
|
||||
event.respondWith(
|
||||
(async () => {
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
const cache = await caches.open(SHELL_CACHE);
|
||||
cache.put("/", response.clone()).catch(() => {});
|
||||
return response;
|
||||
} catch {
|
||||
const cache = await caches.open(SHELL_CACHE);
|
||||
return (await cache.match("/")) ?? (await cache.match("/offline")) ?? Response.error();
|
||||
}
|
||||
})(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCacheableAsset(url)) {
|
||||
event.respondWith(
|
||||
(async () => {
|
||||
const cache = await caches.open(ASSET_CACHE);
|
||||
const cached = await cache.match(request);
|
||||
const network = fetch(request)
|
||||
.then((response) => {
|
||||
if (response.ok) cache.put(request, response.clone()).catch(() => {});
|
||||
return response;
|
||||
})
|
||||
.catch(() => cached);
|
||||
return cached ?? network;
|
||||
})(),
|
||||
);
|
||||
}
|
||||
});
|
||||
32
scripts/copy-pdf-worker.mjs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
/**
|
||||
* Copies the pdf.js worker into /public so the browser can load it as a plain
|
||||
* asset. Running pdf.js on the main thread is not supported, and bundler asset
|
||||
* handling for workers differs between webpack and Turbopack, so an explicit
|
||||
* copy is the most predictable option.
|
||||
*
|
||||
* Wired to `predev` and `prebuild` in package.json.
|
||||
*/
|
||||
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const candidates = [
|
||||
"pdfjs-dist/build/pdf.worker.min.mjs",
|
||||
"pdfjs-dist/build/pdf.worker.mjs",
|
||||
];
|
||||
|
||||
const destination = join(root, "public", "pdf.worker.min.mjs");
|
||||
mkdirSync(join(root, "public"), { recursive: true });
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const source = join(root, "node_modules", candidate);
|
||||
if (existsSync(source)) {
|
||||
copyFileSync(source, destination);
|
||||
console.log(`[freezmo] copied ${candidate} -> public/pdf.worker.min.mjs`);
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
console.error("[freezmo] could not find the pdf.js worker — run `npm install` first");
|
||||
process.exit(1);
|
||||
76
scripts/make-icons.mjs
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
/**
|
||||
* Renders the Freezmo app icons (PWA + iOS) with headless Chromium.
|
||||
* Run: node scripts/make-icons.mjs (requires the Playwright browser cache)
|
||||
*/
|
||||
import { chromium } from "playwright-core";
|
||||
import { mkdirSync, existsSync } from "node:fs";
|
||||
|
||||
const CHROME = process.env.CHROME ?? "/home/martin/.cache/ms-playwright/chromium-1243/chrome-linux64/chrome";
|
||||
const OUT = new URL("../public/icons/", import.meta.url).pathname;
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
|
||||
const SNOWFLAKE = (size, stroke, inset) => `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="${size}" height="${size}">
|
||||
<g transform="translate(${inset},${inset}) scale(${(64 - inset * 2) / 64})"
|
||||
stroke="#ffffff" stroke-width="${stroke}" stroke-linecap="round" fill="none">
|
||||
<path d="M32 12v40M14.7 22l34.6 20M49.3 22 14.7 42" />
|
||||
<path d="M32 20.5 26.8 15.3M32 20.5l5.2-5.2M32 43.5l-5.2 5.2M32 43.5l5.2 5.2" />
|
||||
<path d="M20.7 27.5l-6.6-1.7M20.7 36.5l-6.6 1.7M43.3 27.5l6.6-1.7M43.3 36.5l6.6 1.7" />
|
||||
</g>
|
||||
</svg>`;
|
||||
|
||||
function pageHtml({ size, radius, inset, background, stroke }) {
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><style>
|
||||
html,body{margin:0;padding:0;background:transparent;width:${size}px;height:${size}px;overflow:hidden}
|
||||
.icon{width:${size}px;height:${size}px;border-radius:${radius}px;
|
||||
background:${background};
|
||||
display:grid;place-items:center;
|
||||
box-shadow:inset 0 1px 0 rgba(255,255,255,.18)}
|
||||
</style></head><body><div class="icon">${SNOWFLAKE(size, stroke, inset)}</div></body></html>`;
|
||||
}
|
||||
|
||||
const targets = [
|
||||
// Standard PWA icons: rounded square, iOS-like gradient-free accent
|
||||
{ file: "icon-192.png", size: 192, radius: 42, inset: 15, background: "linear-gradient(180deg,#5c88a8,#476e8c)", stroke: 3.4 },
|
||||
{ file: "icon-512.png", size: 512, radius: 112, inset: 15, background: "linear-gradient(180deg,#5c88a8,#476e8c)", stroke: 3.4 },
|
||||
// Maskable: keep the glyph inside the safe zone (80% circle)
|
||||
{ file: "maskable-512.png", size: 512, radius: 0, inset: 22, background: "linear-gradient(180deg,#5c88a8,#476e8c)", stroke: 3.6 },
|
||||
// iOS home screen icon: square, the OS applies its own mask
|
||||
{ file: "apple-touch-icon.png", size: 180, radius: 0, inset: 15, background: "linear-gradient(180deg,#5c88a8,#476e8c)", stroke: 3.4 },
|
||||
{ file: "apple-touch-icon-152.png", size: 152, radius: 0, inset: 15, background: "linear-gradient(180deg,#5c88a8,#476e8c)", stroke: 3.4 },
|
||||
{ file: "apple-touch-icon-167.png", size: 167, radius: 0, inset: 15, background: "linear-gradient(180deg,#5c88a8,#476e8c)", stroke: 3.4 },
|
||||
{ file: "favicon-32.png", size: 32, radius: 7, inset: 13, background: "linear-gradient(180deg,#5c88a8,#476e8c)", stroke: 4 },
|
||||
];
|
||||
|
||||
const browser = await chromium.launch({ executablePath: CHROME, args: ["--no-sandbox"] });
|
||||
for (const target of targets) {
|
||||
const page = await browser.newPage({ viewport: { width: target.size, height: target.size }, deviceScaleFactor: 1 });
|
||||
await page.setContent(pageHtml(target), { waitUntil: "load" });
|
||||
await page.screenshot({ path: `${OUT}${target.file}`, omitBackground: true });
|
||||
await page.close();
|
||||
console.log(` ✓ ${target.file}`);
|
||||
}
|
||||
|
||||
/* iOS splash screens are optional; a plain background keeps them tiny. */
|
||||
const splash = `<!doctype html><html><head><meta charset="utf-8"><style>
|
||||
html,body{margin:0;height:100%;background:linear-gradient(180deg,#fbfcfd,#f1f5f9);
|
||||
display:grid;place-items:center;font-family:-apple-system,system-ui,sans-serif}
|
||||
.mark{display:grid;gap:14px;justify-items:center}
|
||||
.badge{width:96px;height:96px;border-radius:24px;background:linear-gradient(180deg,#5c88a8,#476e8c);display:grid;place-items:center}
|
||||
.name{font-size:22px;font-weight:600;color:#26323b;letter-spacing:-.01em}
|
||||
</style></head><body><div class="mark"><div class="badge">${SNOWFLAKE(96, 3.4, 18)}</div><div class="name">Freezmo</div></div></body></html>`;
|
||||
|
||||
const splashTargets = [
|
||||
[1170, 2532], [1284, 2778], [1179, 2556], [1290, 2796], [750, 1334], [828, 1792], [1536, 2048], [1668, 2388],
|
||||
];
|
||||
for (const [width, height] of splashTargets) {
|
||||
const page = await browser.newPage({ viewport: { width, height }, deviceScaleFactor: 1 });
|
||||
await page.setContent(splash, { waitUntil: "load" });
|
||||
await page.screenshot({ path: `${OUT}splash-${width}x${height}.png` });
|
||||
await page.close();
|
||||
console.log(` ✓ splash-${width}x${height}.png`);
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
if (!existsSync(`${OUT}icon-512.png`)) process.exit(1);
|
||||
console.log("icons written to public/icons/");
|
||||
102
setup.sh
Executable file
|
|
@ -0,0 +1,102 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Freezmo setup — install dependencies, create a local env file and (by default)
|
||||
# produce a production build.
|
||||
#
|
||||
# Usage:
|
||||
# ./setup.sh install + create .env.local + production build
|
||||
# ./setup.sh --dev install + create .env.local, but no build (for `npm run dev`)
|
||||
# ./setup.sh --no-build install + create .env.local only
|
||||
#
|
||||
# Requires: Node.js 20+ and npm. Nothing here needs sudo or a global install.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# --- pretty output ---------------------------------------------------------
|
||||
bold="$(printf '\033[1m')"
|
||||
dim="$(printf '\033[2m')"
|
||||
green="$(printf '\033[32m')"
|
||||
yellow="$(printf '\033[33m')"
|
||||
red="$(printf '\033[31m')"
|
||||
reset="$(printf '\033[0m')"
|
||||
|
||||
info() { printf '%s[freezmo]%s %s\n' "$green" "$reset" "$1"; }
|
||||
warn() { printf '%s[freezmo]%s %s\n' "$yellow" "$reset" "$1"; }
|
||||
fail() { printf '%s[freezmo]%s %s\n' "$red" "$reset" "$1" >&2; exit 1; }
|
||||
|
||||
# --- arguments -------------------------------------------------------------
|
||||
mode="build"
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--dev|dev) mode="dev" ;;
|
||||
--no-build|--install-only) mode="install" ;;
|
||||
-h|--help)
|
||||
sed -n '2,11p' "$0" | sed 's/^# \{0,1\}//'
|
||||
exit 0
|
||||
;;
|
||||
*) fail "Unknown argument: $arg (try --help)" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Run from the directory that contains this script, so `./setup.sh` works from
|
||||
# anywhere.
|
||||
cd "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
info "Setting up Freezmo in ${bold}$PWD${reset}"
|
||||
|
||||
# --- prerequisites ---------------------------------------------------------
|
||||
command -v node >/dev/null 2>&1 || fail "Node.js is not installed. Install Node.js 20+ and try again."
|
||||
command -v npm >/dev/null 2>&1 || fail "npm is not installed. It ships with Node.js."
|
||||
|
||||
node_major="$(node -p 'process.versions.node.split(".")[0]')"
|
||||
if [ "$node_major" -lt 20 ]; then
|
||||
fail "Node.js 20+ is required (found $(node -v))."
|
||||
fi
|
||||
info "Node $(node -v) · npm $(npm -v)"
|
||||
|
||||
# --- environment file ------------------------------------------------------
|
||||
if [ ! -f .env.local ]; then
|
||||
if [ -f .env.example ]; then
|
||||
cp .env.example .env.local
|
||||
info "Created ${bold}.env.local${reset} from .env.example (bring-your-own-key mode by default)."
|
||||
else
|
||||
warn ".env.example is missing — the app will fall back to built-in defaults."
|
||||
fi
|
||||
else
|
||||
info "Keeping the existing .env.local (nothing overwritten)."
|
||||
fi
|
||||
|
||||
# --- dependencies ----------------------------------------------------------
|
||||
info "Installing dependencies with npm…"
|
||||
if [ -f package-lock.json ]; then
|
||||
npm ci
|
||||
else
|
||||
npm install
|
||||
fi
|
||||
|
||||
# --- build -----------------------------------------------------------------
|
||||
if [ "$mode" = "install" ]; then
|
||||
info "Dependencies installed. Skipping the build (--no-build)."
|
||||
elif [ "$mode" = "dev" ]; then
|
||||
info "Dependencies installed. Skipping the build (--dev)."
|
||||
else
|
||||
info "Building the production bundle…"
|
||||
npm run build
|
||||
fi
|
||||
|
||||
# --- next steps ------------------------------------------------------------
|
||||
printf '\n%sFreezmo is ready.%s\n\n' "$bold$green" "$reset"
|
||||
if [ "$mode" = "dev" ] || [ "$mode" = "install" ]; then
|
||||
printf ' Start the dev server: %snpm run dev%s → http://localhost:3207\n' "$bold" "$reset"
|
||||
else
|
||||
printf ' Start production: %snpm start%s → http://localhost:3207\n' "$bold" "$reset"
|
||||
printf ' Or for development: %snpm run dev%s\n' "$bold" "$reset"
|
||||
fi
|
||||
printf '\n'
|
||||
printf '%sAuthentication%s (edit .env.local, restart the server afterwards):\n' "$dim" "$reset"
|
||||
printf ' OLLAMA_AUTH=user-key each browser pastes its own key in Settings (default)\n'
|
||||
printf ' OLLAMA_AUTH=server one key from OLLAMA_API_KEY in .env.local\n'
|
||||
printf ' OLLAMA_AUTH=local a local daemon signed in with `ollama signin`\n'
|
||||
printf '\n'
|
||||
printf 'Models are chosen in .env.local: VISION_MODEL (multimodal) and TEXT_MODEL.\n'
|
||||
printf 'The app is served on 0.0.0.0:3207, so phones on the same network can reach it.\n'
|
||||
printf 'Details: %sREADME.md%s\n' "$bold" "$reset"
|
||||