commit 23b24b5d21befeefd62b17b08fbc9bad6fd0cbb4 Author: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Tue Jul 14 12:05:52 2026 -0400 Add GitHub Actions workflow and update docker-compose to use GHCR Co-Authored-By: Claude Sonnet 4.6 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..e4edc11 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,31 @@ +name: Build and Push Docker Image + +on: + push: + branches: + - main + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ghcr.io/nmemmert/spelling:latest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8126cdc --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# Dependencies +node_modules/ +npm-debug.log* + +# App data (SQLite database) +data/ + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +Thumbs.db + +# IDE files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Backup files +*.bak +*.tmp +*.backup + +# Environment variables +.env +.env.local diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..188a9de --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM node:26-alpine +WORKDIR /app +COPY package*.json ./ +RUN npm ci --omit=dev +COPY . . +ENV NODE_ENV=production +ENV DATA_DIR=/app/data +EXPOSE 3000 +HEALTHCHECK --interval=30s --timeout=10s --retries=3 \ + CMD wget -q -O - http://localhost:3000/health || exit 1 +CMD ["node", "server.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..48a0648 --- /dev/null +++ b/README.md @@ -0,0 +1,52 @@ +# πŸŽ“ Homeschool + +A self-hosted homeschool learning platform β€” courses, lessons, assignments, quizzes, and a gradebook β€” with spelling practice and flashcards built in as first-class activity types. Two apps share one server: a kid-facing app (`index.html`) and a PIN-protected parent console (`admin.html`). + +## How it's organized + +- **Courses β†’ Units β†’ Items.** A parent builds courses (e.g. "Science 3"), each broken into units (e.g. "Week 1: Weather"), each holding items: lessons, assignments, quizzes, or an embedded spelling/flashcards activity. +- **Lessons** are parent-authored content (text, images, YouTube embeds) the kid marks as read. +- **Assignments** are turned in with a tap ("Mark as done") and graded by a parent from a grading queue. +- **Quizzes** (multiple choice, true/false, short answer) are auto-graded on submission, with a right/wrong review shown immediately. +- **Spelling and flashcards** activities reuse the same engines described below, and their results flow into the same gradebook. +- **Daily agenda.** A parent schedules items (or free-text offline tasks like "Read Ch. 3") onto specific days in the weekly planner; the kid's home screen shows "Today's work" as a checklist that fills in as they go. +- **Gradebook.** Every graded item (assignments, quizzes, spelling tests) rolls up per course into a percentage, with a printable weekly report for homeschool portfolio records. + +## How the spelling module works + +- **Dictation practice** β€” the browser speaks each word (and an example sentence if the list has one); the kid types it. No internet or API keys needed β€” it uses the browser's built-in speech synthesis. +- **Letter-diff feedback** β€” a wrong answer shows exactly which letters were wrong, then the correct word appears for a few seconds, hides, and the kid retypes it from memory (look–cover–write–check). +- **Leitner spaced repetition** β€” every word has a box (0–5). Right on the first try moves it up a box and pushes the next review out (1 β†’ 2 β†’ 4 β†’ 7 β†’ 14 days). A miss sends it back to box 1. Box 5 = mastered. Daily practice mixes the current list with whatever old words are due. +- **Spelling test** β€” every word on the list, dictation only, no hints or retries. Scores are saved, and missed words automatically re-enter the practice rotation. + +## How the flashcards module works + +Classic flip-cards (front β†’ reveal β†’ "Got it!" / "Study more") using the same Leitner scheduler as spelling, so a deck's due cards resurface on the right day automatically. Works standalone or as a course item. + +## Running it + +```bash +npm install +npm start # http://localhost:3000 +``` + +Or with Docker: + +```bash +docker compose up -d +``` + +The SQLite database lives in `./data/` (mounted as a volume in Docker). + +Requires Node 24+ (uses the built-in `node:sqlite` module β€” no native dependencies). + +## First-time setup + +1. Open the app β†’ **Parent dashboard** (`/admin.html`) β†’ PIN `1234` (change it in Settings). +2. Add your kids. +3. Build a course: add units, then add items (lessons, assignments, quizzes, or a spelling/flashcards activity pointing at a list or deck). +4. Enroll kids in the course, from the course's Roster section. +5. Use the Weekly Planner to schedule items onto specific days β€” or let kids browse everything anytime from **My Courses**. +6. For spelling specifically: pick a built-in Dolch list or create your own (one word per line, optional `word | example sentence`), then assign each kid a weekly list from the Spelling panel. + +Kids just tap their name β€” no passwords. Everything they do (course work or spelling) reports back into the parent console automatically. diff --git a/db.js b/db.js new file mode 100644 index 0000000..8041206 --- /dev/null +++ b/db.js @@ -0,0 +1,227 @@ +import { DatabaseSync } from 'node:sqlite'; +import { createHash } from 'node:crypto'; +import { mkdirSync, readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = dirname(fileURLToPath(import.meta.url)); +const DATA_DIR = process.env.DATA_DIR || join(ROOT, 'data'); +mkdirSync(DATA_DIR, { recursive: true }); + +export const db = new DatabaseSync(join(DATA_DIR, 'spelling.db')); + +db.exec(` + PRAGMA journal_mode = WAL; + PRAGMA foreign_keys = ON; + + CREATE TABLE IF NOT EXISTS students ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + emoji TEXT NOT NULL DEFAULT 'πŸ™‚', + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS lists ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + builtin INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS words ( + id INTEGER PRIMARY KEY, + list_id INTEGER NOT NULL REFERENCES lists(id) ON DELETE CASCADE, + word TEXT NOT NULL, + sentence TEXT NOT NULL DEFAULT '' + ); + + -- one active weekly assignment per student + CREATE TABLE IF NOT EXISTS assignments ( + student_id INTEGER PRIMARY KEY REFERENCES students(id) ON DELETE CASCADE, + list_id INTEGER NOT NULL REFERENCES lists(id) ON DELETE CASCADE, + assigned_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + -- Leitner state per student per word. box 0 = new, 5 = mastered. + CREATE TABLE IF NOT EXISTS progress ( + student_id INTEGER NOT NULL REFERENCES students(id) ON DELETE CASCADE, + word_id INTEGER NOT NULL REFERENCES words(id) ON DELETE CASCADE, + box INTEGER NOT NULL DEFAULT 0, + due TEXT NOT NULL DEFAULT (date('now')), + attempts INTEGER NOT NULL DEFAULT 0, + correct INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (student_id, word_id) + ); + + CREATE TABLE IF NOT EXISTS attempts ( + id INTEGER PRIMARY KEY, + student_id INTEGER NOT NULL REFERENCES students(id) ON DELETE CASCADE, + word_id INTEGER NOT NULL REFERENCES words(id) ON DELETE CASCADE, + typed TEXT NOT NULL, + correct INTEGER NOT NULL, + mode TEXT NOT NULL, + at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS tests ( + id INTEGER PRIMARY KEY, + student_id INTEGER NOT NULL REFERENCES students(id) ON DELETE CASCADE, + list_id INTEGER NOT NULL REFERENCES lists(id) ON DELETE CASCADE, + score INTEGER NOT NULL, + total INTEGER NOT NULL, + at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS test_answers ( + test_id INTEGER NOT NULL REFERENCES tests(id) ON DELETE CASCADE, + word_id INTEGER NOT NULL REFERENCES words(id) ON DELETE CASCADE, + typed TEXT NOT NULL, + correct INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + + -- flashcards module + CREATE TABLE IF NOT EXISTS decks ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + builtin INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS cards ( + id INTEGER PRIMARY KEY, + deck_id INTEGER NOT NULL REFERENCES decks(id) ON DELETE CASCADE, + front TEXT NOT NULL, + back TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS card_progress ( + student_id INTEGER NOT NULL REFERENCES students(id) ON DELETE CASCADE, + card_id INTEGER NOT NULL REFERENCES cards(id) ON DELETE CASCADE, + box INTEGER NOT NULL DEFAULT 0, + due TEXT NOT NULL DEFAULT (date('now')), + attempts INTEGER NOT NULL DEFAULT 0, + correct INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (student_id, card_id) + ); + + -- ---------- LMS layer ---------- + + CREATE TABLE IF NOT EXISTS courses ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + subject TEXT NOT NULL DEFAULT '', + color TEXT NOT NULL DEFAULT '#4f86f7', + archived INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS enrollments ( + student_id INTEGER NOT NULL REFERENCES students(id) ON DELETE CASCADE, + course_id INTEGER NOT NULL REFERENCES courses(id) ON DELETE CASCADE, + PRIMARY KEY (student_id, course_id) + ); + + CREATE TABLE IF NOT EXISTS units ( + id INTEGER PRIMARY KEY, + course_id INTEGER NOT NULL REFERENCES courses(id) ON DELETE CASCADE, + name TEXT NOT NULL, + sort INTEGER NOT NULL DEFAULT 0 + ); + + -- type: lesson | assignment | quiz | spelling_practice | spelling_test | flashcards + -- ref_id: list_id for spelling_*, deck_id for flashcards. points: max score for assignment/quiz. + CREATE TABLE IF NOT EXISTS items ( + id INTEGER PRIMARY KEY, + unit_id INTEGER NOT NULL REFERENCES units(id) ON DELETE CASCADE, + type TEXT NOT NULL, + title TEXT NOT NULL, + body TEXT NOT NULL DEFAULT '', + points INTEGER NOT NULL DEFAULT 0, + ref_id INTEGER, + sort INTEGER NOT NULL DEFAULT 0 + ); + + -- type: mc | tf | short + CREATE TABLE IF NOT EXISTS quiz_questions ( + id INTEGER PRIMARY KEY, + item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE, + type TEXT NOT NULL, + prompt TEXT NOT NULL, + choices TEXT NOT NULL DEFAULT '[]', + correct_answer TEXT NOT NULL, + points INTEGER NOT NULL DEFAULT 1, + sort INTEGER NOT NULL DEFAULT 0 + ); + + -- one row per student per item. status: done (ungraded) | graded + CREATE TABLE IF NOT EXISTS submissions ( + id INTEGER PRIMARY KEY, + student_id INTEGER NOT NULL REFERENCES students(id) ON DELETE CASCADE, + item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE, + status TEXT NOT NULL DEFAULT 'done', + score REAL, + points_possible REAL, + answers TEXT, + completed_at TEXT NOT NULL DEFAULT (datetime('now')), + graded_at TEXT, + UNIQUE(student_id, item_id) + ); + + -- daily agenda. item_id set = a scheduled course item; null = a standalone offline task (title used) + CREATE TABLE IF NOT EXISTS schedule ( + id INTEGER PRIMARY KEY, + student_id INTEGER NOT NULL REFERENCES students(id) ON DELETE CASCADE, + date TEXT NOT NULL, + item_id INTEGER REFERENCES items(id) ON DELETE CASCADE, + title TEXT NOT NULL DEFAULT '', + done INTEGER NOT NULL DEFAULT 0, + done_at TEXT, + sort INTEGER NOT NULL DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS idx_schedule_day ON schedule(student_id, date); +`); + +export const sha256 = (s) => createHash('sha256').update(String(s)).digest('hex'); + +// Default parent PIN on first run +if (!db.prepare(`SELECT value FROM settings WHERE key = 'pin'`).get()) { + db.prepare(`INSERT INTO settings (key, value) VALUES ('pin', ?)`).run(sha256('1234')); +} + +// Default scan config (local Ollama primary, no cloud key yet) +const seedSetting = (key, val) => { + if (!db.prepare(`SELECT value FROM settings WHERE key = ?`).get(key)) + db.prepare(`INSERT INTO settings (key, value) VALUES (?, ?)`).run(key, val); +}; +seedSetting('scan_primary', 'local'); +seedSetting('ollama_host', 'http://localhost:11434'); +seedSetting('ollama_model', 'llava'); +seedSetting('cloud_endpoint', 'https://api.openai.com/v1'); +seedSetting('cloud_model', 'gpt-4o'); + +// Seed bundled word lists on first run +if (db.prepare(`SELECT COUNT(*) AS n FROM lists`).get().n === 0) { + const seed = JSON.parse(readFileSync(join(ROOT, 'seed-lists.json'), 'utf8')); + const insList = db.prepare(`INSERT INTO lists (name, builtin) VALUES (?, 1)`); + const insWord = db.prepare(`INSERT INTO words (list_id, word, sentence) VALUES (?, ?, ?)`); + for (const list of seed) { + const listId = insList.run(list.name).lastInsertRowid; + for (const entry of list.words) { + const { word, sentence = '' } = typeof entry === 'string' ? { word: entry } : entry; + insWord.run(listId, word, sentence); + } + } +} + +// Seed bundled flashcard decks on first run +if (db.prepare(`SELECT COUNT(*) AS n FROM decks`).get().n === 0) { + const seed = JSON.parse(readFileSync(join(ROOT, 'seed-decks.json'), 'utf8')); + const insDeck = db.prepare(`INSERT INTO decks (name, builtin) VALUES (?, 1)`); + const insCard = db.prepare(`INSERT INTO cards (deck_id, front, back) VALUES (?, ?, ?)`); + for (const deck of seed) { + const deckId = insDeck.run(deck.name).lastInsertRowid; + for (const c of deck.cards) insCard.run(deckId, c.front, c.back); + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..bba39de --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,9 @@ +services: + spelling: + image: ghcr.io/nmemmert/spelling:latest + container_name: spelling-v2 + ports: + - "3000:3000" + volumes: + - ./data:/app/data + restart: unless-stopped diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..7321b22 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,865 @@ +{ + "name": "spelling-v2", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "spelling-v2", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "express": "^5.1.0" + }, + "engines": { + "node": ">=24" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..1c5d84d --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "spelling-v2", + "version": "1.0.0", + "description": "Homeschool spelling practice β€” dictation, spaced repetition, and weekly tests", + "main": "server.js", + "type": "module", + "scripts": { + "start": "node server.js", + "dev": "node --watch server.js" + }, + "author": "NateEmmert", + "license": "ISC", + "dependencies": { + "express": "^5.1.0" + }, + "engines": { + "node": ">=24" + } +} diff --git a/public/admin.html b/public/admin.html new file mode 100644 index 0000000..7ea3569 --- /dev/null +++ b/public/admin.html @@ -0,0 +1,286 @@ + + + + + + Parent Dashboard + + + + +
+

πŸ‘©β€πŸ« Parent Dashboard

+
+ + +
+ +

Default PIN is 1234 β€” change it in Settings after unlocking.

+

← Back to kid picker

+
+ + + + + + diff --git a/public/admin.js b/public/admin.js new file mode 100644 index 0000000..1ba8151 --- /dev/null +++ b/public/admin.js @@ -0,0 +1,965 @@ +// ---------- tiny helpers ---------- + +const $ = (sel) => document.querySelector(sel); +const esc = (s) => + String(s).replace(/[&<>"']/g, (c) => + ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]) + ); + +let parentPin = sessionStorage.getItem('pin') || null; + +async function api(path, opts = {}) { + const headers = { 'Content-Type': 'application/json' }; + if (parentPin) headers['x-pin'] = parentPin; + const res = await fetch(path, { + method: opts.method || 'GET', + headers, + body: opts.body ? JSON.stringify(opts.body) : undefined, + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Request failed'); + return data; +} + +function msg(text) { + $('#admin-msg').textContent = text; + setTimeout(() => ($('#admin-msg').textContent = ''), 4000); +} + +const TYPE_ICON = { + lesson: 'πŸ“–', assignment: 'πŸ“', quiz: '❓', + spelling_practice: '✏️', spelling_test: '⭐', flashcards: 'πŸ—‚οΈ', +}; +const TYPE_LABEL = { + lesson: 'Lesson', assignment: 'Assignment', quiz: 'Quiz', + spelling_practice: 'Spelling Practice', spelling_test: 'Spelling Test', flashcards: 'Flashcards', +}; + +// ---------- PIN gate ---------- + +$('#pin-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const pin = $('#pin-input').value; + const { ok } = await api('/api/parent/verify', { method: 'POST', body: { pin } }); + if (!ok) return ($('#pin-error').hidden = false); + parentPin = pin; + sessionStorage.setItem('pin', pin); + $('#pin-error').hidden = true; + unlock(); +}); + +async function unlock() { + $('#pin-gate').hidden = true; + $('#console').hidden = false; + showPanel('kids'); +} + +if (parentPin) unlock(); + +// ---------- navigation ---------- + +document.querySelectorAll('.admin-nav .nav-pill').forEach((pill) => + pill.addEventListener('click', () => showPanel(pill.dataset.panel)) +); +document.querySelectorAll('[data-nav]').forEach((btn) => + btn.addEventListener('click', () => showPanel(btn.dataset.nav)) +); + +function showPanel(name) { + document.querySelectorAll('.panel').forEach((p) => (p.hidden = p.id !== `panel-${name}`)); + document.querySelectorAll('.admin-nav .nav-pill').forEach((p) => p.classList.toggle('active', p.dataset.panel === name)); + const loaders = { + kids: loadKids, courses: loadCourses, planner: loadPlannerPanel, + grading: loadGrading, gradebook: loadGradebookPanel, spelling: loadSpelling, decks: loadDecks, + settings: loadOllamaConfig, + }; + if (loaders[name]) loaders[name](); + if (name === 'course-detail') openCourseDetail(currentCourseId); +} + +// ============================================================ +// Kids +// ============================================================ + +async function loadKids() { + const students = await api('/api/students'); + $('#student-rows').innerHTML = students.length + ? students + .map( + (s) => `
+ ${esc(s.emoji)} + ${esc(s.name)} + +
` + ) + .join('') + : `

No kids yet β€” add one below.

`; + + document.querySelectorAll('[data-del-student]').forEach((btn) => + btn.addEventListener('click', async () => { + if (!confirm('Remove this kid and all their progress? This cannot be undone.')) return; + await api(`/api/students/${btn.dataset.delStudent}`, { method: 'DELETE' }); + loadKids(); + }) + ); +} + +$('#add-student-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const name = $('#new-student-name').value.trim(); + if (!name) return; + await api('/api/students', { method: 'POST', body: { name, emoji: $('#new-student-emoji').value } }); + $('#new-student-name').value = ''; + loadKids(); +}); + +// ============================================================ +// Courses list + detail (units, items, roster) +// ============================================================ + +let currentCourseId = null; +let cachedLists = []; +let cachedDecks = []; + +async function loadCourses() { + const courses = await api('/api/admin/courses'); + $('#course-rows').innerHTML = courses.length + ? courses + .map( + (c) => `
+ + ${esc(c.name)} + ${esc(c.subject || '')} + ${c.unitCount} unit${c.unitCount === 1 ? '' : 's'}, ${c.itemCount} item${c.itemCount === 1 ? '' : 's'} + ${c.archived ? 'archived' : ''} + +
` + ) + .join('') + : `

No courses yet β€” add one below.

`; + + document.querySelectorAll('[data-open-course]').forEach((btn) => + btn.addEventListener('click', () => { + currentCourseId = Number(btn.dataset.openCourse); + showPanel('course-detail'); + }) + ); +} + +$('#add-course-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const name = $('#new-course-name').value.trim(); + if (!name) return; + const { id } = await api('/api/courses', { + method: 'POST', + body: { name, subject: $('#new-course-subject').value.trim(), color: $('#new-course-color').value }, + }); + $('#new-course-name').value = ''; + $('#new-course-subject').value = ''; + currentCourseId = id; + showPanel('course-detail'); +}); + +async function openCourseDetail(id) { + if (!id) return showPanel('courses'); + const [course, roster] = await Promise.all([ + api(`/api/admin/courses/${id}`), + api(`/api/courses/${id}/roster`), + ]); + $('#cd-name').textContent = course.name; + $('#cd-name-input').value = course.name; + $('#cd-subject-input').value = course.subject || ''; + $('#cd-color-input').value = course.color; + $('#cd-archived').checked = !!course.archived; + + $('#cd-roster').innerHTML = roster + .map( + (s) => `` + ) + .join(''); + document.querySelectorAll('[data-roster-student]').forEach((box) => + box.addEventListener('change', async () => { + if (box.checked) await api(`/api/courses/${id}/enroll`, { method: 'POST', body: { studentId: box.dataset.rosterStudent } }); + else await api(`/api/courses/${id}/enroll/${box.dataset.rosterStudent}`, { method: 'DELETE' }); + }) + ); + + $('#cd-units').innerHTML = course.units + .map( + (u) => `
+
+

${esc(u.name)}

+ +
+ ${u.items + .map( + (it) => `
+ ${TYPE_ICON[it.type]} + ${esc(it.title)} + ${TYPE_LABEL[it.type]} + +
` + ) + .join('')} + +
` + ) + .join('') || `

No units yet β€” add one below.

`; + + document.querySelectorAll('[data-del-unit]').forEach((btn) => + btn.addEventListener('click', async () => { + if (!confirm('Delete this unit and everything in it?')) return; + await api(`/api/units/${btn.dataset.delUnit}`, { method: 'DELETE' }); + openCourseDetail(id); + }) + ); + document.querySelectorAll('[data-add-item]').forEach((btn) => + btn.addEventListener('click', () => openItemEditor(btn.dataset.addItem, null)) + ); + document.querySelectorAll('[data-edit-item]').forEach((btn) => + btn.addEventListener('click', () => openItemEditor(btn.dataset.unitId, btn.dataset.editItem)) + ); + + document.querySelectorAll('.panel').forEach((p) => (p.hidden = p.id !== 'panel-course-detail')); + document.querySelectorAll('.admin-nav .nav-pill').forEach((p) => p.classList.remove('active')); +} + +$('#course-meta-form').addEventListener('submit', async (e) => { + e.preventDefault(); + await api(`/api/courses/${currentCourseId}`, { + method: 'PUT', + body: { + name: $('#cd-name-input').value.trim(), + subject: $('#cd-subject-input').value.trim(), + color: $('#cd-color-input').value, + archived: $('#cd-archived').checked, + }, + }); + msg('Course saved.'); + openCourseDetail(currentCourseId); +}); + +$('#cd-delete').addEventListener('click', async () => { + if (!confirm('Delete this whole course, including all units, items, and grades?')) return; + await api(`/api/courses/${currentCourseId}`, { method: 'DELETE' }); + currentCourseId = null; + showPanel('courses'); +}); + +$('#add-unit-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const name = $('#new-unit-name').value.trim(); + if (!name) return; + await api('/api/units', { method: 'POST', body: { courseId: currentCourseId, name } }); + $('#new-unit-name').value = ''; + openCourseDetail(currentCourseId); +}); + +// ============================================================ +// Item editor +// ============================================================ + +let questionCount = 0; + +async function openItemEditor(unitId, itemId) { + [cachedLists, cachedDecks] = await Promise.all([api('/api/lists'), api('/api/decks')]); + $('#ie-list').innerHTML = cachedLists.map((l) => ``).join(''); + $('#ie-deck').innerHTML = cachedDecks.map((d) => ``).join(''); + + $('#ie-id').value = itemId || ''; + $('#ie-unit-id').value = unitId; + $('#ie-error').hidden = true; + $('#ie-questions').innerHTML = ''; + questionCount = 0; + + if (itemId) { + const item = await api(`/api/admin/items/${itemId}`); + $('#ie-heading').textContent = `Edit: ${item.title}`; + $('#ie-type').value = item.type; + $('#ie-title').value = item.title; + $('#ie-body-lesson').value = item.body; + $('#ie-body-assignment').value = item.body; + $('#ie-points').value = item.points || 10; + if (item.type === 'spelling_practice' || item.type === 'spelling_test') $('#ie-list').value = item.ref_id; + if (item.type === 'flashcards') $('#ie-deck').value = item.ref_id; + if (item.type === 'quiz') (item.questions || []).forEach(addQuestionRow); + $('#ie-delete').hidden = false; + $('#ie-delete').onclick = async () => { + if (!confirm('Delete this item and any grades for it?')) return; + await api(`/api/items/${itemId}`, { method: 'DELETE' }); + showPanel('course-detail'); + }; + } else { + $('#ie-heading').textContent = 'New item'; + $('#ie-type').value = 'lesson'; + $('#ie-title').value = ''; + $('#ie-body-lesson').value = ''; + $('#ie-body-assignment').value = ''; + $('#ie-points').value = 10; + $('#ie-delete').hidden = true; + } + updateItemFieldVisibility(); + resetScanSection(); + showPanel('item-editor'); +} + +function updateItemFieldVisibility() { + const type = $('#ie-type').value; + $('#ie-field-lesson').hidden = type !== 'lesson'; + $('#ie-field-assignment').hidden = type !== 'assignment'; + $('#ie-field-quiz').hidden = type !== 'quiz'; + $('#ie-field-spelling').hidden = type !== 'spelling_practice' && type !== 'spelling_test'; + $('#ie-field-flashcards').hidden = type !== 'flashcards'; + $('#ie-scan-section').hidden = type !== 'lesson' && type !== 'quiz'; +} +$('#ie-type').addEventListener('change', updateItemFieldVisibility); + +// ---------- scan a page (photo -> lesson/quiz content via local Ollama) ---------- + +let scanImageBase64 = null; + +function resetScanSection() { + scanImageBase64 = null; + $('#scan-file-input').value = ''; + $('#scan-preview').hidden = true; + $('#scan-preview').src = ''; + $('#scan-go-btn').disabled = true; + $('#scan-status').textContent = ''; +} + +function fileToScanImage(file, maxDim = 1600, quality = 0.85) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = () => reject(new Error('Could not read that file')); + reader.onload = () => { + const img = new Image(); + img.onerror = () => reject(new Error('Could not read that image')); + img.onload = () => { + let { width, height } = img; + if (width > maxDim || height > maxDim) { + const scale = maxDim / Math.max(width, height); + width = Math.round(width * scale); + height = Math.round(height * scale); + } + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + canvas.getContext('2d').drawImage(img, 0, 0, width, height); + resolve(canvas.toDataURL('image/jpeg', quality)); + }; + img.src = reader.result; + }; + reader.readAsDataURL(file); + }); +} + +$('#scan-pick-btn').addEventListener('click', () => $('#scan-file-input').click()); + +$('#scan-file-input').addEventListener('change', async () => { + const file = $('#scan-file-input').files[0]; + if (!file) return; + try { + const dataUrl = await fileToScanImage(file); + scanImageBase64 = dataUrl.split(',')[1]; + $('#scan-preview').src = dataUrl; + $('#scan-preview').hidden = false; + $('#scan-go-btn').disabled = false; + $('#scan-status').textContent = ''; + } catch (err) { + $('#scan-status').textContent = `❌ ${err.message}`; + } +}); + +$('#scan-go-btn').addEventListener('click', async () => { + if (!scanImageBase64) return; + const mode = $('#ie-type').value === 'quiz' ? 'quiz' : 'lesson'; + $('#scan-go-btn').disabled = true; + $('#scan-status').textContent = 'πŸ” Reading the page… this can take a minute or two on local hardware.'; + try { + const result = await api('/api/admin/scan', { method: 'POST', body: { image: scanImageBase64, mode } }); + if (result.title) $('#ie-title').value = result.title; + const providerLabel = result._provider === 'cloud' ? 'cloud API' : 'local Ollama'; + const fallbackNote = result._fallback ? ` (primary failed β€” used ${providerLabel} as fallback: ${result._primaryError})` : ` via ${providerLabel}`; + if (mode === 'lesson') { + $('#ie-body-lesson').value = result.body || ''; + $('#scan-status').textContent = `βœ… Scanned${fallbackNote}. Review the text below before saving.`; + } else { + $('#ie-questions').innerHTML = ''; + questionCount = 0; + // addQuestionRow expects correct_answer (snake_case); scan returns correctAnswer (camelCase) β€” normalize here. + (result.questions || []).forEach((q) => addQuestionRow({ ...q, correct_answer: q.correctAnswer })); + const blanks = (result.questions || []).filter((q) => !q.correctAnswer).length; + $('#scan-status').textContent = `βœ… Found ${result.questions?.length || 0} question(s)${fallbackNote}` + + (blanks ? ` β€” ${blanks} need${blanks === 1 ? 's' : ''} a correct answer` : '') + '. Review before saving.'; + } + } catch (err) { + $('#scan-status').textContent = `❌ ${err.message}`; + } finally { + $('#scan-go-btn').disabled = false; + } +}); + +function addQuestionRow(q = {}) { + const n = questionCount++; + const div = document.createElement('div'); + div.className = 'quiz-q-row'; + div.dataset.qn = n; + div.innerHTML = ` + + + + + + `; + div.querySelector('.qn-remove').addEventListener('click', () => div.remove()); + $('#ie-questions').appendChild(div); +} +$('#ie-add-question').addEventListener('click', () => addQuestionRow()); + +$('#item-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const type = $('#ie-type').value; + const title = $('#ie-title').value.trim(); + const body = { + unitId: Number($('#ie-unit-id').value), + type, + title, + body: type === 'lesson' ? $('#ie-body-lesson').value : type === 'assignment' ? $('#ie-body-assignment').value : '', + points: type === 'assignment' ? Number($('#ie-points').value) || 0 : 0, + refId: type === 'spelling_practice' || type === 'spelling_test' ? Number($('#ie-list').value) + : type === 'flashcards' ? Number($('#ie-deck').value) : null, + }; + if (type === 'quiz') { + body.questions = Array.from(document.querySelectorAll('.quiz-q-row')).map((row) => ({ + type: row.querySelector('.qn-type').value, + prompt: row.querySelector('.qn-prompt').value.trim(), + choices: row.querySelector('.qn-choices').value.split(',').map((c) => c.trim()).filter(Boolean), + correctAnswer: row.querySelector('.qn-correct').value.trim(), + points: Number(row.querySelector('.qn-points').value) || 1, + })); + } + + const id = $('#ie-id').value; + try { + if (id) await api(`/api/items/${id}`, { method: 'PUT', body }); + else await api('/api/items', { method: 'POST', body }); + msg('Item saved.'); + showPanel('course-detail'); + } catch (err) { + $('#ie-error').textContent = err.message; + $('#ie-error').hidden = false; + } +}); + +// ============================================================ +// Planner +// ============================================================ + +function mondayOf(d) { + const date = new Date(d); + const day = date.getDay(); + const diff = day === 0 ? -6 : 1 - day; + date.setDate(date.getDate() + diff); + return date.toISOString().slice(0, 10); +} +function addDays(dateStr, n) { + const d = new Date(dateStr + 'T00:00:00'); + d.setDate(d.getDate() + n); + return d.toISOString().slice(0, 10); +} + +let plannerWeekStart = mondayOf(new Date()); +let plannerStudentId = null; +let plannerCoursesCache = []; + +async function loadPlannerPanel() { + const students = await api('/api/students'); + if (students.length === 0) { + $('#planner-grid').innerHTML = `

Add a kid first.

`; + return; + } + $('#planner-student').innerHTML = students.map((s) => ``).join(''); + if (!plannerStudentId || !students.some((s) => s.id === plannerStudentId)) plannerStudentId = students[0].id; + $('#planner-student').value = plannerStudentId; + await renderPlanner(); +} + +$('#planner-student').addEventListener('change', () => { + plannerStudentId = Number($('#planner-student').value); + renderPlanner(); +}); +$('#planner-prev').addEventListener('click', () => { plannerWeekStart = addDays(plannerWeekStart, -7); renderPlanner(); }); +$('#planner-next').addEventListener('click', () => { plannerWeekStart = addDays(plannerWeekStart, 7); renderPlanner(); }); + +$('#planner-copy').addEventListener('click', async () => { + const from = addDays(plannerWeekStart, -7); + const { copied } = await api('/api/schedule/copy', { method: 'POST', body: { studentId: plannerStudentId, from, to: plannerWeekStart } }); + msg(`Copied ${copied} task${copied === 1 ? '' : 's'} from last week.`); + renderPlanner(); +}); + +$('#planner-print').addEventListener('click', () => printWeekReport(plannerStudentId, plannerWeekStart)); + +const DAY_NAMES = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday']; + +async function renderPlanner() { + const end = addDays(plannerWeekStart, 4); + $('#planner-week-label').textContent = `${plannerWeekStart} β†’ ${end}`; + const [{ tasks }, courses] = await Promise.all([ + api(`/api/schedule-week/${plannerStudentId}?start=${plannerWeekStart}`), + api(`/api/courses/mine/${plannerStudentId}`), + ]); + plannerCoursesCache = courses; + + const days = DAY_NAMES.map((name, i) => ({ name, date: addDays(plannerWeekStart, i) })); + $('#planner-grid').innerHTML = days + .map((day) => { + const dayTasks = tasks.filter((t) => t.date === day.date); + return `
+

${day.name}${day.date}

+
+ ${dayTasks + .map( + (t) => `
+ ${t.itemId ? TYPE_ICON[t.type] : 'πŸ“Œ'} ${esc(t.itemTitle || t.offlineTitle)} + +
` + ) + .join('') || '

Nothing planned

'} +
+ + +
`; + }) + .join(''); + + document.querySelectorAll('[data-del-schedule]').forEach((btn) => + btn.addEventListener('click', async () => { + await api(`/api/schedule/${btn.dataset.delSchedule}`, { method: 'DELETE' }); + renderPlanner(); + }) + ); + document.querySelectorAll('[data-add-task]').forEach((btn) => + btn.addEventListener('click', () => toggleAddTaskForm(btn.dataset.addTask)) + ); +} + +function toggleAddTaskForm(date) { + const box = document.querySelector(`[data-form-for="${date}"]`); + if (!box.hidden) { box.hidden = true; return; } + const itemOptions = plannerCoursesCache + .map((c) => ``) + .join(''); + box.hidden = false; + box.innerHTML = ` + +
or
+ + `; + + const courseSelect = box.querySelector('.task-course-select'); + (async () => { + for (const c of plannerCoursesCache) { + const detail = await api(`/api/admin/courses/${c.id}`); + const group = document.createElement('optgroup'); + group.label = c.name; + for (const u of detail.units) { + for (const it of u.items) { + const opt = document.createElement('option'); + opt.value = it.id; + opt.textContent = `${TYPE_ICON[it.type]} ${it.title}`; + group.appendChild(opt); + } + } + courseSelect.appendChild(group); + } + })(); + + box.querySelector('.task-save-btn').addEventListener('click', async () => { + const itemId = courseSelect.value; + const title = box.querySelector('.task-offline-input').value.trim(); + if (!itemId && !title) return; + await api('/api/schedule', { + method: 'POST', + body: { studentId: plannerStudentId, date, itemId: itemId || null, title }, + }); + renderPlanner(); + }); +} + +async function printWeekReport(studentId, start) { + const r = await api(`/api/week-report/${studentId}?start=${start}`); + const taskRows = r.tasks + .map((t) => `${t.date}${esc(t.itemTitle || t.offlineTitle)}${esc(t.courseName || 'Offline')}${t.done ? 'βœ“ done' : 'β€”'}`) + .join(''); + const gradedRows = r.graded + .map((g) => `${esc(g.itemTitle)}${esc(g.courseName)}${g.score}/${g.points_possible}`) + .join(''); + const spellingRows = r.spellingTests + .map((t) => `${esc(t.list)}${t.score}/${t.total}`) + .join(''); + const win = window.open('', '_blank'); + win.document.write(`Weekly Report + +

Weekly Report β€” ${esc(r.student)}

+

Week of ${r.start}

+

Schedule

+ ${taskRows || ''}
DateTaskCourseStatus
Nothing scheduled
+

Graded work

+ ${gradedRows || ''}
ItemCourseScore
Nothing graded this week
+

Spelling tests

+ ${spellingRows || ''}
ListScore
No spelling tests this week
+ + + diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..ea5e71c --- /dev/null +++ b/public/styles.css @@ -0,0 +1,394 @@ +* { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg: #fdf6e3; + --card: #ffffff; + --ink: #2d3142; + --accent: #4f86f7; + --accent-dark: #3a6fd8; + --good: #2e9e5b; + --good-bg: #e3f6ec; + --bad: #d64550; + --bad-bg: #fdeaec; + --gold: #f6b93b; + --muted: #8a8fa3; + --radius: 18px; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Comic Sans MS", sans-serif; + background: var(--bg); + color: var(--ink); + min-height: 100vh; +} + +.view { + max-width: 640px; + margin: 0 auto; + padding: 2rem 1.25rem 4rem; + text-align: center; +} + +.app-title { font-size: 2.4rem; margin-bottom: 0.5rem; } +.subtitle { font-size: 1.25rem; color: var(--muted); margin-bottom: 2rem; } +.hint { color: var(--muted); margin: 1rem 0; } +.error { color: var(--bad); margin: 0.5rem 0; } + +/* ---------- kid picker ---------- */ +.kid-cards { + display: flex; flex-wrap: wrap; gap: 1rem; justify-content: center; + margin-bottom: 2.5rem; +} +.kid-card { + background: var(--card); + border: 3px solid transparent; + border-radius: var(--radius); + padding: 1.5rem 2rem; + font-size: 1.2rem; + font-weight: 700; + cursor: pointer; + box-shadow: 0 4px 12px rgba(45, 49, 66, 0.08); + transition: transform 0.1s, border-color 0.1s; + min-width: 140px; +} +.kid-card:hover { transform: translateY(-3px) scale(1.03); border-color: var(--accent); } +.kid-card .avatar { display: block; font-size: 3rem; margin-bottom: 0.5rem; } + +/* ---------- kid home ---------- */ +.back-btn { + display: inline-block; + background: none; border: none; color: var(--muted); + font-size: 1rem; cursor: pointer; margin-bottom: 1rem; +} +#kid-greeting { font-size: 2rem; margin: 1rem 0; clear: both; } + +.week-card { + background: var(--card); border-radius: var(--radius); + padding: 1.25rem; margin-bottom: 2rem; + box-shadow: 0 4px 12px rgba(45, 49, 66, 0.08); + font-size: 1.1rem; +} +.week-card .mastery-dots { + margin-top: 0.75rem; line-height: 1.9; letter-spacing: 2px; font-size: 1.1rem; + word-break: break-all; overflow-wrap: anywhere; +} + +.big-actions { display: flex; flex-direction: column; gap: 1rem; align-items: center; } +.big-btn { + font-size: 1.6rem; font-weight: 700; font-family: inherit; + padding: 1.25rem 2rem; width: 100%; max-width: 380px; + border: none; border-radius: var(--radius); cursor: pointer; + color: #fff; box-shadow: 0 6px 0 rgba(0, 0, 0, 0.15); + transition: transform 0.05s; +} +.big-btn:active { transform: translateY(4px); box-shadow: 0 2px 0 rgba(0,0,0,0.15); } +.big-btn.practice { background: var(--accent); } +.big-btn.test { background: var(--gold); color: var(--ink); } +.big-btn:disabled { background: #ccc; color: #888; cursor: not-allowed; box-shadow: none; } + +/* ---------- practice / test ---------- */ +.progress-label { color: var(--muted); font-weight: 700; margin: 0.5rem 0; clear: both; } +.progress-track { + height: 12px; background: #e8e2d0; border-radius: 6px; overflow: hidden; margin-bottom: 1.5rem; +} +.progress-fill { height: 100%; width: 0; background: var(--accent); transition: width 0.3s; } +.progress-fill.test-fill { background: var(--gold); } + +.word-card { + background: var(--card); border-radius: var(--radius); + padding: 2rem 1.5rem; box-shadow: 0 4px 12px rgba(45, 49, 66, 0.08); +} +.speak-btn { + font-size: 1.2rem; font-family: inherit; font-weight: 700; + background: var(--good-bg); color: var(--good); + border: 2px solid var(--good); border-radius: 999px; + padding: 0.6rem 1.4rem; cursor: pointer; margin-bottom: 1.25rem; +} +.prompt { font-size: 1.1rem; color: var(--muted); margin-bottom: 1rem; } + +.spell-input { + display: block; width: 100%; max-width: 420px; margin: 0 auto 1rem; + font-size: 2rem; font-family: inherit; text-align: center; + letter-spacing: 4px; padding: 0.6rem; + border: 3px solid var(--accent); border-radius: 12px; + outline: none; +} +.spell-input:focus { border-color: var(--accent-dark); } + +.check-btn { + font-size: 1.3rem; font-weight: 700; font-family: inherit; + background: var(--accent); color: #fff; border: none; + border-radius: 12px; padding: 0.75rem 2.5rem; cursor: pointer; + box-shadow: 0 4px 0 var(--accent-dark); +} +.check-btn:active { transform: translateY(3px); box-shadow: 0 1px 0 var(--accent-dark); } + +/* letter-diff feedback */ +.feedback { min-height: 1rem; margin-bottom: 1rem; font-size: 1.2rem; } +.feedback .banner { + display: inline-block; padding: 0.5rem 1.25rem; border-radius: 999px; + font-weight: 700; margin-bottom: 0.75rem; +} +.feedback .banner.good { background: var(--good-bg); color: var(--good); } +.feedback .banner.bad { background: var(--bad-bg); color: var(--bad); } +.diff-line { font-size: 1.9rem; letter-spacing: 4px; margin: 0.4rem 0; font-weight: 700; } +.diff-line .label { font-size: 0.85rem; letter-spacing: 0; color: var(--muted); display: block; font-weight: 400; } +.ltr-ok { color: var(--ink); } +.ltr-bad { color: var(--bad); text-decoration: line-through; } +.ltr-fix { color: var(--good); background: var(--good-bg); border-radius: 6px; padding: 0 2px; } +.study-word { + font-size: 2.6rem; font-weight: 800; letter-spacing: 6px; margin: 0.5rem 0; +} +.countdown { color: var(--muted); } + +.done-card { + background: var(--card); border-radius: var(--radius); + padding: 2.5rem 1.5rem; box-shadow: 0 4px 12px rgba(45, 49, 66, 0.08); + font-size: 1.3rem; +} +.done-card .big-score { font-size: 3rem; font-weight: 800; margin: 0.75rem 0; } +.done-card ul { list-style: none; margin: 1rem 0; } +.done-card li { margin: 0.3rem 0; } +.done-card button { + font-size: 1.2rem; font-weight: 700; font-family: inherit; + background: var(--accent); color: #fff; border: none; + border-radius: 12px; padding: 0.7rem 2rem; cursor: pointer; margin-top: 1rem; +} + +/* ---------- parent dashboard ---------- */ +.parent { text-align: left; } +.parent h1 { text-align: center; margin-bottom: 1.5rem; clear: both; } +.parent h2 { margin: 2rem 0 0.75rem; font-size: 1.3rem; border-bottom: 2px solid #e8e2d0; padding-bottom: 0.3rem; } + +#pin-gate { text-align: center; } +#pin-form input { + font-size: 1.5rem; text-align: center; width: 180px; padding: 0.5rem; + border: 2px solid var(--accent); border-radius: 10px; margin-right: 0.5rem; +} +#pin-form button, .inline-form button, #list-form button, .row-actions button { + font-size: 1rem; font-family: inherit; font-weight: 700; + background: var(--accent); color: #fff; border: none; + border-radius: 10px; padding: 0.6rem 1.2rem; cursor: pointer; +} +button.secondary { background: var(--muted); } +button.danger { background: var(--bad); } + +.inline-form { display: flex; gap: 0.5rem; margin: 0.75rem 0; flex-wrap: wrap; } +.inline-form input, .inline-form select, #list-form input, #list-form textarea { + font-size: 1rem; font-family: inherit; padding: 0.55rem; + border: 2px solid #d8d2c0; border-radius: 10px; +} +.inline-form input { flex: 1; min-width: 140px; } + +.item-row { + display: flex; align-items: center; gap: 0.75rem; + background: var(--card); border-radius: 12px; padding: 0.75rem 1rem; margin: 0.5rem 0; + box-shadow: 0 2px 6px rgba(45, 49, 66, 0.06); + flex-wrap: wrap; +} +.item-row .grow { flex: 1; min-width: 120px; } +.item-row select { padding: 0.4rem; border-radius: 8px; border: 2px solid #d8d2c0; font-family: inherit; } +.item-row button { + font-size: 0.9rem; font-family: inherit; border: none; border-radius: 8px; + padding: 0.4rem 0.9rem; cursor: pointer; color: #fff; background: var(--accent); +} + +#list-form { display: flex; flex-direction: column; gap: 0.75rem; margin-top: 0.75rem; } +#list-editor-details summary { cursor: pointer; font-weight: 700; margin: 0.75rem 0; } +.row-actions { display: flex; gap: 0.5rem; } + +.results-student { margin-bottom: 1.25rem; } +.results-student h3 { margin-bottom: 0.4rem; } +table.results { + width: 100%; border-collapse: collapse; background: var(--card); + border-radius: 12px; overflow: hidden; font-size: 0.95rem; +} +table.results th, table.results td { padding: 0.5rem 0.75rem; text-align: left; } +table.results th { background: #efe9d8; } +table.results tr:nth-child(even) td { background: #faf7ee; } +.score-good { color: var(--good); font-weight: 700; } +.score-bad { color: var(--bad); font-weight: 700; } + +.link-btn { + display: inline-block; background: none; border: none; color: var(--muted); + text-decoration: underline; cursor: pointer; font-size: 1rem; +} + +/* ---------- kid home tabs ---------- */ +.kid-nav { display: flex; gap: 0.5rem; justify-content: center; margin: 0.5rem 0 1.5rem; clear: both; flex-wrap: wrap; } +.nav-pill { + font-family: inherit; font-size: 1rem; font-weight: 700; + background: var(--card); color: var(--muted); border: 2px solid #e8e2d0; + border-radius: 999px; padding: 0.5rem 1.1rem; cursor: pointer; +} +.nav-pill.active { background: var(--accent); color: #fff; border-color: var(--accent); } +.tab-panel { text-align: left; } + +/* ---------- today list / item rows (shared by agenda + course pages) ---------- */ +.today-row { + display: flex; align-items: center; gap: 0.85rem; width: 100%; + background: var(--card); border: none; border-radius: 14px; + padding: 0.9rem 1.1rem; margin: 0.5rem 0; cursor: pointer; text-align: left; + font-family: inherit; box-shadow: 0 2px 8px rgba(45, 49, 66, 0.06); +} +.today-row .row-check { font-size: 1.4rem; flex-shrink: 0; } +.today-row .row-title { flex: 1; font-size: 1.05rem; font-weight: 700; color: var(--ink); } +.today-row .row-title small { display: block; font-weight: 400; color: var(--muted); font-size: 0.85rem; } +.today-row .row-badge { font-size: 0.85rem; color: var(--muted); font-weight: 700; white-space: nowrap; } + +/* ---------- course cards ---------- */ +.course-cards { display: flex; flex-direction: column; gap: 0.75rem; } +.course-card { + display: flex; flex-direction: column; gap: 0.4rem; text-align: left; + background: var(--card); border: none; border-left: 6px solid var(--accent); + border-radius: 14px; padding: 1rem 1.2rem; cursor: pointer; font-family: inherit; + box-shadow: 0 2px 8px rgba(45, 49, 66, 0.06); +} +.course-card strong { font-size: 1.15rem; } +.course-subject { color: var(--muted); font-size: 0.9rem; } +.course-pct { font-size: 0.85rem; color: var(--muted); } +.progress-track.small { height: 8px; margin: 0.2rem 0; } + +.unit-block { margin-bottom: 1.5rem; } +.unit-block h2 { font-size: 1.15rem; margin-bottom: 0.5rem; } + +/* ---------- item viewers (lesson/assignment/quiz) ---------- */ +.item-card { + background: var(--card); border-radius: var(--radius); padding: 1.75rem 1.5rem; + box-shadow: 0 4px 12px rgba(45, 49, 66, 0.08); text-align: left; +} +.item-kicker { color: var(--muted); font-size: 0.9rem; margin-bottom: 0.25rem; } +.item-card h1 { font-size: 1.5rem; margin-bottom: 1rem; } +.lesson-body { font-size: 1.05rem; line-height: 1.6; margin-bottom: 1.5rem; } +.lesson-body img, .lesson-body iframe { max-width: 100%; border-radius: 10px; } +.points-label { color: var(--muted); font-weight: 700; margin-bottom: 0.75rem; } +.status-banner { + margin-top: 1rem; padding: 0.75rem 1rem; border-radius: 12px; + background: #fdf1d6; font-weight: 700; +} +.status-banner.good { background: var(--good-bg); color: var(--good); } + +/* ---------- quiz ---------- */ +.quiz-question { margin-bottom: 1.25rem; } +.q-prompt { font-weight: 700; margin-bottom: 0.5rem; font-size: 1.05rem; } +.quiz-choice { display: block; margin: 0.35rem 0; font-size: 1rem; cursor: pointer; } +.quiz-choice input { margin-right: 0.5rem; } +.quiz-short-input { + width: 100%; max-width: 320px; font-size: 1rem; font-family: inherit; + padding: 0.5rem; border: 2px solid #d8d2c0; border-radius: 10px; +} +.quiz-question.review .q-your-answer { color: var(--muted); } +.quiz-question.review .q-correct-answer { color: var(--good); font-weight: 700; } + +/* ---------- flashcards ---------- */ +.flip-card { text-align: center; } +.flash-face { font-size: 1.6rem; font-weight: 700; padding: 2rem 1rem; min-height: 3rem; } +#flash-back { color: var(--accent-dark); } +.flash-grade-row { display: flex; gap: 0.75rem; justify-content: center; margin-top: 1rem; } +.flash-grade-row[hidden] { display: none; } +.grade-btn { + font-size: 1.1rem; font-weight: 700; font-family: inherit; border: none; + border-radius: 12px; padding: 0.75rem 1.5rem; cursor: pointer; color: #fff; +} +.grade-btn.again { background: var(--bad); } +.grade-btn.got-it { background: var(--good); } + +/* ---------- admin console ---------- */ +.admin-header { + display: flex; align-items: center; gap: 1.5rem; flex-wrap: wrap; + background: var(--card); padding: 1rem 1.5rem; box-shadow: 0 2px 8px rgba(45, 49, 66, 0.06); +} +.admin-logo { font-size: 1.3rem; font-weight: 800; color: var(--ink); text-decoration: none; } +.admin-nav { display: flex; gap: 0.4rem; flex-wrap: wrap; } +.admin-main { max-width: 900px; margin: 0 auto; padding: 1.5rem 1.25rem 4rem; } +.admin-main h1 { margin-bottom: 1rem; clear: both; } +.admin-main h2 { margin: 1.5rem 0 0.5rem; font-size: 1.15rem; } +.panel { text-align: left; } + +.admin-card { + background: var(--card); border-radius: 14px; padding: 1.25rem; + margin: 0.75rem 0; box-shadow: 0 2px 8px rgba(45, 49, 66, 0.06); + display: flex; flex-direction: column; gap: 0.75rem; +} +.admin-card label { display: flex; flex-direction: column; gap: 0.3rem; font-weight: 700; font-size: 0.9rem; } +.admin-card input, .admin-card select, .admin-card textarea { + font-family: inherit; font-size: 1rem; padding: 0.5rem; border: 2px solid #d8d2c0; border-radius: 10px; font-weight: 400; +} + +.checkbox-label { display: inline-flex; align-items: center; gap: 0.4rem; font-weight: 400; margin-right: 1rem; } +.color-dot { width: 14px; height: 14px; border-radius: 50%; display: inline-block; flex-shrink: 0; } +.archived-badge { background: #e8e2d0; color: var(--muted); font-size: 0.75rem; padding: 0.2rem 0.5rem; border-radius: 999px; } + +button.small { font-size: 0.85rem; padding: 0.4rem 0.8rem; } +button.tiny { font-size: 0.75rem; padding: 0.15rem 0.5rem; } +.hint.tiny { font-size: 0.8rem; margin: 0.25rem 0; } + +.unit-block { background: var(--card); border-radius: 14px; padding: 1rem 1.1rem; margin: 0.75rem 0; box-shadow: 0 2px 8px rgba(45, 49, 66, 0.06); } +.unit-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem; } +.unit-header h3 { font-size: 1.05rem; } + +.quiz-q-row { + display: grid; grid-template-columns: 9rem 1fr 1fr 1fr 4rem auto; gap: 0.5rem; + align-items: center; margin-bottom: 0.5rem; +} +.quiz-q-row select, .quiz-q-row input { + font-family: inherit; font-size: 0.9rem; padding: 0.4rem; border: 2px solid #d8d2c0; border-radius: 8px; +} + +/* ---------- planner ---------- */ +.week-label { font-weight: 700; } +.planner-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 0.75rem; margin-top: 1rem; } +.planner-day { background: var(--card); border-radius: 14px; padding: 0.85rem; box-shadow: 0 2px 8px rgba(45, 49, 66, 0.06); } +.planner-day h3 { font-size: 0.95rem; margin-bottom: 0.5rem; } +.planner-day h3 small { display: block; font-weight: 400; color: var(--muted); } +.planner-tasks { display: flex; flex-direction: column; gap: 0.4rem; margin-bottom: 0.5rem; min-height: 1.5rem; } +.planner-task { + display: flex; justify-content: space-between; align-items: center; gap: 0.4rem; + background: #f7f2e3; border-radius: 8px; padding: 0.4rem 0.6rem; font-size: 0.85rem; +} +.planner-task.done { opacity: 0.55; text-decoration: line-through; } +.add-task-form { display: flex; flex-direction: column; gap: 0.4rem; margin-top: 0.5rem; } +.add-task-form select, .add-task-form input { font-size: 0.85rem; padding: 0.4rem; border: 2px solid #d8d2c0; border-radius: 8px; font-family: inherit; } +.or-divider { text-align: center; font-size: 0.75rem; color: var(--muted); } +.task-save-btn { font-size: 0.85rem; } + +.grade-input { width: 4.5rem; padding: 0.4rem; border: 2px solid #d8d2c0; border-radius: 8px; font-family: inherit; } + +@media (max-width: 700px) { + .planner-grid { grid-template-columns: 1fr; } + .quiz-q-row { grid-template-columns: 1fr 1fr; } +} + +@media (max-width: 480px) { + .app-title { font-size: 1.8rem; } + .spell-input { font-size: 1.6rem; } + .study-word { font-size: 2rem; } + .today-row .row-title { font-size: 0.95rem; } +} + +/* ---------- page scanning ---------- */ +.scan-card { + background: var(--card); + border: 1.5px dashed var(--accent); + border-radius: 10px; + padding: 1rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} +.scan-controls { display: flex; gap: 0.5rem; flex-wrap: wrap; } +.scan-controls button { flex: 0 0 auto; } +.scan-controls button.secondary { background: var(--muted); } +.scan-controls button:disabled { background: #ccc; cursor: not-allowed; } +#scan-preview { max-width: 220px; max-height: 220px; border-radius: 8px; object-fit: contain; } + +/* ---------- scan config settings ---------- */ +.scan-provider-box { + border: 1px solid var(--border, #ddd); + border-radius: 8px; + padding: 0.75rem 1rem; + margin-bottom: 0.75rem; +} +.scan-provider-box legend { font-weight: 600; padding: 0 0.3rem; } +.radio-label { display: block; margin: 0.3rem 0; cursor: pointer; } +.radio-label input { margin-right: 0.4rem; } diff --git a/seed-decks.json b/seed-decks.json new file mode 100644 index 0000000..d47d872 --- /dev/null +++ b/seed-decks.json @@ -0,0 +1,34 @@ +[ + { + "name": "U.S. State Capitals (sample)", + "cards": [ + {"front": "What is the capital of Ohio?", "back": "Columbus"}, + {"front": "What is the capital of Texas?", "back": "Austin"}, + {"front": "What is the capital of California?", "back": "Sacramento"}, + {"front": "What is the capital of Florida?", "back": "Tallahassee"}, + {"front": "What is the capital of New York?", "back": "Albany"}, + {"front": "What is the capital of Pennsylvania?", "back": "Harrisburg"}, + {"front": "What is the capital of Illinois?", "back": "Springfield"}, + {"front": "What is the capital of Georgia?", "back": "Atlanta"}, + {"front": "What is the capital of Michigan?", "back": "Lansing"}, + {"front": "What is the capital of Washington?", "back": "Olympia"}, + {"front": "What is the capital of Colorado?", "back": "Denver"}, + {"front": "What is the capital of Tennessee?", "back": "Nashville"} + ] + }, + { + "name": "World Geography Basics", + "cards": [ + {"front": "What is the largest ocean?", "back": "The Pacific Ocean"}, + {"front": "What is the largest continent?", "back": "Asia"}, + {"front": "What is the longest river in the world?", "back": "The Nile River"}, + {"front": "What is the tallest mountain on Earth?", "back": "Mount Everest"}, + {"front": "How many continents are there?", "back": "Seven"}, + {"front": "What is the largest desert in the world?", "back": "The Sahara (largest hot desert)"}, + {"front": "Which continent is Egypt in?", "back": "Africa"}, + {"front": "What imaginary line divides Earth into Northern and Southern halves?", "back": "The Equator"}, + {"front": "What is the smallest continent?", "back": "Australia"}, + {"front": "Which ocean is between America and Europe?", "back": "The Atlantic Ocean"} + ] + } +] diff --git a/seed-lists.json b/seed-lists.json new file mode 100644 index 0000000..e0c4091 --- /dev/null +++ b/seed-lists.json @@ -0,0 +1,42 @@ +[ + { + "name": "Dolch Pre-Primer", + "words": ["a", "and", "away", "big", "blue", "can", "come", "down", "find", "for", "funny", "go", "help", "here", "I", "in", "is", "it", "jump", "little", "look", "make", "me", "my", "not", "one", "play", "red", "run", "said", "see", "the", "three", "to", "two", "up", "we", "where", "yellow", "you"] + }, + { + "name": "Dolch Primer", + "words": ["all", "am", "are", "at", "ate", "be", "black", "brown", "but", "came", "did", "do", "eat", "four", "get", "good", "have", "he", "into", "like", "must", "new", "no", "now", "on", "our", "out", "please", "pretty", "ran", "ride", "saw", "say", "she", "so", "soon", "that", "there", "they", "this", "too", "under", "want", "was", "well", "went", "what", "white", "who", "will", "with", "yes"] + }, + { + "name": "Dolch Grade 1", + "words": ["after", "again", "an", "any", "as", "ask", "by", "could", "every", "fly", "from", "give", "going", "had", "has", "her", "him", "his", "how", "just", "know", "let", "live", "may", "of", "old", "once", "open", "over", "put", "round", "some", "stop", "take", "thank", "them", "then", "think", "walk", "were", "when"] + }, + { + "name": "Dolch Grade 2", + "words": ["always", "around", "because", "been", "before", "best", "both", "buy", "call", "cold", "does", "don't", "fast", "first", "five", "found", "gave", "goes", "green", "its", "made", "many", "off", "or", "pull", "read", "right", "sing", "sit", "sleep", "tell", "their", "these", "those", "upon", "us", "use", "very", "wash", "which", "why", "wish", "work", "would", "write", "your"] + }, + { + "name": "Dolch Grade 3", + "words": ["about", "better", "bring", "carry", "clean", "cut", "done", "draw", "drink", "eight", "fall", "far", "full", "got", "grow", "hold", "hot", "hurt", "if", "keep", "kind", "laugh", "light", "long", "much", "myself", "never", "only", "own", "pick", "seven", "shall", "show", "six", "small", "start", "ten", "today", "together", "try", "warm"] + }, + { + "name": "Commonly Misspelled (Elementary)", + "words": [ + {"word": "because", "sentence": "I smiled because it was sunny."}, + {"word": "friend", "sentence": "My friend came over to play."}, + {"word": "believe", "sentence": "I believe you can do it."}, + {"word": "beautiful", "sentence": "The sunset was beautiful."}, + {"word": "different", "sentence": "Every snowflake is different."}, + {"word": "favorite", "sentence": "Pizza is my favorite food."}, + {"word": "finally", "sentence": "We finally finished the puzzle."}, + {"word": "surprise", "sentence": "The party was a big surprise."}, + {"word": "together", "sentence": "We worked together on the project."}, + {"word": "tomorrow", "sentence": "We will go to the park tomorrow."}, + {"word": "which", "sentence": "Which book do you want to read?"}, + {"word": "would", "sentence": "I would like some water, please."}, + {"word": "really", "sentence": "That movie was really funny."}, + {"word": "until", "sentence": "Wait until the light turns green."}, + {"word": "people", "sentence": "Many people came to the fair."} + ] + } +] diff --git a/server.js b/server.js new file mode 100644 index 0000000..c170340 --- /dev/null +++ b/server.js @@ -0,0 +1,1087 @@ +import express from 'express'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { db, sha256 } from './db.js'; + +const ROOT = dirname(fileURLToPath(import.meta.url)); +const app = express(); +app.use(express.json({ limit: '15mb' })); // photos for page-scanning are base64 in the JSON body +app.use(express.static(join(ROOT, 'public'))); + +// ---------- helpers ---------- + +// Days until next review after reaching each Leitner box +const INTERVALS = { 1: 1, 2: 2, 3: 4, 4: 7, 5: 14 }; +const MASTERED_BOX = 5; + +const normalize = (s) => String(s).trim().toLowerCase(); +const isDateStr = (s) => /^\d{4}-\d{2}-\d{2}$/.test(String(s)); +const today = () => new Date().toISOString().slice(0, 10); + +const shuffle = (arr) => { + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [arr[i], arr[j]] = [arr[j], arr[i]]; + } + return arr; +}; + +// Apply a result to a student's Leitner state. Words and flashcards each +// have their own progress table but share the same scheduling rules. +function leitnerUpdater(table, idCol) { + const select = db.prepare(`SELECT box FROM ${table} WHERE student_id = ? AND ${idCol} = ?`); + const upsert = db.prepare(` + INSERT INTO ${table} (student_id, ${idCol}, box, due, attempts, correct) + VALUES (?, ?, ?, date('now', ?), 1, ?) + ON CONFLICT (student_id, ${idCol}) DO UPDATE SET + box = excluded.box, + due = excluded.due, + attempts = attempts + 1, + correct = correct + excluded.correct + `); + return (studentId, itemId, correct) => { + const row = select.get(studentId, itemId); + const box = correct ? Math.min((row ? row.box : 0) + 1, MASTERED_BOX) : 1; + const days = correct ? INTERVALS[box] : 0; + upsert.run(studentId, itemId, box, `+${days} days`, correct ? 1 : 0); + return box; + }; +} +const updateProgress = leitnerUpdater('progress', 'word_id'); +const updateCardProgress = leitnerUpdater('card_progress', 'card_id'); + +// Parent-only routes must send the PIN in the x-pin header +function requirePin(req, res, next) { + const stored = db.prepare(`SELECT value FROM settings WHERE key = 'pin'`).get().value; + if (sha256(req.get('x-pin') || '') !== stored) { + return res.status(401).json({ error: 'Wrong PIN' }); + } + next(); +} + +// Mark a scheduled agenda entry for this student/item/date as done, if one exists +const markScheduleDone = db.prepare(` + UPDATE schedule SET done = 1, done_at = datetime('now') + WHERE student_id = ? AND item_id = ? AND date = ? +`); + +const ITEM_TYPES = ['lesson', 'assignment', 'quiz', 'spelling_practice', 'spelling_test', 'flashcards']; +const GRADABLE_TYPES = ['assignment', 'quiz', 'spelling_test']; + +// ---------- kid picker ---------- + +app.get('/api/students', (req, res) => { + res.json(db.prepare(`SELECT id, name, emoji FROM students ORDER BY name`).all()); +}); + +// ---------- spelling module ---------- + +// Everything the standalone spelling home screen needs +app.get('/api/state/:studentId', (req, res) => { + const id = req.params.studentId; + const student = db.prepare(`SELECT id, name, emoji FROM students WHERE id = ?`).get(id); + if (!student) return res.status(404).json({ error: 'No such student' }); + + const assignment = db.prepare(` + SELECT l.id, l.name FROM assignments a JOIN lists l ON l.id = a.list_id + WHERE a.student_id = ? + `).get(id); + + let listProgress = null; + if (assignment) { + listProgress = db.prepare(` + SELECT COUNT(*) AS total, + SUM(CASE WHEN COALESCE(p.box, 0) >= ${MASTERED_BOX} THEN 1 ELSE 0 END) AS mastered + FROM words w + LEFT JOIN progress p ON p.word_id = w.id AND p.student_id = ? + WHERE w.list_id = ? + `).get(id, assignment.id); + } + + // only words outside this week's list count as "old reviews" + const dueReviews = db.prepare(` + SELECT COUNT(*) AS n FROM progress p JOIN words w ON w.id = p.word_id + WHERE p.student_id = ? AND w.list_id <> ? AND p.box < ${MASTERED_BOX} + AND date(p.due) <= date('now') + `).get(id, assignment ? assignment.id : -1).n; + + res.json({ student, assignment, listProgress, dueReviews }); +}); + +// Build a practice session: the target list's unmastered words + due reviews. +// listId overrides the student's pinned weekly assignment (used when launched from a course item). +app.get('/api/session/:studentId', (req, res) => { + const id = req.params.studentId; + let listId = req.query.listId ? Number(req.query.listId) : null; + if (!listId) { + const assignment = db.prepare(`SELECT list_id FROM assignments WHERE student_id = ?`).get(id); + listId = assignment ? assignment.list_id : -1; + } + + const weekWords = db.prepare(` + SELECT w.id, w.word, w.sentence, COALESCE(p.box, 0) AS box + FROM words w + LEFT JOIN progress p ON p.word_id = w.id AND p.student_id = ? + WHERE w.list_id = ? AND COALESCE(p.box, 0) < ${MASTERED_BOX} + ORDER BY box, RANDOM() + LIMIT 10 + `).all(id, listId); + + const reviews = db.prepare(` + SELECT w.id, w.word, w.sentence, p.box + FROM progress p JOIN words w ON w.id = p.word_id + WHERE p.student_id = ? AND w.list_id <> ? AND p.box < ${MASTERED_BOX} + AND date(p.due) <= date('now') + ORDER BY p.due + LIMIT 5 + `).all(id, listId); + + res.json({ words: shuffle([...weekWords, ...reviews]) }); +}); + +// Grade one practice answer. firstTry attempts move the Leitner box; +// look-cover-write-check retypes are recorded but don't. +app.post('/api/attempt', (req, res) => { + const { studentId, wordId, typed, firstTry } = req.body; + const word = db.prepare(`SELECT word FROM words WHERE id = ?`).get(wordId); + if (!word) return res.status(404).json({ error: 'No such word' }); + + const correct = normalize(typed) === normalize(word.word); + db.prepare(`INSERT INTO attempts (student_id, word_id, typed, correct, mode) VALUES (?, ?, ?, ?, ?)`) + .run(studentId, wordId, String(typed), correct ? 1 : 0, firstTry ? 'practice' : 'retype'); + + let box = null; + if (firstTry) box = updateProgress(studentId, wordId, correct); + res.json({ correct, word: word.word, box }); +}); + +// ---------- spelling test (Friday test, or a course spelling_test item) ---------- + +app.get('/api/test/:studentId', (req, res) => { + let list; + if (req.query.listId) { + list = db.prepare(`SELECT id, name FROM lists WHERE id = ?`).get(req.query.listId); + } else { + list = db.prepare(` + SELECT l.id, l.name FROM assignments a JOIN lists l ON l.id = a.list_id + WHERE a.student_id = ? + `).get(req.params.studentId); + } + if (!list) return res.status(404).json({ error: 'No list assigned' }); + + const words = db.prepare(`SELECT id, word, sentence FROM words WHERE list_id = ?`).all(list.id); + res.json({ list, words: shuffle(words) }); +}); + +app.post('/api/test/:studentId', (req, res) => { + const studentId = req.params.studentId; + const { listId, answers, itemId, date } = req.body; // answers: [{ wordId, typed }] + if (!Array.isArray(answers) || answers.length === 0) { + return res.status(400).json({ error: 'No answers' }); + } + + const getWord = db.prepare(`SELECT word FROM words WHERE id = ?`); + const graded = answers.map((a) => { + const w = getWord.get(a.wordId); + return { ...a, word: w.word, correct: normalize(a.typed) === normalize(w.word) }; + }); + const score = graded.filter((g) => g.correct).length; + + const testId = db.prepare(`INSERT INTO tests (student_id, list_id, score, total) VALUES (?, ?, ?, ?)`) + .run(studentId, listId, score, graded.length).lastInsertRowid; + const insAnswer = db.prepare(`INSERT INTO test_answers (test_id, word_id, typed, correct) VALUES (?, ?, ?, ?)`); + for (const g of graded) { + insAnswer.run(testId, g.wordId, String(g.typed), g.correct ? 1 : 0); + updateProgress(studentId, g.wordId, g.correct); // missed test words come back in practice + } + + // if this test was launched from a course item, record it in the gradebook too + if (itemId) { + db.prepare(` + INSERT INTO submissions (student_id, item_id, status, score, points_possible, completed_at, graded_at) + VALUES (?, ?, 'graded', ?, ?, datetime('now'), datetime('now')) + ON CONFLICT (student_id, item_id) DO UPDATE SET + status = 'graded', score = excluded.score, points_possible = excluded.points_possible, + completed_at = datetime('now'), graded_at = datetime('now') + `).run(studentId, itemId, score, graded.length); + markScheduleDone.run(studentId, itemId, isDateStr(date) ? date : today()); + } + + res.json({ testId, score, total: graded.length, graded }); +}); + +// Full detail for the printable report +app.get('/api/test-report/:testId', (req, res) => { + const test = db.prepare(` + SELECT t.id, t.score, t.total, t.at, s.name AS student, l.name AS list + FROM tests t JOIN students s ON s.id = t.student_id JOIN lists l ON l.id = t.list_id + WHERE t.id = ? + `).get(req.params.testId); + if (!test) return res.status(404).json({ error: 'No such test' }); + + test.answers = db.prepare(` + SELECT w.word, ta.typed, ta.correct + FROM test_answers ta JOIN words w ON w.id = ta.word_id + WHERE ta.test_id = ? + ORDER BY w.word + `).all(req.params.testId); + res.json(test); +}); + +// ---------- word lists (read is public, kids never write) ---------- + +app.get('/api/lists', (req, res) => { + res.json(db.prepare(` + SELECT l.id, l.name, l.builtin, COUNT(w.id) AS wordCount + FROM lists l LEFT JOIN words w ON w.list_id = l.id + GROUP BY l.id ORDER BY l.builtin, l.name + `).all()); +}); + +app.get('/api/lists/:id', (req, res) => { + const list = db.prepare(`SELECT id, name, builtin FROM lists WHERE id = ?`).get(req.params.id); + if (!list) return res.status(404).json({ error: 'No such list' }); + list.words = db.prepare(`SELECT id, word, sentence FROM words WHERE list_id = ? ORDER BY id`).all(req.params.id); + res.json(list); +}); + +app.post('/api/lists', requirePin, (req, res) => { + const { name, words } = req.body; + if (!name || !Array.isArray(words) || words.length === 0) { + return res.status(400).json({ error: 'Name and at least one word required' }); + } + const id = db.prepare(`INSERT INTO lists (name, builtin) VALUES (?, 0)`).run(String(name).trim()).lastInsertRowid; + const ins = db.prepare(`INSERT INTO words (list_id, word, sentence) VALUES (?, ?, ?)`); + for (const w of words) ins.run(id, String(w.word).trim(), String(w.sentence || '').trim()); + res.json({ id }); +}); + +app.put('/api/lists/:id', requirePin, (req, res) => { + const { name, words } = req.body; + const id = req.params.id; + if (!db.prepare(`SELECT id FROM lists WHERE id = ?`).get(id)) { + return res.status(404).json({ error: 'No such list' }); + } + db.prepare(`UPDATE lists SET name = ? WHERE id = ?`).run(String(name).trim(), id); + db.prepare(`DELETE FROM words WHERE list_id = ?`).run(id); + const ins = db.prepare(`INSERT INTO words (list_id, word, sentence) VALUES (?, ?, ?)`); + for (const w of words) ins.run(id, String(w.word).trim(), String(w.sentence || '').trim()); + res.json({ ok: true }); +}); + +app.delete('/api/lists/:id', requirePin, (req, res) => { + db.prepare(`DELETE FROM lists WHERE id = ?`).run(req.params.id); + res.json({ ok: true }); +}); + +// ---------- flashcards module ---------- + +app.get('/api/decks', (req, res) => { + res.json(db.prepare(` + SELECT d.id, d.name, d.builtin, COUNT(c.id) AS cardCount + FROM decks d LEFT JOIN cards c ON c.deck_id = d.id + GROUP BY d.id ORDER BY d.builtin, d.name + `).all()); +}); + +app.get('/api/decks/:id', (req, res) => { + const deck = db.prepare(`SELECT id, name, builtin FROM decks WHERE id = ?`).get(req.params.id); + if (!deck) return res.status(404).json({ error: 'No such deck' }); + deck.cards = db.prepare(`SELECT id, front, back FROM cards WHERE deck_id = ? ORDER BY id`).all(req.params.id); + res.json(deck); +}); + +app.post('/api/decks', requirePin, (req, res) => { + const { name, cards } = req.body; + if (!name || !Array.isArray(cards) || cards.length === 0) { + return res.status(400).json({ error: 'Name and at least one card required' }); + } + const id = db.prepare(`INSERT INTO decks (name, builtin) VALUES (?, 0)`).run(String(name).trim()).lastInsertRowid; + const ins = db.prepare(`INSERT INTO cards (deck_id, front, back) VALUES (?, ?, ?)`); + for (const c of cards) ins.run(id, String(c.front).trim(), String(c.back).trim()); + res.json({ id }); +}); + +app.put('/api/decks/:id', requirePin, (req, res) => { + const { name, cards } = req.body; + const id = req.params.id; + if (!db.prepare(`SELECT id FROM decks WHERE id = ?`).get(id)) { + return res.status(404).json({ error: 'No such deck' }); + } + db.prepare(`UPDATE decks SET name = ? WHERE id = ?`).run(String(name).trim(), id); + db.prepare(`DELETE FROM cards WHERE deck_id = ?`).run(id); + const ins = db.prepare(`INSERT INTO cards (deck_id, front, back) VALUES (?, ?, ?)`); + for (const c of cards) ins.run(id, String(c.front).trim(), String(c.back).trim()); + res.json({ ok: true }); +}); + +app.delete('/api/decks/:id', requirePin, (req, res) => { + db.prepare(`DELETE FROM decks WHERE id = ?`).run(req.params.id); + res.json({ ok: true }); +}); + +// Due + new cards from one deck, capped per session +app.get('/api/flashcards/session/:studentId', (req, res) => { + const cards = db.prepare(` + SELECT c.id, c.front, c.back, COALESCE(p.box, 0) AS box + FROM cards c + LEFT JOIN card_progress p ON p.card_id = c.id AND p.student_id = ? + WHERE c.deck_id = ? AND COALESCE(p.box, 0) < ${MASTERED_BOX} + AND (p.due IS NULL OR date(p.due) <= date('now')) + ORDER BY box, RANDOM() + LIMIT 20 + `).all(req.params.studentId, req.query.deckId); + res.json({ cards: shuffle(cards) }); +}); + +app.post('/api/flashcards/grade', (req, res) => { + const { studentId, cardId, gotIt } = req.body; + if (!db.prepare(`SELECT id FROM cards WHERE id = ?`).get(cardId)) { + return res.status(404).json({ error: 'No such card' }); + } + const box = updateCardProgress(studentId, cardId, !!gotIt); + res.json({ ok: true, box }); +}); + +// ---------- parent account API ---------- + +app.post('/api/parent/verify', (req, res) => { + const stored = db.prepare(`SELECT value FROM settings WHERE key = 'pin'`).get().value; + res.json({ ok: sha256(req.body.pin || '') === stored }); +}); + +app.post('/api/parent/pin', requirePin, (req, res) => { + const pin = String(req.body.newPin || ''); + if (pin.length < 4) return res.status(400).json({ error: 'PIN must be at least 4 digits' }); + db.prepare(`UPDATE settings SET value = ? WHERE key = 'pin'`).run(sha256(pin)); + res.json({ ok: true }); +}); + +app.post('/api/students', requirePin, (req, res) => { + const name = String(req.body.name || '').trim(); + if (!name) return res.status(400).json({ error: 'Name required' }); + const id = db.prepare(`INSERT INTO students (name, emoji) VALUES (?, ?)`) + .run(name, req.body.emoji || 'πŸ™‚').lastInsertRowid; + res.json({ id }); +}); + +app.delete('/api/students/:id', requirePin, (req, res) => { + db.prepare(`DELETE FROM students WHERE id = ?`).run(req.params.id); + res.json({ ok: true }); +}); + +app.post('/api/assign', requirePin, (req, res) => { + const { studentId, listId } = req.body; + db.prepare(` + INSERT INTO assignments (student_id, list_id) VALUES (?, ?) + ON CONFLICT (student_id) DO UPDATE SET list_id = excluded.list_id, assigned_at = datetime('now') + `).run(studentId, listId); + res.json({ ok: true }); +}); + +// Legacy spelling-only dashboard summary +app.get('/api/overview', requirePin, (req, res) => { + const students = db.prepare(`SELECT id, name, emoji FROM students ORDER BY name`).all(); + for (const s of students) { + s.assignment = db.prepare(` + SELECT l.id, l.name FROM assignments a JOIN lists l ON l.id = a.list_id WHERE a.student_id = ? + `).get(s.id) || null; + s.mastered = db.prepare( + `SELECT COUNT(*) AS n FROM progress WHERE student_id = ? AND box >= ${MASTERED_BOX}` + ).get(s.id).n; + s.tests = db.prepare(` + SELECT t.id, t.score, t.total, t.at, l.name AS list + FROM tests t JOIN lists l ON l.id = t.list_id + WHERE t.student_id = ? ORDER BY t.at DESC LIMIT 20 + `).all(s.id); + } + res.json({ students }); +}); + +// ============================================================ +// LMS layer: courses, units, items, submissions, gradebook +// ============================================================ + +// ---------- courses (admin) ---------- + +app.get('/api/admin/courses', requirePin, (req, res) => { + res.json(db.prepare(` + SELECT c.id, c.name, c.subject, c.color, c.archived, + (SELECT COUNT(*) FROM units u WHERE u.course_id = c.id) AS unitCount, + (SELECT COUNT(*) FROM items i JOIN units u ON u.id = i.unit_id WHERE u.course_id = c.id) AS itemCount + FROM courses c ORDER BY c.archived, c.name + `).all()); +}); + +app.post('/api/courses', requirePin, (req, res) => { + const { name, subject, color } = req.body; + if (!String(name || '').trim()) return res.status(400).json({ error: 'Name required' }); + const id = db.prepare(`INSERT INTO courses (name, subject, color) VALUES (?, ?, ?)`) + .run(String(name).trim(), String(subject || '').trim(), color || '#4f86f7').lastInsertRowid; + res.json({ id }); +}); + +app.put('/api/courses/:id', requirePin, (req, res) => { + const { name, subject, color, archived } = req.body; + if (!db.prepare(`SELECT id FROM courses WHERE id = ?`).get(req.params.id)) { + return res.status(404).json({ error: 'No such course' }); + } + db.prepare(`UPDATE courses SET name = ?, subject = ?, color = ?, archived = ? WHERE id = ?`) + .run(String(name).trim(), String(subject || '').trim(), color || '#4f86f7', archived ? 1 : 0, req.params.id); + res.json({ ok: true }); +}); + +app.delete('/api/courses/:id', requirePin, (req, res) => { + db.prepare(`DELETE FROM courses WHERE id = ?`).run(req.params.id); + res.json({ ok: true }); +}); + +app.post('/api/courses/:id/enroll', requirePin, (req, res) => { + db.prepare(`INSERT OR IGNORE INTO enrollments (student_id, course_id) VALUES (?, ?)`) + .run(req.body.studentId, req.params.id); + res.json({ ok: true }); +}); + +app.delete('/api/courses/:id/enroll/:studentId', requirePin, (req, res) => { + db.prepare(`DELETE FROM enrollments WHERE student_id = ? AND course_id = ?`) + .run(req.params.studentId, req.params.id); + res.json({ ok: true }); +}); + +// Full course structure for the admin course editor (no student-specific filtering) +app.get('/api/admin/courses/:id', requirePin, (req, res) => { + const course = db.prepare(`SELECT id, name, subject, color, archived FROM courses WHERE id = ?`).get(req.params.id); + if (!course) return res.status(404).json({ error: 'No such course' }); + const units = db.prepare(`SELECT id, name, sort FROM units WHERE course_id = ? ORDER BY sort, id`).all(req.params.id); + const itemStmt = db.prepare(`SELECT id, type, title, points, ref_id, sort FROM items WHERE unit_id = ? ORDER BY sort, id`); + for (const u of units) u.items = itemStmt.all(u.id); + res.json({ ...course, units }); +}); + +// Full item detail for the admin item editor (quiz correct answers always included) +app.get('/api/admin/items/:id', requirePin, (req, res) => { + const item = db.prepare(`SELECT id, unit_id, type, title, body, points, ref_id FROM items WHERE id = ?`).get(req.params.id); + if (!item) return res.status(404).json({ error: 'No such item' }); + if (item.type === 'quiz') { + item.questions = db.prepare(` + SELECT id, type, prompt, choices, correct_answer, points FROM quiz_questions WHERE item_id = ? ORDER BY sort, id + `).all(req.params.id).map((q) => ({ ...q, choices: JSON.parse(q.choices) })); + } + res.json(item); +}); + +app.get('/api/courses/:id/roster', requirePin, (req, res) => { + res.json(db.prepare(` + SELECT s.id, s.name, s.emoji, (e.student_id IS NOT NULL) AS enrolled + FROM students s LEFT JOIN enrollments e ON e.student_id = s.id AND e.course_id = ? + ORDER BY s.name + `).all(req.params.id)); +}); + +// ---------- units (admin) ---------- + +app.post('/api/units', requirePin, (req, res) => { + const { courseId, name } = req.body; + if (!String(name || '').trim()) return res.status(400).json({ error: 'Name required' }); + const sort = db.prepare(`SELECT COALESCE(MAX(sort), -1) + 1 AS n FROM units WHERE course_id = ?`).get(courseId).n; + const id = db.prepare(`INSERT INTO units (course_id, name, sort) VALUES (?, ?, ?)`) + .run(courseId, String(name).trim(), sort).lastInsertRowid; + res.json({ id }); +}); + +app.put('/api/units/:id', requirePin, (req, res) => { + const { name, sort } = req.body; + db.prepare(`UPDATE units SET name = COALESCE(?, name), sort = COALESCE(?, sort) WHERE id = ?`) + .run(name ? String(name).trim() : null, Number.isInteger(sort) ? sort : null, req.params.id); + res.json({ ok: true }); +}); + +app.delete('/api/units/:id', requirePin, (req, res) => { + db.prepare(`DELETE FROM units WHERE id = ?`).run(req.params.id); + res.json({ ok: true }); +}); + +// ---------- items (admin) ---------- + +function validateItemBody(body) { + const { type, title, refId } = body; + if (!ITEM_TYPES.includes(type)) return 'Invalid item type'; + if (!String(title || '').trim()) return 'Title required'; + if (type === 'spelling_practice' || type === 'spelling_test') { + if (!db.prepare(`SELECT id FROM lists WHERE id = ?`).get(refId)) return 'Pick a word list'; + } + if (type === 'flashcards' && !db.prepare(`SELECT id FROM decks WHERE id = ?`).get(refId)) { + return 'Pick a deck'; + } + if (type === 'quiz' && (!Array.isArray(body.questions) || body.questions.length === 0)) { + return 'Quiz needs at least one question'; + } + return null; +} + +function saveQuizQuestions(itemId, questions) { + db.prepare(`DELETE FROM quiz_questions WHERE item_id = ?`).run(itemId); + const ins = db.prepare(` + INSERT INTO quiz_questions (item_id, type, prompt, choices, correct_answer, points, sort) + VALUES (?, ?, ?, ?, ?, ?, ?) + `); + questions.forEach((q, i) => { + ins.run( + itemId, + q.type, + String(q.prompt).trim(), + JSON.stringify(q.type === 'mc' ? (q.choices || []) : []), + String(q.correctAnswer).trim(), + Number(q.points) || 1, + i + ); + }); +} + +app.post('/api/items', requirePin, (req, res) => { + const err = validateItemBody(req.body); + if (err) return res.status(400).json({ error: err }); + const { unitId, type, title, body, points, refId } = req.body; + const sort = db.prepare(`SELECT COALESCE(MAX(sort), -1) + 1 AS n FROM items WHERE unit_id = ?`).get(unitId).n; + const id = db.prepare(` + INSERT INTO items (unit_id, type, title, body, points, ref_id, sort) VALUES (?, ?, ?, ?, ?, ?, ?) + `).run(unitId, type, String(title).trim(), String(body || ''), Number(points) || 0, + (type === 'flashcards' || type === 'spelling_practice' || type === 'spelling_test') ? refId : null, + sort).lastInsertRowid; + if (type === 'quiz') { + saveQuizQuestions(id, req.body.questions); + const totalPoints = req.body.questions.reduce((sum, q) => sum + (Number(q.points) || 1), 0); + db.prepare(`UPDATE items SET points = ? WHERE id = ?`).run(totalPoints, id); + } + res.json({ id }); +}); + +app.put('/api/items/:id', requirePin, (req, res) => { + const err = validateItemBody(req.body); + if (err) return res.status(400).json({ error: err }); + if (!db.prepare(`SELECT id FROM items WHERE id = ?`).get(req.params.id)) { + return res.status(404).json({ error: 'No such item' }); + } + const { type, title, body, points, refId } = req.body; + const finalPoints = type === 'quiz' + ? req.body.questions.reduce((sum, q) => sum + (Number(q.points) || 1), 0) + : (Number(points) || 0); + db.prepare(`UPDATE items SET type = ?, title = ?, body = ?, points = ?, ref_id = ? WHERE id = ?`) + .run(type, String(title).trim(), String(body || ''), finalPoints, + (type === 'flashcards' || type === 'spelling_practice' || type === 'spelling_test') ? refId : null, + req.params.id); + if (type === 'quiz') saveQuizQuestions(req.params.id, req.body.questions); + res.json({ ok: true }); +}); + +app.delete('/api/items/:id', requirePin, (req, res) => { + db.prepare(`DELETE FROM items WHERE id = ?`).run(req.params.id); + res.json({ ok: true }); +}); + +// ---------- kid course views ---------- + +// Enrolled courses with rough completion progress +app.get('/api/courses/mine/:studentId', (req, res) => { + const studentId = req.params.studentId; + const courses = db.prepare(` + SELECT c.id, c.name, c.subject, c.color + FROM courses c JOIN enrollments e ON e.course_id = c.id + WHERE e.student_id = ? AND c.archived = 0 + ORDER BY c.name + `).all(studentId); + for (const c of courses) { + const totals = db.prepare(` + SELECT COUNT(*) AS total, + SUM(CASE WHEN s.id IS NOT NULL THEN 1 ELSE 0 END) AS done + FROM items i + JOIN units u ON u.id = i.unit_id + LEFT JOIN submissions s ON s.item_id = i.id AND s.student_id = ? + WHERE u.course_id = ? + `).get(studentId, c.id); + c.total = totals.total; + c.done = totals.done || 0; + } + res.json(courses); +}); + +// Full course outline with per-item status for this student +app.get('/api/courses/:id/detail', (req, res) => { + const studentId = req.query.studentId; + const course = db.prepare(`SELECT id, name, subject, color FROM courses WHERE id = ?`).get(req.params.id); + if (!course) return res.status(404).json({ error: 'No such course' }); + + const units = db.prepare(`SELECT id, name, sort FROM units WHERE course_id = ? ORDER BY sort, id`).all(req.params.id); + const itemStmt = db.prepare(` + SELECT i.id, i.type, i.title, i.points, i.sort, + s.status, s.score, s.points_possible + FROM items i + LEFT JOIN submissions s ON s.item_id = i.id AND s.student_id = ? + WHERE i.unit_id = ? ORDER BY i.sort, i.id + `); + for (const u of units) { + u.items = itemStmt.all(studentId, u.id).map((it) => ({ ...it, status: it.status || 'not_started' })); + } + res.json({ ...course, units }); +}); + +// Single item detail for the kid to work on +app.get('/api/items/:id', (req, res) => { + const studentId = req.query.studentId; + const item = db.prepare(` + SELECT i.id, i.type, i.title, i.body, i.points, i.ref_id, u.name AS unit_name, u.course_id, c.name AS course_name + FROM items i JOIN units u ON u.id = i.unit_id JOIN courses c ON c.id = u.course_id + WHERE i.id = ? + `).get(req.params.id); + if (!item) return res.status(404).json({ error: 'No such item' }); + + item.submission = db.prepare(` + SELECT status, score, points_possible, answers, completed_at, graded_at + FROM submissions WHERE student_id = ? AND item_id = ? + `).get(studentId, req.params.id) || null; + + if (item.type === 'quiz') { + const graded = item.submission && item.submission.status === 'graded'; + const savedAnswers = graded && item.submission.answers ? JSON.parse(item.submission.answers) : null; + item.questions = db.prepare(` + SELECT id, type, prompt, choices, points ${graded ? ', correct_answer' : ''} + FROM quiz_questions WHERE item_id = ? ORDER BY sort, id + `).all(req.params.id).map((q) => ({ + ...q, + choices: JSON.parse(q.choices), + given: savedAnswers ? savedAnswers[q.id] : undefined, + })); + } else if (item.type === 'flashcards') { + item.deck = db.prepare(`SELECT id, name FROM decks WHERE id = ?`).get(item.ref_id); + } else if (item.type === 'spelling_practice' || item.type === 'spelling_test') { + item.list = db.prepare(`SELECT id, name FROM lists WHERE id = ?`).get(item.ref_id); + } + res.json(item); +}); + +// Mark a lesson viewed, an assignment done, or a practice/flashcards session finished. +// Never downgrades an already-graded submission (quizzes, spelling tests). +app.post('/api/items/:id/complete', (req, res) => { + const { studentId, date } = req.body; + const item = db.prepare(`SELECT id, points FROM items WHERE id = ?`).get(req.params.id); + if (!item) return res.status(404).json({ error: 'No such item' }); + + db.prepare(` + INSERT INTO submissions (student_id, item_id, status, points_possible, completed_at) + VALUES (?, ?, 'done', ?, datetime('now')) + ON CONFLICT (student_id, item_id) DO UPDATE SET completed_at = datetime('now') + WHERE submissions.status != 'graded' + `).run(studentId, req.params.id, item.points || null); + + markScheduleDone.run(studentId, req.params.id, isDateStr(date) ? date : today()); + res.json({ ok: true }); +}); + +// Auto-graded quiz submission +app.post('/api/items/:id/quiz-submit', (req, res) => { + const { studentId, answers, date } = req.body; // answers: { questionId: value } + const questions = db.prepare(`SELECT * FROM quiz_questions WHERE item_id = ?`).all(req.params.id); + if (questions.length === 0) return res.status(404).json({ error: 'No such quiz' }); + + let earned = 0, possible = 0; + const record = {}; + for (const q of questions) { + possible += q.points; + const given = answers ? answers[q.id] : undefined; + const correct = given !== undefined && normalize(given) === normalize(q.correct_answer); + if (correct) earned += q.points; + record[q.id] = given ?? ''; + } + + db.prepare(` + INSERT INTO submissions (student_id, item_id, status, score, points_possible, answers, completed_at, graded_at) + VALUES (?, ?, 'graded', ?, ?, ?, datetime('now'), datetime('now')) + ON CONFLICT (student_id, item_id) DO UPDATE SET + status = 'graded', score = excluded.score, points_possible = excluded.points_possible, + answers = excluded.answers, completed_at = datetime('now'), graded_at = datetime('now') + `).run(studentId, req.params.id, earned, possible, JSON.stringify(record)); + + markScheduleDone.run(studentId, req.params.id, isDateStr(date) ? date : today()); + res.json({ score: earned, total: possible }); +}); + +// ---------- grading (parent) ---------- + +app.get('/api/grading-queue', requirePin, (req, res) => { + res.json(db.prepare(` + SELECT sub.id AS submissionId, sub.completed_at, sub.points_possible, + s.id AS studentId, s.name AS studentName, s.emoji, + i.id AS itemId, i.title AS itemTitle, + c.name AS courseName, u.name AS unitName + FROM submissions sub + JOIN students s ON s.id = sub.student_id + JOIN items i ON i.id = sub.item_id + JOIN units u ON u.id = i.unit_id + JOIN courses c ON c.id = u.course_id + WHERE sub.status = 'done' AND i.type = 'assignment' + ORDER BY sub.completed_at + `).all()); +}); + +app.put('/api/submissions/:id/grade', requirePin, (req, res) => { + const score = Number(req.body.score); + if (Number.isNaN(score)) return res.status(400).json({ error: 'Score must be a number' }); + db.prepare(`UPDATE submissions SET score = ?, status = 'graded', graded_at = datetime('now') WHERE id = ?`) + .run(score, req.params.id); + res.json({ ok: true }); +}); + +// ---------- gradebook (parent) ---------- + +app.get('/api/gradebook/:courseId', requirePin, (req, res) => { + const course = db.prepare(`SELECT id, name FROM courses WHERE id = ?`).get(req.params.courseId); + if (!course) return res.status(404).json({ error: 'No such course' }); + + const gradableItems = db.prepare(` + SELECT i.id, i.title, i.type, i.points + FROM items i JOIN units u ON u.id = i.unit_id + WHERE u.course_id = ? AND i.type IN (${GRADABLE_TYPES.map(() => '?').join(',')}) + ORDER BY u.sort, i.sort, i.id + `).all(req.params.courseId, ...GRADABLE_TYPES); + + const students = db.prepare(` + SELECT s.id, s.name, s.emoji FROM students s + JOIN enrollments e ON e.student_id = s.id WHERE e.course_id = ? ORDER BY s.name + `).all(req.params.courseId); + + const scoreStmt = db.prepare(`SELECT score, points_possible, status FROM submissions WHERE student_id = ? AND item_id = ?`); + for (const s of students) { + s.scores = {}; + let earned = 0, possible = 0; + for (const item of gradableItems) { + const row = scoreStmt.get(s.id, item.id); + s.scores[item.id] = row || null; + if (row && row.status === 'graded' && row.points_possible) { + earned += row.score; + possible += row.points_possible; + } + } + s.percent = possible > 0 ? Math.round((earned / possible) * 100) : null; + } + + res.json({ course, gradableItems, students }); +}); + +// ---------- daily agenda / scheduling ---------- + +// Kid's checklist for one day +app.get('/api/schedule/:studentId', (req, res) => { + const date = isDateStr(req.query.date) ? req.query.date : null; + if (!date) return res.status(400).json({ error: 'date=YYYY-MM-DD required' }); + const tasks = db.prepare(` + SELECT sc.id, sc.date, sc.title AS offlineTitle, sc.done, + i.id AS itemId, i.type, i.title AS itemTitle, + c.name AS courseName, c.color AS courseColor, + sub.status AS subStatus, sub.score, sub.points_possible + FROM schedule sc + LEFT JOIN items i ON i.id = sc.item_id + LEFT JOIN units u ON u.id = i.unit_id + LEFT JOIN courses c ON c.id = u.course_id + LEFT JOIN submissions sub ON sub.item_id = sc.item_id AND sub.student_id = sc.student_id + WHERE sc.student_id = ? AND sc.date = ? + ORDER BY sc.sort, sc.id + `).all(req.params.studentId, date); + res.json({ tasks }); +}); + +// Kid toggles a standalone offline task +app.post('/api/schedule/:id/done', (req, res) => { + const row = db.prepare(`SELECT item_id FROM schedule WHERE id = ?`).get(req.params.id); + if (!row) return res.status(404).json({ error: 'No such task' }); + if (row.item_id) return res.status(400).json({ error: 'Course items complete themselves β€” open the item instead' }); + const done = req.body.done ? 1 : 0; + db.prepare(` + UPDATE schedule SET done = ?, done_at = CASE WHEN ? THEN datetime('now') ELSE NULL END WHERE id = ? + `).run(done, done, req.params.id); + res.json({ ok: true }); +}); + +app.post('/api/schedule', requirePin, (req, res) => { + const { studentId, date, itemId, title } = req.body; + if (!isDateStr(date)) return res.status(400).json({ error: 'date=YYYY-MM-DD required' }); + if (!itemId && !String(title || '').trim()) return res.status(400).json({ error: 'Pick an item or write a task' }); + if (itemId && !db.prepare(`SELECT id FROM items WHERE id = ?`).get(itemId)) { + return res.status(400).json({ error: 'No such item' }); + } + const sort = db.prepare(`SELECT COALESCE(MAX(sort), -1) + 1 AS n FROM schedule WHERE student_id = ? AND date = ?`) + .get(studentId, date).n; + const id = db.prepare(` + INSERT INTO schedule (student_id, date, item_id, title, sort) VALUES (?, ?, ?, ?, ?) + `).run(studentId, date, itemId || null, itemId ? '' : String(title).trim(), sort).lastInsertRowid; + res.json({ id }); +}); + +app.delete('/api/schedule/:id', requirePin, (req, res) => { + db.prepare(`DELETE FROM schedule WHERE id = ?`).run(req.params.id); + res.json({ ok: true }); +}); + +// One week of plan for the parent grid (start = Monday) +app.get('/api/schedule-week/:studentId', requirePin, (req, res) => { + const start = isDateStr(req.query.start) ? req.query.start : null; + if (!start) return res.status(400).json({ error: 'start=YYYY-MM-DD required' }); + const tasks = db.prepare(` + SELECT sc.id, sc.date, sc.title AS offlineTitle, sc.done, + i.id AS itemId, i.type, i.title AS itemTitle, c.name AS courseName + FROM schedule sc + LEFT JOIN items i ON i.id = sc.item_id + LEFT JOIN units u ON u.id = i.unit_id + LEFT JOIN courses c ON c.id = u.course_id + WHERE sc.student_id = ? AND sc.date BETWEEN ? AND date(?, '+4 days') + ORDER BY sc.date, sc.sort, sc.id + `).all(req.params.studentId, start, start); + res.json({ tasks }); +}); + +// Copy one week's plan to another week +app.post('/api/schedule/copy', requirePin, (req, res) => { + const { studentId, from, to } = req.body; + if (!isDateStr(from) || !isDateStr(to)) return res.status(400).json({ error: 'from/to dates required' }); + const offsetDays = Math.round((new Date(to) - new Date(from)) / 86400000); + const rows = db.prepare(` + SELECT date, item_id, title, sort FROM schedule WHERE student_id = ? AND date BETWEEN ? AND date(?, '+4 days') + `).all(studentId, from, from); + const ins = db.prepare(` + INSERT INTO schedule (student_id, date, item_id, title, sort) VALUES (?, date(?, ?), ?, ?, ?) + `); + for (const r of rows) ins.run(studentId, r.date, `+${offsetDays} days`, r.item_id, r.title, r.sort); + res.json({ copied: rows.length }); +}); + +// Printable weekly report: schedule completion + any graded work that week +app.get('/api/week-report/:studentId', requirePin, (req, res) => { + const start = isDateStr(req.query.start) ? req.query.start : null; + if (!start) return res.status(400).json({ error: 'start=YYYY-MM-DD required' }); + const student = db.prepare(`SELECT name FROM students WHERE id = ?`).get(req.params.studentId); + if (!student) return res.status(404).json({ error: 'No such student' }); + + const tasks = db.prepare(` + SELECT sc.date, sc.title AS offlineTitle, sc.done, i.title AS itemTitle, i.type, c.name AS courseName + FROM schedule sc + LEFT JOIN items i ON i.id = sc.item_id + LEFT JOIN units u ON u.id = i.unit_id + LEFT JOIN courses c ON c.id = u.course_id + WHERE sc.student_id = ? AND sc.date BETWEEN ? AND date(?, '+4 days') + ORDER BY sc.date, sc.sort, sc.id + `).all(req.params.studentId, start, start); + + const graded = db.prepare(` + SELECT sub.score, sub.points_possible, sub.graded_at, i.title AS itemTitle, c.name AS courseName + FROM submissions sub + JOIN items i ON i.id = sub.item_id + JOIN units u ON u.id = i.unit_id + JOIN courses c ON c.id = u.course_id + WHERE sub.student_id = ? AND sub.status = 'graded' AND date(sub.graded_at) BETWEEN ? AND date(?, '+4 days') + ORDER BY sub.graded_at + `).all(req.params.studentId, start, start); + + const spellingTests = db.prepare(` + SELECT t.score, t.total, t.at, l.name AS list + FROM tests t JOIN lists l ON l.id = t.list_id + WHERE t.student_id = ? AND date(t.at) BETWEEN ? AND date(?, '+4 days') + ORDER BY t.at + `).all(req.params.studentId, start, start); + + res.json({ student: student.name, start, tasks, graded, spellingTests }); +}); + +// ---------- page scanning: photo -> lesson/quiz content ---------- + +const getSetting = (key) => db.prepare(`SELECT value FROM settings WHERE key = ?`).get(key)?.value; + +const SCAN_PROMPTS = { + lesson: `You are an OCR engine. Your only job is to copy the printed text from this image exactly as it appears β€” word for word, letter for letter. Do NOT summarize, paraphrase, rewrite, or shorten anything. Do NOT add explanations or commentary. + +Rules: +- Copy every word of the body text exactly as printed. +- Preserve paragraph breaks (use a blank line between paragraphs). +- If there is a heading or title printed on the page, put it in "title". Otherwise use a very short descriptive label. +- Skip page numbers, running headers/footers, and captions under illustrations β€” copy body text only. +- If a word is hard to read, give your best exact guess β€” never skip it or replace it with a summary. + +Respond with ONLY a JSON object in exactly this shape, nothing else, no markdown fences: +{"title": "the heading printed on the page", "body": "the complete word-for-word text of the page, paragraphs separated by a blank line"}`, + + quiz: `You are an OCR engine helping digitize a quiz or test page. Copy every question and every answer choice exactly as printed β€” word for word. Do NOT summarize, rewrite, or skip any question. + +For each question extract: +- "type": "mc" if it has lettered/numbered answer choices, "tf" if it is true/false, "short" if it expects a written answer +- "prompt": the exact question text as printed +- "choices": for "mc", an array of the exact answer choice texts as printed (empty array for "tf" or "short") +- "correctAnswer": copy the marked answer ONLY if one is clearly indicated (circled, checked, starred, bolded, or in an answer key on the page). For "tf" use exactly "true" or "false". If no answer is marked, use "". +- "points": 1 unless the page prints a different value + +Respond with ONLY a JSON object in exactly this shape, nothing else, no markdown fences: +{"title": "the quiz title as printed, or a short label if none", "questions": [{"type": "mc", "prompt": "...", "choices": ["...", "..."], "correctAnswer": "...", "points": 1}]}`, +}; + +function parseVisionJson(text) { + try { return JSON.parse(text); } catch { /* fall through */ } + const match = String(text).match(/\{[\s\S]*\}/); + if (!match) throw new Error('Model didn\'t return usable JSON. Try again or try a different model.'); + return JSON.parse(match[0]); +} + +// Local Ollama provider +async function callOllama(imageBase64, mode) { + const host = (getSetting('ollama_host') || 'http://localhost:11434').replace(/\/+$/, ''); + const model = getSetting('ollama_model') || 'llava'; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 180_000); + let res; + try { + res = await fetch(`${host}/api/generate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model, prompt: SCAN_PROMPTS[mode], images: [imageBase64], stream: false, format: 'json' }), + signal: controller.signal, + }); + } catch (err) { + if (err.name === 'AbortError') throw new Error('Ollama timed out (3 min). Try a smaller photo or a faster model.'); + throw new Error(`Couldn't reach Ollama at ${host} β€” is it running? (${err.message})`); + } finally { clearTimeout(timer); } + if (!res.ok) { + const text = await res.text().catch(() => ''); + if (res.status === 404) throw new Error(`Model "${model}" not found on Ollama. Run: ollama pull ${model}`); + throw new Error(`Ollama error (${res.status}): ${text.slice(0, 200)}`); + } + const data = await res.json(); + return parseVisionJson(data.response); +} + +// Ollama Cloud provider β€” routes through local Ollama with :cloud model tag. +// Cloud models are pulled via local Ollama (e.g. `ollama pull gemma4:cloud`) and +// automatically offloaded to Ollama's cloud infrastructure. +async function callOllamaCloud(imageBase64, mode) { + const apiKey = getSetting('ollama_cloud_api_key') || ''; + const host = (getSetting('ollama_host') || 'http://localhost:11434').replace(/\/+$/, ''); + // Ensure model has :cloud suffix so Ollama offloads it + const baseModel = (getSetting('ollama_cloud_model') || 'gemma4').replace(/:cloud$/, ''); + const model = `${baseModel}:cloud`; + if (!apiKey) throw new Error('Ollama Cloud API key not set β€” add it in Settings.'); + + // Ollama reads OLLAMA_API_KEY from env; we inject it per-request via Authorization header + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 180_000); + let res; + try { + // Auth is handled by Ollama via OLLAMA_API_KEY env var β€” do not send it as a header + res = await fetch(`${host}/api/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model, + messages: [{ role: 'user', content: SCAN_PROMPTS[mode], images: [imageBase64] }], + stream: false, + }), + signal: controller.signal, + }); + } catch (err) { + if (err.name === 'AbortError') throw new Error('Ollama Cloud timed out (3 min).'); + throw new Error(`Couldn't reach local Ollama at ${host} β€” is it running? (${err.message})`); + } finally { clearTimeout(timer); } + if (!res.ok) { + const text = await res.text().catch(() => ''); + if (res.status === 404) throw new Error(`Cloud model "${model}" not found. Run: ollama pull ${model}`); + throw new Error(`Ollama Cloud error (${res.status}): ${text.slice(0, 300)}`); + } + const data = await res.json(); + const content = data.message?.content; + if (!content) throw new Error('Ollama Cloud returned an empty response.'); + return parseVisionJson(content); +} + +// Try primary provider, fall back to the other +app.post('/api/admin/scan', requirePin, async (req, res) => { + const { image, mode } = req.body; + if (!image) return res.status(400).json({ error: 'No image provided' }); + if (!SCAN_PROMPTS[mode]) return res.status(400).json({ error: 'mode must be "lesson" or "quiz"' }); + + const primary = getSetting('scan_primary') || 'local'; + const providerFns = { local: callOllama, ollama_cloud: callOllamaCloud }; + const order = primary === 'ollama_cloud' + ? ['ollama_cloud', 'local'] + : ['local', 'ollama_cloud']; + const isConfigured = { + local: true, + ollama_cloud: !!getSetting('ollama_cloud_api_key'), + }; + + const errors = []; + for (const provider of order) { + if (!isConfigured[provider]) continue; + try { + const result = await providerFns[provider](image, mode); + return res.json({ ...result, _provider: provider, _fallback: provider !== primary, _primaryError: errors[0] || null }); + } catch (err) { + errors.push(`${provider}: ${err.message}`); + } + } + return res.status(502).json({ error: errors.join(' | ') || 'No scan providers configured.' }); +}); + +app.get('/api/admin/scan-config', requirePin, (req, res) => { + res.json({ + primary: getSetting('scan_primary') || 'local', + ollama_host: getSetting('ollama_host'), + ollama_model: getSetting('ollama_model'), + ollama_cloud_model: getSetting('ollama_cloud_model') || '', + ollama_cloud_has_key: !!(getSetting('ollama_cloud_api_key')), + }); +}); + +app.post('/api/admin/scan-config', requirePin, (req, res) => { + const { primary, ollama_host, ollama_model, ollama_cloud_model, ollama_cloud_api_key } = req.body; + const set = (k, v) => { + if (v === undefined) return; + const s = String(v).trim(); + if (s) db.prepare(`INSERT INTO settings (key,value) VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`).run(k, s); + }; + set('scan_primary', primary); + set('ollama_host', ollama_host); + set('ollama_model', ollama_model); + set('ollama_cloud_model', ollama_cloud_model); + if (ollama_cloud_api_key !== undefined) + db.prepare(`INSERT INTO settings (key,value) VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`).run('ollama_cloud_api_key', String(ollama_cloud_api_key).trim()); + res.json({ ok: true }); +}); + +// Keep backward-compat routes for existing ollama-config/test calls +app.get('/api/admin/ollama-config', requirePin, (req, res) => { + res.json({ host: getSetting('ollama_host'), model: getSetting('ollama_model') }); +}); +app.post('/api/admin/ollama-config', requirePin, (req, res) => { + const { host, model } = req.body; + if (String(host || '').trim()) db.prepare(`INSERT INTO settings (key,value) VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`).run('ollama_host', String(host).trim()); + if (String(model || '').trim()) db.prepare(`INSERT INTO settings (key,value) VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`).run('ollama_model', String(model).trim()); + res.json({ ok: true }); +}); + +app.post('/api/admin/ollama-test', requirePin, async (req, res) => { + const host = (getSetting('ollama_host') || '').replace(/\/+$/, ''); + const model = getSetting('ollama_model'); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 5000); + try { + const r = await fetch(`${host}/api/tags`, { signal: controller.signal }); + if (!r.ok) throw new Error(`Ollama responded with HTTP ${r.status}`); + const { models } = await r.json(); + const names = (models || []).map((m) => m.name); + const hasModel = names.some((n) => n === model || n.startsWith(`${model}:`)); + res.json({ reachable: true, hasModel, availableModels: names }); + } catch (err) { + res.json({ reachable: false, error: err.name === 'AbortError' ? 'Timed out reaching Ollama' : err.message }); + } finally { + clearTimeout(timer); + } +}); + +app.get('/health', (req, res) => res.json({ ok: true })); + +const PORT = process.env.PORT || 3000; +app.listen(PORT, () => console.log(`Spelling v2 running on http://localhost:${PORT}`));