Add GitHub Actions workflow and update docker-compose to use GHCR

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-07-14 12:05:52 -04:00
commit 23b24b5d21
16 files changed
+4844

No files matched your search

+31
View File
@@ -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
+30
View File
@@ -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
+11
View File
@@ -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"]
+52
View File
@@ -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.
+227
View File
@@ -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);
}
}
+9
View File
@@ -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
+865
View File
@@ -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"
}
}
}
+19
View File
@@ -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"
}
}
+286
View File
@@ -0,0 +1,286 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Parent Dashboard</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<section id="pin-gate" class="view parent">
<h1>👩‍🏫 Parent Dashboard</h1>
<form id="pin-form">
<input id="pin-input" type="password" inputmode="numeric" placeholder="Enter PIN" aria-label="Parent PIN">
<button type="submit">Unlock</button>
</form>
<p id="pin-error" class="error" hidden>Wrong PIN, try again.</p>
<p class="hint">Default PIN is 1234 — change it in Settings after unlocking.</p>
<p><a class="link-btn" href="index.html">← Back to kid picker</a></p>
</section>
<div id="console" hidden>
<header class="admin-header">
<a href="index.html" class="admin-logo">🎓 Homeschool</a>
<nav class="admin-nav">
<button class="nav-pill active" data-panel="kids">🧒 Kids</button>
<button class="nav-pill" data-panel="courses">📚 Courses</button>
<button class="nav-pill" data-panel="planner">🗓️ Planner</button>
<button class="nav-pill" data-panel="grading">✅ Grading</button>
<button class="nav-pill" data-panel="gradebook">📊 Gradebook</button>
<button class="nav-pill" data-panel="spelling">✏️ Spelling</button>
<button class="nav-pill" data-panel="decks">🗂️ Flashcards</button>
<button class="nav-pill" data-panel="settings">⚙️ Settings</button>
</nav>
</header>
<main class="admin-main">
<!-- Kids -->
<section id="panel-kids" class="panel">
<h1>Kids</h1>
<div id="student-rows"></div>
<form id="add-student-form" class="inline-form">
<input id="new-student-name" placeholder="Name" aria-label="New kid name">
<select id="new-student-emoji" aria-label="Avatar">
<option>🦊</option><option>🐸</option><option>🦄</option><option>🐯</option>
<option>🐼</option><option>🦖</option><option>🐙</option><option>🦉</option>
</select>
<button type="submit">Add kid</button>
</form>
</section>
<!-- Courses list -->
<section id="panel-courses" class="panel">
<h1>Courses</h1>
<div id="course-rows"></div>
<form id="add-course-form" class="inline-form">
<input id="new-course-name" placeholder="Course name (e.g. Science 3)" aria-label="Course name">
<input id="new-course-subject" placeholder="Subject" aria-label="Subject">
<input id="new-course-color" type="color" value="#4f86f7" aria-label="Color">
<button type="submit">Add course</button>
</form>
</section>
<!-- Course detail (units + items + roster) -->
<section id="panel-course-detail" class="panel" hidden>
<button class="back-btn" data-nav="courses">← All courses</button>
<h1 id="cd-name"></h1>
<div class="admin-card">
<h2>Course info</h2>
<form id="course-meta-form" class="inline-form">
<input id="cd-name-input" placeholder="Course name">
<input id="cd-subject-input" placeholder="Subject">
<input id="cd-color-input" type="color">
<label class="checkbox-label"><input type="checkbox" id="cd-archived"> Archived</label>
<button type="submit">Save</button>
<button type="button" id="cd-delete" class="danger">Delete course</button>
</form>
</div>
<div class="admin-card">
<h2>Roster</h2>
<div id="cd-roster"></div>
</div>
<div class="admin-card">
<h2>Units &amp; content</h2>
<div id="cd-units"></div>
<form id="add-unit-form" class="inline-form">
<input id="new-unit-name" placeholder="Unit name (e.g. Week 1: Weather)" aria-label="Unit name">
<button type="submit">Add unit</button>
</form>
</div>
</section>
<!-- Item editor (lesson / assignment / quiz / spelling / flashcards) -->
<section id="panel-item-editor" class="panel" hidden>
<button class="back-btn" data-nav="course-detail">← Back to course</button>
<h1 id="ie-heading">New item</h1>
<form id="item-form" class="admin-card">
<input type="hidden" id="ie-id">
<input type="hidden" id="ie-unit-id">
<label>Type
<select id="ie-type">
<option value="lesson">📖 Lesson</option>
<option value="assignment">📝 Assignment</option>
<option value="quiz">❓ Quiz</option>
<option value="spelling_practice">✏️ Spelling Practice</option>
<option value="spelling_test">⭐ Spelling Test</option>
<option value="flashcards">🗂️ Flashcards</option>
</select>
</label>
<label>Title <input id="ie-title" placeholder="Item title"></label>
<!-- Scan a page: visible for lesson + quiz types -->
<div id="ie-scan-section" class="scan-card" hidden>
<strong>📷 Scan a book page</strong>
<input type="file" id="scan-file-input" accept="image/*" capture="environment" hidden>
<div class="scan-controls">
<button type="button" id="scan-pick-btn" class="secondary">Choose photo</button>
<button type="button" id="scan-go-btn" disabled>🔍 Scan this page</button>
</div>
<img id="scan-preview" alt="Selected page photo" hidden>
<p id="scan-status" class="hint"></p>
</div>
<div id="ie-field-lesson" class="ie-field">
<label>Lesson content (text, or paste HTML — images/YouTube embeds allowed)
<textarea id="ie-body-lesson" rows="8" placeholder="Weather is what the sky and air are doing right now.&#10;&#10;&lt;iframe src=&quot;https://www.youtube.com/embed/VIDEO_ID&quot;&gt;&lt;/iframe&gt;"></textarea>
</label>
</div>
<div id="ie-field-assignment" class="ie-field">
<label>Instructions <textarea id="ie-body-assignment" rows="5" placeholder="Draw today's weather and write one sentence."></textarea></label>
<label>Points possible <input id="ie-points" type="number" min="0" value="10"></label>
</div>
<div id="ie-field-quiz" class="ie-field">
<div id="ie-questions"></div>
<button type="button" id="ie-add-question" class="secondary">+ Add question</button>
</div>
<div id="ie-field-spelling" class="ie-field">
<label>Word list <select id="ie-list"></select></label>
</div>
<div id="ie-field-flashcards" class="ie-field">
<label>Deck <select id="ie-deck"></select></label>
</div>
<div class="row-actions">
<button type="submit">Save item</button>
<button type="button" id="ie-delete" class="danger" hidden>Delete item</button>
</div>
<p id="ie-error" class="error" hidden></p>
</form>
</section>
<!-- Planner -->
<section id="panel-planner" class="panel">
<h1>Weekly Planner</h1>
<div class="inline-form">
<label>Kid <select id="planner-student"></select></label>
<button id="planner-prev" class="secondary">← Prev week</button>
<span id="planner-week-label" class="week-label"></span>
<button id="planner-next" class="secondary">Next week →</button>
<button id="planner-copy" class="secondary">Copy last week here</button>
<button id="planner-print">🖨 Print week report</button>
</div>
<div id="planner-grid" class="planner-grid"></div>
</section>
<!-- Grading queue -->
<section id="panel-grading" class="panel">
<h1>Grading Queue</h1>
<div id="grading-rows"></div>
<p id="grading-empty" class="hint" hidden>Nothing waiting to be graded 🎉</p>
</section>
<!-- Gradebook -->
<section id="panel-gradebook" class="panel">
<h1>Gradebook</h1>
<label>Course <select id="gradebook-course"></select></label>
<div id="gradebook-table"></div>
</section>
<!-- Spelling (legacy word lists + weekly assignment) -->
<section id="panel-spelling" class="panel">
<h1>Spelling</h1>
<h2>This week's list per kid</h2>
<div id="assign-rows"></div>
<h2>Word lists</h2>
<div id="list-rows"></div>
<details id="list-editor-details">
<summary id="list-editor-title">New list</summary>
<form id="list-form" class="admin-card">
<input type="hidden" id="list-id">
<input id="list-name" placeholder="List name (e.g. Week of July 6)" aria-label="List name">
<textarea id="list-words" rows="8"
placeholder="One word per line. Add an example sentence with | like:&#10;receive | Did you receive my letter?"></textarea>
<div class="row-actions">
<button type="submit">Save list</button>
<button type="button" id="list-cancel" class="secondary">Clear</button>
</div>
</form>
</details>
<h2>Test results</h2>
<div id="results-area"></div>
</section>
<!-- Flashcard decks -->
<section id="panel-decks" class="panel">
<h1>Flashcard Decks</h1>
<div id="deck-rows"></div>
<details id="deck-editor-details">
<summary id="deck-editor-title">New deck</summary>
<form id="deck-form" class="admin-card">
<input type="hidden" id="deck-id">
<input id="deck-name" placeholder="Deck name (e.g. State Capitals)" aria-label="Deck name">
<textarea id="deck-cards" rows="8"
placeholder="One card per line: front | back&#10;What is the capital of Ohio? | Columbus"></textarea>
<div class="row-actions">
<button type="submit">Save deck</button>
<button type="button" id="deck-cancel" class="secondary">Clear</button>
</div>
</form>
</details>
</section>
<!-- Settings -->
<section id="panel-settings" class="panel">
<h1>Settings</h1>
<form id="pin-change-form" class="inline-form">
<input id="new-pin" type="password" inputmode="numeric" placeholder="New PIN (4+ digits)" aria-label="New PIN">
<button type="submit">Change PIN</button>
</form>
<h2>Page scanning (AI vision)</h2>
<p class="hint">Used by "Scan a page" in the lesson/quiz editor. Configure one or both providers — the primary is tried first and the other is used as automatic fallback.</p>
<form id="scan-config-form">
<fieldset class="scan-provider-box">
<legend>Primary provider</legend>
<label class="radio-label">
<input type="radio" name="scan_primary" value="local"> Local (Ollama) — runs on your machine, completely free &amp; private
</label>
<label class="radio-label">
<input type="radio" name="scan_primary" value="ollama_cloud"> Ollama Cloud — free hosted tier, requires API key
</label>
</fieldset>
<fieldset class="scan-provider-box">
<legend>Local — Ollama</legend>
<div class="inline-form">
<input id="ollama-host" placeholder="http://localhost:11434" aria-label="Ollama host">
<input id="ollama-model" placeholder="llava" aria-label="Vision model">
<button type="button" id="ollama-test-btn" class="secondary">Test</button>
</div>
<p id="ollama-test-result" class="hint"></p>
<p class="hint">Install a vision model: <code>ollama pull llava</code> or <code>ollama pull llava-llama3</code></p>
</fieldset>
<fieldset class="scan-provider-box">
<legend>Ollama Cloud (free tier)</legend>
<div class="inline-form">
<input id="ollama-cloud-model" placeholder="gemma4" aria-label="Ollama Cloud model">
<input id="ollama-cloud-api-key" type="password" placeholder="Ollama Cloud API key" aria-label="Ollama Cloud API key">
<span id="ollama-cloud-key-status" class="hint"></span>
</div>
<p class="hint">Runs through your local Ollama but offloads compute to Ollama's cloud. Get a free API key at <strong>ollama.com</strong> → Account → API Keys, then pull the model: <code>ollama pull gemma4:cloud</code>. Enter the base name only (e.g. <code>gemma4</code>) — <code>:cloud</code> is added automatically.</p>
</fieldset>
<button type="submit">Save scan settings</button>
</form>
</section>
<p id="admin-msg" class="hint"></p>
</main>
</div>
<script src="admin.js"></script>
</body>
</html>
+965
View File
@@ -0,0 +1,965 @@
// ---------- tiny helpers ----------
const $ = (sel) => document.querySelector(sel);
const esc = (s) =>
String(s).replace(/[&<>"']/g, (c) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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) => `<div class="item-row">
<span>${esc(s.emoji)}</span>
<strong class="grow">${esc(s.name)}</strong>
<button class="danger" data-del-student="${s.id}">Remove</button>
</div>`
)
.join('')
: `<p class="hint">No kids yet — add one below.</p>`;
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) => `<div class="item-row">
<span class="color-dot" style="background:${esc(c.color)}"></span>
<strong class="grow">${esc(c.name)}</strong>
<span>${esc(c.subject || '')}</span>
<span>${c.unitCount} unit${c.unitCount === 1 ? '' : 's'}, ${c.itemCount} item${c.itemCount === 1 ? '' : 's'}</span>
${c.archived ? '<span class="archived-badge">archived</span>' : ''}
<button data-open-course="${c.id}">Open</button>
</div>`
)
.join('')
: `<p class="hint">No courses yet — add one below.</p>`;
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) => `<label class="checkbox-label">
<input type="checkbox" data-roster-student="${s.id}" ${s.enrolled ? 'checked' : ''}>
${esc(s.emoji)} ${esc(s.name)}
</label>`
)
.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) => `<div class="unit-block">
<div class="unit-header">
<h3>${esc(u.name)}</h3>
<button class="danger small" data-del-unit="${u.id}">Delete unit</button>
</div>
${u.items
.map(
(it) => `<div class="item-row">
<span>${TYPE_ICON[it.type]}</span>
<span class="grow">${esc(it.title)}</span>
<span>${TYPE_LABEL[it.type]}</span>
<button data-edit-item="${it.id}" data-unit-id="${u.id}">Edit</button>
</div>`
)
.join('')}
<button class="secondary small" data-add-item="${u.id}">+ Add item to this unit</button>
</div>`
)
.join('') || `<p class="hint">No units yet — add one below.</p>`;
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) => `<option value="${l.id}">${esc(l.name)}</option>`).join('');
$('#ie-deck').innerHTML = cachedDecks.map((d) => `<option value="${d.id}">${esc(d.name)}</option>`).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 = `
<select class="qn-type">
<option value="mc" ${q.type === 'mc' ? 'selected' : ''}>Multiple choice</option>
<option value="tf" ${q.type === 'tf' ? 'selected' : ''}>True / False</option>
<option value="short" ${q.type === 'short' ? 'selected' : ''}>Short answer</option>
</select>
<input class="qn-prompt" placeholder="Question" value="${esc(q.prompt || '')}">
<input class="qn-choices" placeholder="Choices, comma separated (multiple choice only)" value="${esc((q.choices || []).join(', '))}">
<input class="qn-correct" placeholder="Correct answer" value="${esc(q.correct_answer || '')}">
<input class="qn-points" type="number" min="1" value="${q.points || 1}" style="width:4rem">
<button type="button" class="danger small qn-remove">✕</button>`;
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 = `<p class="hint">Add a kid first.</p>`;
return;
}
$('#planner-student').innerHTML = students.map((s) => `<option value="${s.id}">${esc(s.emoji)} ${esc(s.name)}</option>`).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 `<div class="planner-day">
<h3>${day.name}<small>${day.date}</small></h3>
<div class="planner-tasks">
${dayTasks
.map(
(t) => `<div class="planner-task ${t.done ? 'done' : ''}">
<span>${t.itemId ? TYPE_ICON[t.type] : '📌'} ${esc(t.itemTitle || t.offlineTitle)}</span>
<button class="danger tiny" data-del-schedule="${t.id}">✕</button>
</div>`
)
.join('') || '<p class="hint tiny">Nothing planned</p>'}
</div>
<button class="secondary small" data-add-task="${day.date}">+ Add</button>
<div class="add-task-form" data-form-for="${day.date}" hidden></div>
</div>`;
})
.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) => `<optgroup label="${esc(c.name)}"></optgroup>`)
.join('');
box.hidden = false;
box.innerHTML = `
<select class="task-course-select"><option value="">— pick a course item —</option></select>
<div class="or-divider">or</div>
<input class="task-offline-input" placeholder="Offline task, e.g. Read Ch. 3">
<button type="button" class="task-save-btn">Add</button>`;
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) => `<tr><td>${t.date}</td><td>${esc(t.itemTitle || t.offlineTitle)}</td><td>${esc(t.courseName || 'Offline')}</td><td>${t.done ? '✓ done' : '—'}</td></tr>`)
.join('');
const gradedRows = r.graded
.map((g) => `<tr><td>${esc(g.itemTitle)}</td><td>${esc(g.courseName)}</td><td>${g.score}/${g.points_possible}</td></tr>`)
.join('');
const spellingRows = r.spellingTests
.map((t) => `<tr><td>${esc(t.list)}</td><td>${t.score}/${t.total}</td></tr>`)
.join('');
const win = window.open('', '_blank');
win.document.write(`<!DOCTYPE html><html><head><title>Weekly Report</title>
<style>
body { font-family: Georgia, serif; max-width: 640px; margin: 2rem auto; color: #222; }
h1 { font-size: 1.4rem; border-bottom: 2px solid #222; padding-bottom: 0.4rem; }
h2 { font-size: 1.1rem; margin-top: 1.5rem; }
table { width: 100%; border-collapse: collapse; margin-top: 0.5rem; }
td, th { border: 1px solid #999; padding: 0.4rem 0.6rem; text-align: left; }
</style></head><body>
<h1>Weekly Report — ${esc(r.student)}</h1>
<p>Week of ${r.start}</p>
<h2>Schedule</h2>
<table><tr><th>Date</th><th>Task</th><th>Course</th><th>Status</th></tr>${taskRows || '<tr><td colspan="4">Nothing scheduled</td></tr>'}</table>
<h2>Graded work</h2>
<table><tr><th>Item</th><th>Course</th><th>Score</th></tr>${gradedRows || '<tr><td colspan="3">Nothing graded this week</td></tr>'}</table>
<h2>Spelling tests</h2>
<table><tr><th>List</th><th>Score</th></tr>${spellingRows || '<tr><td colspan="2">No spelling tests this week</td></tr>'}</table>
<script>window.print()<\/script></body></html>`);
win.document.close();
}
// ============================================================
// Grading queue
// ============================================================
async function loadGrading() {
const rows = await api('/api/grading-queue');
$('#grading-empty').hidden = rows.length > 0;
$('#grading-rows').innerHTML = rows
.map(
(r) => `<div class="item-row">
<span>${esc(r.emoji)}</span>
<strong class="grow">${esc(r.studentName)}</strong>
<span>${esc(r.itemTitle)}</span>
<span class="hint">${esc(r.courseName)} · ${esc(r.unitName)}</span>
<input type="number" class="grade-input" min="0" max="${r.points_possible}" placeholder="/ ${r.points_possible}" data-sub="${r.submissionId}">
<button data-save-grade="${r.submissionId}">Save</button>
</div>`
)
.join('');
document.querySelectorAll('[data-save-grade]').forEach((btn) =>
btn.addEventListener('click', async () => {
const input = document.querySelector(`.grade-input[data-sub="${btn.dataset.saveGrade}"]`);
if (input.value === '') return;
await api(`/api/submissions/${btn.dataset.saveGrade}/grade`, { method: 'PUT', body: { score: Number(input.value) } });
loadGrading();
})
);
}
// ============================================================
// Gradebook
// ============================================================
async function loadGradebookPanel() {
const courses = await api('/api/admin/courses');
$('#gradebook-course').innerHTML = courses.map((c) => `<option value="${c.id}">${esc(c.name)}</option>`).join('');
if (courses.length) renderGradebook(courses[0].id);
}
$('#gradebook-course').addEventListener('change', () => renderGradebook($('#gradebook-course').value));
async function renderGradebook(courseId) {
if (!courseId) return ($('#gradebook-table').innerHTML = '');
const gb = await api(`/api/gradebook/${courseId}`);
if (gb.gradableItems.length === 0 || gb.students.length === 0) {
$('#gradebook-table').innerHTML = `<p class="hint">Need at least one enrolled kid and one graded item (assignment, quiz, or spelling test) to show a gradebook.</p>`;
return;
}
const header = gb.gradableItems.map((it) => `<th>${esc(it.title)}<br><small>${it.points} pts</small></th>`).join('');
const rows = gb.students
.map((s) => {
const cells = gb.gradableItems
.map((it) => {
const sc = s.scores[it.id];
if (!sc) return `<td class="hint">—</td>`;
if (sc.status !== 'graded') return `<td class="hint">⏳</td>`;
return `<td>${sc.score}/${sc.points_possible}</td>`;
})
.join('');
const pctClass = s.percent === null ? '' : s.percent >= 80 ? 'score-good' : 'score-bad';
return `<tr><td>${esc(s.emoji)} ${esc(s.name)}</td>${cells}<td class="${pctClass}">${s.percent === null ? '—' : s.percent + '%'}</td></tr>`;
})
.join('');
$('#gradebook-table').innerHTML = `<table class="results"><tr><th>Kid</th>${header}<th>Overall</th></tr>${rows}</table>`;
}
// ============================================================
// Spelling (word lists + weekly assignment + results)
// ============================================================
async function loadSpelling() {
const [overview, lists] = await Promise.all([api('/api/overview'), api('/api/lists')]);
cachedLists = lists;
renderAssignRows(overview.students);
renderLists(lists);
renderResults(overview.students);
}
function renderAssignRows(students) {
const options = (sel) =>
`<option value="">— no list —</option>` +
cachedLists.map((l) => `<option value="${l.id}" ${sel === l.id ? 'selected' : ''}>${esc(l.name)}</option>`).join('');
$('#assign-rows').innerHTML = students.length
? students
.map(
(s) => `<div class="item-row">
<span>${esc(s.emoji)}</span>
<strong class="grow">${esc(s.name)}</strong>
<span>🌟 ${s.mastered} mastered</span>
<label>This week: <select data-assign="${s.id}">${options(s.assignment?.id)}</select></label>
</div>`
)
.join('')
: `<p class="hint">No kids yet.</p>`;
document.querySelectorAll('[data-assign]').forEach((sel) =>
sel.addEventListener('change', async () => {
if (!sel.value) return;
await api('/api/assign', { method: 'POST', body: { studentId: Number(sel.dataset.assign), listId: Number(sel.value) } });
msg('List assigned.');
loadSpelling();
})
);
}
function renderLists(lists) {
$('#list-rows').innerHTML = lists
.map(
(l) => `<div class="item-row">
<strong class="grow">${esc(l.name)}</strong>
<span>${l.wordCount} words${l.builtin ? ' · built-in' : ''}</span>
<button data-edit-list="${l.id}">${l.builtin ? 'Copy & edit' : 'Edit'}</button>
${l.builtin ? '' : `<button class="danger" data-del-list="${l.id}">Delete</button>`}
</div>`
)
.join('');
document.querySelectorAll('[data-edit-list]').forEach((btn) =>
btn.addEventListener('click', async () => {
const list = await api(`/api/lists/${btn.dataset.editList}`);
$('#list-id').value = list.builtin ? '' : list.id;
$('#list-name').value = list.builtin ? `${list.name} (copy)` : list.name;
$('#list-words').value = list.words.map((w) => (w.sentence ? `${w.word} | ${w.sentence}` : w.word)).join('\n');
$('#list-editor-title').textContent = list.builtin ? 'New list (from copy)' : `Editing: ${list.name}`;
$('#list-editor-details').open = true;
$('#list-name').scrollIntoView({ behavior: 'smooth' });
})
);
document.querySelectorAll('[data-del-list]').forEach((btn) =>
btn.addEventListener('click', async () => {
if (!confirm('Delete this list?')) return;
await api(`/api/lists/${btn.dataset.delList}`, { method: 'DELETE' });
loadSpelling();
})
);
}
$('#list-form').addEventListener('submit', async (e) => {
e.preventDefault();
const name = $('#list-name').value.trim();
const words = $('#list-words').value
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const [word, sentence = ''] = line.split('|').map((p) => p.trim());
return { word, sentence };
});
if (!name || words.length === 0) return msg('Give the list a name and at least one word.');
const id = $('#list-id').value;
if (id) await api(`/api/lists/${id}`, { method: 'PUT', body: { name, words } });
else await api('/api/lists', { method: 'POST', body: { name, words } });
clearListForm();
msg('List saved.');
loadSpelling();
});
$('#list-cancel').addEventListener('click', clearListForm);
function clearListForm() {
$('#list-id').value = '';
$('#list-name').value = '';
$('#list-words').value = '';
$('#list-editor-title').textContent = 'New list';
}
function renderResults(students) {
$('#results-area').innerHTML = students
.map((s) => {
if (s.tests.length === 0) return `<div class="results-student"><h3>${esc(s.emoji)} ${esc(s.name)}</h3><p class="hint">No tests yet.</p></div>`;
const rows = s.tests
.map((t) => {
const pct = Math.round((t.score / t.total) * 100);
return `<tr>
<td>${new Date(t.at + 'Z').toLocaleDateString()}</td>
<td>${esc(t.list)}</td>
<td class="${pct >= 80 ? 'score-good' : 'score-bad'}">${t.score}/${t.total} (${pct}%)</td>
<td><button data-print="${t.id}">🖨 Print</button></td>
</tr>`;
})
.join('');
return `<div class="results-student"><h3>${esc(s.emoji)} ${esc(s.name)}</h3>
<table class="results"><tr><th>Date</th><th>List</th><th>Score</th><th></th></tr>${rows}</table></div>`;
})
.join('');
document.querySelectorAll('[data-print]').forEach((btn) => btn.addEventListener('click', () => printSpellingReport(btn.dataset.print)));
}
async function printSpellingReport(testId) {
const r = await api(`/api/test-report/${testId}`);
const rows = r.answers
.map((a) => `<tr><td>${esc(a.word)}</td><td>${a.correct ? '✓ correct' : `✗ wrote "${esc(a.typed)}"`}</td></tr>`)
.join('');
const win = window.open('', '_blank');
win.document.write(`<!DOCTYPE html><html><head><title>Spelling Test Report</title>
<style>
body { font-family: Georgia, serif; max-width: 640px; margin: 2rem auto; color: #222; }
h1 { font-size: 1.4rem; border-bottom: 2px solid #222; padding-bottom: 0.4rem; }
table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
td, th { border: 1px solid #999; padding: 0.4rem 0.6rem; text-align: left; }
.meta { margin: 0.75rem 0; line-height: 1.6; }
</style></head><body>
<h1>Spelling Test Report</h1>
<div class="meta">
<strong>Student:</strong> ${esc(r.student)}<br>
<strong>Word list:</strong> ${esc(r.list)}<br>
<strong>Date:</strong> ${new Date(r.at + 'Z').toLocaleDateString()}<br>
<strong>Score:</strong> ${r.score} / ${r.total} (${Math.round((r.score / r.total) * 100)}%)
</div>
<table><tr><th>Word</th><th>Result</th></tr>${rows}</table>
<script>window.print()<\/script></body></html>`);
win.document.close();
}
// ============================================================
// Flashcard decks
// ============================================================
async function loadDecks() {
const decks = await api('/api/decks');
cachedDecks = decks;
$('#deck-rows').innerHTML = decks
.map(
(d) => `<div class="item-row">
<strong class="grow">${esc(d.name)}</strong>
<span>${d.cardCount} cards${d.builtin ? ' · built-in' : ''}</span>
<button data-edit-deck="${d.id}">${d.builtin ? 'Copy & edit' : 'Edit'}</button>
${d.builtin ? '' : `<button class="danger" data-del-deck="${d.id}">Delete</button>`}
</div>`
)
.join('');
document.querySelectorAll('[data-edit-deck]').forEach((btn) =>
btn.addEventListener('click', async () => {
const deck = await api(`/api/decks/${btn.dataset.editDeck}`);
$('#deck-id').value = deck.builtin ? '' : deck.id;
$('#deck-name').value = deck.builtin ? `${deck.name} (copy)` : deck.name;
$('#deck-cards').value = deck.cards.map((c) => `${c.front} | ${c.back}`).join('\n');
$('#deck-editor-title').textContent = deck.builtin ? 'New deck (from copy)' : `Editing: ${deck.name}`;
$('#deck-editor-details').open = true;
$('#deck-name').scrollIntoView({ behavior: 'smooth' });
})
);
document.querySelectorAll('[data-del-deck]').forEach((btn) =>
btn.addEventListener('click', async () => {
if (!confirm('Delete this deck?')) return;
await api(`/api/decks/${btn.dataset.delDeck}`, { method: 'DELETE' });
loadDecks();
})
);
}
$('#deck-form').addEventListener('submit', async (e) => {
e.preventDefault();
const name = $('#deck-name').value.trim();
const cards = $('#deck-cards').value
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const [front, back = ''] = line.split('|').map((p) => p.trim());
return { front, back };
});
if (!name || cards.length === 0) return msg('Give the deck a name and at least one card.');
const id = $('#deck-id').value;
if (id) await api(`/api/decks/${id}`, { method: 'PUT', body: { name, cards } });
else await api('/api/decks', { method: 'POST', body: { name, cards } });
clearDeckForm();
msg('Deck saved.');
loadDecks();
});
$('#deck-cancel').addEventListener('click', clearDeckForm);
function clearDeckForm() {
$('#deck-id').value = '';
$('#deck-name').value = '';
$('#deck-cards').value = '';
$('#deck-editor-title').textContent = 'New deck';
}
// ============================================================
// Settings
// ============================================================
$('#pin-change-form').addEventListener('submit', async (e) => {
e.preventDefault();
const newPin = $('#new-pin').value;
try {
await api('/api/parent/pin', { method: 'POST', body: { newPin } });
parentPin = newPin;
sessionStorage.setItem('pin', newPin);
$('#new-pin').value = '';
msg('PIN changed.');
} catch (err) {
msg(err.message);
}
});
// ---------- Scan config (settings panel) ----------
async function loadOllamaConfig() {
const cfg = await api('/api/admin/scan-config');
document.querySelector(`input[name="scan_primary"][value="${cfg.primary || 'local'}"]`).checked = true;
$('#ollama-host').value = cfg.ollama_host || '';
$('#ollama-model').value = cfg.ollama_model || '';
$('#ollama-test-result').textContent = '';
$('#ollama-cloud-model').value = cfg.ollama_cloud_model || '';
$('#ollama-cloud-api-key').value = '';
$('#ollama-cloud-key-status').textContent = cfg.ollama_cloud_has_key ? '🔑 Key saved' : 'No key saved';
}
$('#scan-config-form').addEventListener('submit', async (e) => {
e.preventDefault();
const primary = document.querySelector('input[name="scan_primary"]:checked')?.value || 'local';
const body = {
primary,
ollama_host: $('#ollama-host').value.trim(),
ollama_model: $('#ollama-model').value.trim(),
ollama_cloud_model: $('#ollama-cloud-model').value.trim(),
};
const ollamaCloudKey = $('#ollama-cloud-api-key').value.trim();
if (ollamaCloudKey) body.ollama_cloud_api_key = ollamaCloudKey;
await api('/api/admin/scan-config', { method: 'POST', body });
if (ollamaCloudKey) { $('#ollama-cloud-api-key').value = ''; $('#ollama-cloud-key-status').textContent = '🔑 Key saved'; }
msg('Scan settings saved.');
});
$('#ollama-test-btn').addEventListener('click', async () => {
$('#ollama-test-result').textContent = 'Testing…';
const r = await api('/api/admin/ollama-test', { method: 'POST' });
if (r.reachable && r.hasModel) {
$('#ollama-test-result').textContent = `✅ Connected — "${esc($('#ollama-model').value)}" is available.`;
} else if (r.reachable) {
$('#ollama-test-result').textContent =
`⚠️ Connected but "${esc($('#ollama-model').value)}" isn't pulled. Available: ${esc(r.availableModels.join(', ') || '(none)')}. Run: ollama pull ${esc($('#ollama-model').value)}`;
} else {
$('#ollama-test-result').textContent = `❌ ${esc(r.error)}`;
}
});
+640
View File
@@ -0,0 +1,640 @@
// ---------- tiny helpers ----------
const $ = (sel) => document.querySelector(sel);
async function api(path, opts = {}) {
const headers = { 'Content-Type': 'application/json' };
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 show(name) {
document.querySelectorAll('.view').forEach((v) => (v.hidden = true));
$(`#view-${name}`).hidden = false;
}
const esc = (s) =>
String(s).replace(/[&<>"']/g, (c) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])
);
const todayStr = () => new Date().toISOString().slice(0, 10);
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',
};
document.querySelectorAll('[data-nav]').forEach((btn) =>
btn.addEventListener('click', () => {
speechSynthesis.cancel();
if (btn.dataset.nav === 'home') {
loadHome();
show('home');
}
})
);
// Sub-views set this before opening so their back/stop button knows where to return
let backTarget = () => openKid(currentStudent.id);
document.querySelectorAll('[data-back]').forEach((btn) =>
btn.addEventListener('click', () => {
speechSynthesis.cancel();
backTarget();
})
);
// ---------- text to speech (spelling module) ----------
function speak(text, rate = 0.85) {
const u = new SpeechSynthesisUtterance(text);
u.rate = rate;
u.lang = 'en-US';
speechSynthesis.speak(u);
}
function speakWord(w) {
speechSynthesis.cancel();
speak(w.word);
if (w.sentence) speak(w.sentence, 0.95);
}
// ---------- letter diff (LCS alignment, spelling module) ----------
function diffLetters(typed, correct) {
const a = typed.toLowerCase(), b = correct.toLowerCase();
const m = a.length, n = b.length;
const L = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
for (let i = m - 1; i >= 0; i--)
for (let j = n - 1; j >= 0; j--)
L[i][j] = a[i] === b[j] ? L[i + 1][j + 1] + 1 : Math.max(L[i + 1][j], L[i][j + 1]);
const typedParts = [], correctParts = [];
let i = 0, j = 0;
while (i < m && j < n) {
if (a[i] === b[j]) {
typedParts.push(`<span class="ltr-ok">${esc(typed[i])}</span>`);
correctParts.push(`<span class="ltr-ok">${esc(correct[j])}</span>`);
i++; j++;
} else if (L[i + 1][j] >= L[i][j + 1]) {
typedParts.push(`<span class="ltr-bad">${esc(typed[i])}</span>`);
i++;
} else {
correctParts.push(`<span class="ltr-fix">${esc(correct[j])}</span>`);
j++;
}
}
while (i < m) typedParts.push(`<span class="ltr-bad">${esc(typed[i++])}</span>`);
while (j < n) correctParts.push(`<span class="ltr-fix">${esc(correct[j++])}</span>`);
return { typedHtml: typedParts.join(''), correctHtml: correctParts.join('') };
}
// ---------- home (kid picker) ----------
let currentStudent = null;
async function loadHome() {
const students = await api('/api/students');
$('#no-kids').hidden = students.length > 0;
$('#kid-cards').innerHTML = students
.map(
(s) => `<button class="kid-card" data-id="${s.id}">
<span class="avatar">${esc(s.emoji)}</span>${esc(s.name)}</button>`
)
.join('');
document.querySelectorAll('.kid-card').forEach((card) =>
card.addEventListener('click', () => openKid(Number(card.dataset.id)))
);
}
// ---------- kid home: Today / Courses / Spelling ----------
document.querySelectorAll('.nav-pill').forEach((pill) =>
pill.addEventListener('click', () => switchTab(pill.dataset.tab))
);
function switchTab(name) {
document.querySelectorAll('.nav-pill').forEach((p) => p.classList.toggle('active', p.dataset.tab === name));
document.querySelectorAll('.tab-panel').forEach((panel) => (panel.hidden = panel.id !== `tab-${name}`));
}
async function openKid(id, tab = 'today') {
const [state, agenda, courses] = await Promise.all([
api(`/api/state/${id}`),
api(`/api/schedule/${id}?date=${todayStr()}`),
api(`/api/courses/mine/${id}`),
]);
currentStudent = state.student;
$('#kid-greeting').textContent = `${state.student.emoji} Hi, ${state.student.name}!`;
renderToday(agenda.tasks);
renderCourseCards(courses);
renderSpellingTab(state);
show('kid');
switchTab(tab);
}
function renderToday(tasks) {
$('#today-empty').hidden = tasks.length > 0;
$('#today-list').innerHTML = tasks
.map((t) => {
if (!t.itemId) {
return `<button class="today-row offline" data-schedule-id="${t.id}">
<span class="row-check">${t.done ? '✅' : '⬜'}</span>
<span class="row-title">${esc(t.offlineTitle)}</span>
<span class="row-badge">📌 Offline</span>
</button>`;
}
const badge = statusBadge(t.type, t.subStatus, t.score, t.points_possible, t.done);
return `<button class="today-row" data-item-id="${t.itemId}" data-item-type="${t.type}">
<span class="row-check">${t.done ? '✅' : TYPE_ICON[t.type]}</span>
<span class="row-title">${esc(t.itemTitle)}<small>${esc(t.courseName)}</small></span>
<span class="row-badge">${badge}</span>
</button>`;
})
.join('');
document.querySelectorAll('.today-row.offline').forEach((row) =>
row.addEventListener('click', async () => {
const done = row.querySelector('.row-check').textContent.trim() !== '✅';
await api(`/api/schedule/${row.dataset.scheduleId}/done`, { method: 'POST', body: { done } });
openKid(currentStudent.id, 'today');
})
);
document.querySelectorAll('.today-row:not(.offline)').forEach((row) =>
row.addEventListener('click', () => {
backTarget = () => openKid(currentStudent.id, 'today');
openItem(Number(row.dataset.itemId), row.dataset.itemType);
})
);
}
function statusBadge(type, status, score, pointsPossible, done) {
if (status === 'graded') return `🌟 ${score}/${pointsPossible}`;
if (status === 'done') return type === 'assignment' ? '⏳ grading' : '✅ done';
if (done) return '✅ done';
return '';
}
function renderCourseCards(courses) {
$('#courses-empty').hidden = courses.length > 0;
$('#course-cards').innerHTML = courses
.map((c) => {
const pct = c.total > 0 ? Math.round((c.done / c.total) * 100) : 0;
return `<button class="course-card" style="border-left-color:${esc(c.color)}" data-course-id="${c.id}">
<strong>${esc(c.name)}</strong>
<span class="course-subject">${esc(c.subject || '')}</span>
<div class="progress-track small"><div class="progress-fill" style="width:${pct}%;background:${esc(c.color)}"></div></div>
<span class="course-pct">${c.done} / ${c.total} complete</span>
</button>`;
})
.join('');
document.querySelectorAll('.course-card').forEach((card) =>
card.addEventListener('click', () => openCourse(Number(card.dataset.courseId)))
);
}
function renderSpellingTab(state) {
let html;
if (state.assignment) {
const { mastered, total } = state.listProgress;
html = `<strong>This week: ${esc(state.assignment.name)}</strong>
<div class="mastery-dots">${'🌟'.repeat(mastered)}${'⚪'.repeat(Math.max(0, total - mastered))}</div>
<div>${mastered} of ${total} words mastered</div>`;
} else {
html = `No list assigned yet — ask a parent to pick one!`;
}
if (state.dueReviews > 0) {
html += `<div>🔁 ${state.dueReviews} old ${state.dueReviews === 1 ? 'word' : 'words'} due for review</div>`;
}
$('#kid-week').innerHTML = html;
$('#btn-test').disabled = !state.assignment;
}
// ---------- course detail ----------
async function openCourse(courseId) {
const course = await api(`/api/courses/${courseId}/detail?studentId=${currentStudent.id}`);
$('#course-title').textContent = course.name;
$('#course-units').innerHTML = course.units
.map(
(u) => `<div class="unit-block">
<h2>${esc(u.name)}</h2>
${u.items
.map((it) => {
const badge = statusBadge(it.type, it.status, it.score, it.points_possible, it.status !== 'not_started');
return `<button class="today-row" data-item-id="${it.id}" data-item-type="${it.type}">
<span class="row-check">${it.status === 'not_started' ? TYPE_ICON[it.type] : '✅'}</span>
<span class="row-title">${esc(it.title)}<small>${TYPE_LABEL[it.type]}</small></span>
<span class="row-badge">${badge}</span>
</button>`;
})
.join('')}
</div>`
)
.join('');
document.querySelectorAll('#course-units .today-row').forEach((row) =>
row.addEventListener('click', () => {
backTarget = () => openCourse(courseId);
openItem(Number(row.dataset.itemId), row.dataset.itemType);
})
);
show('course');
}
// ---------- item dispatch ----------
async function openItem(itemId, type) {
if (type === 'lesson') return openLesson(itemId);
if (type === 'assignment') return openAssignment(itemId);
if (type === 'quiz') return openQuiz(itemId);
const item = await api(`/api/items/${itemId}?studentId=${currentStudent.id}`);
if (type === 'spelling_practice') return startPractice({ listId: item.list.id, itemId });
if (type === 'spelling_test') return startTest({ listId: item.list.id, itemId });
if (type === 'flashcards') return startFlashcards({ deckId: item.deck.id, deckName: item.deck.name, itemId });
}
// ---------- lesson ----------
async function openLesson(itemId) {
const item = await api(`/api/items/${itemId}?studentId=${currentStudent.id}`);
$('#lesson-kicker').textContent = `${item.course_name} · ${item.unit_name}`;
$('#lesson-title').textContent = item.title;
$('#lesson-body').innerHTML = item.body;
show('lesson');
const btn = $('#lesson-done-btn');
btn.textContent = item.submission ? '✅ Read' : 'Done reading ✓';
btn.onclick = async () => {
await api(`/api/items/${itemId}/complete`, { method: 'POST', body: { studentId: currentStudent.id, date: todayStr() } });
backTarget();
};
}
// ---------- assignment ----------
async function openAssignment(itemId) {
const item = await api(`/api/items/${itemId}?studentId=${currentStudent.id}`);
$('#assignment-kicker').textContent = `${item.course_name} · ${item.unit_name}`;
$('#assignment-title').textContent = item.title;
$('#assignment-points').textContent = item.points ? `Worth ${item.points} points` : '';
$('#assignment-body').innerHTML = esc(item.body).replace(/\n/g, '<br>');
const btn = $('#assignment-done-btn');
const status = $('#assignment-status');
if (item.submission && item.submission.status === 'graded') {
btn.hidden = true;
status.hidden = false;
status.className = 'status-banner good';
status.textContent = `🌟 Graded: ${item.submission.score} / ${item.submission.points_possible}`;
} else if (item.submission && item.submission.status === 'done') {
btn.hidden = true;
status.hidden = false;
status.className = 'status-banner';
status.textContent = `⏳ Turned in — waiting for a parent to grade it.`;
} else {
btn.hidden = false;
status.hidden = true;
btn.onclick = async () => {
await api(`/api/items/${itemId}/complete`, { method: 'POST', body: { studentId: currentStudent.id, date: todayStr() } });
openAssignment(itemId);
};
}
show('assignment');
}
// ---------- quiz ----------
async function openQuiz(itemId) {
const item = await api(`/api/items/${itemId}?studentId=${currentStudent.id}`);
$('#quiz-kicker').textContent = `${item.course_name} · ${item.unit_name}`;
$('#quiz-title').textContent = item.title;
const graded = item.submission && item.submission.status === 'graded';
const result = $('#quiz-result');
if (graded) {
result.hidden = false;
result.className = 'status-banner good';
result.textContent = `🌟 Score: ${item.submission.score} / ${item.submission.points_possible}`;
$('#quiz-form').innerHTML = item.questions
.map((q) => {
const correct = normalizeAnswer(q.given) === normalizeAnswer(q.correct_answer);
return `<div class="quiz-question review">
<p class="q-prompt">${correct ? '✅' : '❌'} ${esc(q.prompt)}</p>
<p class="q-your-answer">Your answer: ${esc(q.given || '(blank)')}</p>
${correct ? '' : `<p class="q-correct-answer">Correct answer: ${esc(q.correct_answer)}</p>`}
</div>`;
})
.join('');
} else {
result.hidden = true;
$('#quiz-form').innerHTML =
item.questions
.map((q, i) => {
if (q.type === 'mc') {
return `<div class="quiz-question">
<p class="q-prompt">${i + 1}. ${esc(q.prompt)}</p>
${q.choices
.map(
(c) => `<label class="quiz-choice"><input type="radio" name="q${q.id}" value="${esc(c)}"> ${esc(c)}</label>`
)
.join('')}
</div>`;
}
if (q.type === 'tf') {
return `<div class="quiz-question">
<p class="q-prompt">${i + 1}. ${esc(q.prompt)}</p>
<label class="quiz-choice"><input type="radio" name="q${q.id}" value="true"> True</label>
<label class="quiz-choice"><input type="radio" name="q${q.id}" value="false"> False</label>
</div>`;
}
return `<div class="quiz-question">
<p class="q-prompt">${i + 1}. ${esc(q.prompt)}</p>
<input type="text" class="quiz-short-input" name="q${q.id}" autocomplete="off">
</div>`;
})
.join('') + `<button type="submit" class="check-btn">Submit Quiz</button>`;
$('#quiz-form').onsubmit = async (e) => {
e.preventDefault();
const submitBtn = $('#quiz-form button[type=submit]');
if (submitBtn.disabled) return;
submitBtn.disabled = true;
const answers = {};
for (const q of item.questions) {
const field = $('#quiz-form').elements[`q${q.id}`];
answers[q.id] = field ? (field.value !== undefined ? field.value : '') : '';
}
await api(`/api/items/${itemId}/quiz-submit`, {
method: 'POST',
body: { studentId: currentStudent.id, answers, date: todayStr() },
});
openQuiz(itemId);
};
}
show('quiz');
}
const normalizeAnswer = (s) => String(s ?? '').trim().toLowerCase();
// ---------- flashcards ----------
const flash = { cards: [], i: 0, itemId: null, reviewed: 0 };
async function startFlashcards({ deckId, deckName, itemId }) {
const { cards } = await api(`/api/flashcards/session/${currentStudent.id}?deckId=${deckId}`);
Object.assign(flash, { cards, i: 0, itemId: itemId || null, reviewed: 0 });
$('#flash-deck-name').textContent = deckName || '';
$('#flash-card').hidden = false;
$('#flash-done').hidden = true;
show('flashcards');
if (cards.length === 0) return finishFlashcards(true);
presentFlashcard();
}
function presentFlashcard() {
flashGrading = false;
const c = flash.cards[flash.i];
$('#flash-progress').textContent = `Card ${flash.i + 1} of ${flash.cards.length}`;
$('#flash-bar').style.width = `${(flash.i / flash.cards.length) * 100}%`;
$('#flash-front').textContent = c.front;
$('#flash-front').hidden = false;
$('#flash-back').hidden = true;
$('#flash-back').textContent = c.back;
$('#flash-reveal').hidden = false;
$('#flash-grade-buttons').hidden = true;
}
$('#flash-reveal').addEventListener('click', () => {
$('#flash-back').hidden = false;
$('#flash-reveal').hidden = true;
$('#flash-grade-buttons').hidden = false;
});
let flashGrading = false;
async function gradeFlashcard(gotIt) {
if (flashGrading) return; // guard against a rapid double-tap misgrading two cards
flashGrading = true;
const c = flash.cards[flash.i];
await api('/api/flashcards/grade', { method: 'POST', body: { studentId: currentStudent.id, cardId: c.id, gotIt } });
flash.reviewed++;
flash.i++;
if (flash.i < flash.cards.length) return presentFlashcard();
finishFlashcards(false);
}
$('#flash-again').addEventListener('click', () => gradeFlashcard(false));
$('#flash-got-it').addEventListener('click', () => gradeFlashcard(true));
async function finishFlashcards(nothingDue) {
$('#flash-bar').style.width = '100%';
$('#flash-progress').textContent = 'Done!';
$('#flash-card').hidden = true;
if (flash.itemId) {
await api(`/api/items/${flash.itemId}/complete`, { method: 'POST', body: { studentId: currentStudent.id, date: todayStr() } });
}
$('#flash-done').innerHTML = nothingDue
? `<div>🎉 Nothing due on this deck right now!</div><button id="flash-home">Back</button>`
: `<div>🎉 Reviewed ${flash.reviewed} card${flash.reviewed === 1 ? '' : 's'}!</div><button id="flash-home">Back</button>`;
$('#flash-done').hidden = false;
$('#flash-home').addEventListener('click', () => backTarget());
}
// ---------- spelling: practice ----------
const practice = { words: [], i: 0, missed: [], firstTryCorrect: 0, awaitingRetype: false, itemId: null };
async function startPractice({ listId, itemId } = {}) {
const url = listId ? `/api/session/${currentStudent.id}?listId=${listId}` : `/api/session/${currentStudent.id}`;
const { words } = await api(url);
if (words.length === 0) {
$('#practice-card').hidden = true;
$('#practice-done').hidden = false;
$('#practice-done').innerHTML = `<div>🎉 Nothing to practice right now — all caught up!</div><button id="practice-home">Back</button>`;
$('#practice-home').addEventListener('click', () => backTarget());
show('practice');
return;
}
Object.assign(practice, { words, i: 0, missed: [], firstTryCorrect: 0, awaitingRetype: false, itemId: itemId || null });
$('#practice-card').hidden = false;
$('#practice-done').hidden = true;
show('practice');
presentPracticeWord();
}
function presentPracticeWord() {
const w = practice.words[practice.i];
practice.awaitingRetype = false;
$('#practice-progress').textContent = `Word ${practice.i + 1} of ${practice.words.length}`;
$('#practice-bar').style.width = `${(practice.i / practice.words.length) * 100}%`;
$('#practice-prompt').textContent = 'Listen, then type the word:';
$('#feedback').innerHTML = '';
const input = $('#practice-input');
input.value = '';
input.disabled = false;
input.focus();
speakWord(w);
}
$('#btn-speak').addEventListener('click', () => speakWord(practice.words[practice.i]));
$('#practice-form').addEventListener('submit', async (e) => {
e.preventDefault();
const input = $('#practice-input');
const typed = input.value.trim();
if (!typed || input.disabled) return;
const w = practice.words[practice.i];
const result = await api('/api/attempt', {
method: 'POST',
body: { studentId: currentStudent.id, wordId: w.id, typed, firstTry: !practice.awaitingRetype },
});
if (result.correct) {
if (!practice.awaitingRetype) practice.firstTryCorrect++;
$('#feedback').innerHTML = `<div class="banner good">✅ ${practice.awaitingRetype ? 'You got it!' : 'Correct!'}</div>`;
input.disabled = true;
setTimeout(nextPracticeWord, 900);
} else {
if (!practice.awaitingRetype) practice.missed.push(w.word);
showStudyThenRetype(typed, w);
}
});
// look–cover–write–check: show the diff and the correct word, then hide it and retype
function showStudyThenRetype(typed, w) {
practice.awaitingRetype = true;
const input = $('#practice-input');
input.disabled = true;
const d = diffLetters(typed, w.word);
const fb = $('#feedback');
let secs = 4;
const render = () => {
fb.innerHTML = `
<div class="banner bad">Not quite — study it!</div>
<div class="diff-line"><span class="label">You typed</span>${d.typedHtml}</div>
<div class="diff-line"><span class="label">Correct</span>${d.correctHtml}</div>
<div class="study-word">${esc(w.word)}</div>
<div class="countdown">Memorize it… ${secs}</div>`;
};
render();
const timer = setInterval(() => {
secs--;
if (secs > 0) return render();
clearInterval(timer);
fb.innerHTML = `<div class="banner bad">🙈 Word hidden — now type it from memory!</div>`;
$('#practice-prompt').textContent = 'Type it from memory:';
input.value = '';
input.disabled = false;
input.focus();
}, 1000);
}
async function nextPracticeWord() {
practice.i++;
if (practice.i < practice.words.length) return presentPracticeWord();
$('#practice-bar').style.width = '100%';
$('#practice-progress').textContent = 'Done!';
$('#practice-card').hidden = true;
if (practice.itemId) {
await api(`/api/items/${practice.itemId}/complete`, { method: 'POST', body: { studentId: currentStudent.id, date: todayStr() } });
}
const missedHtml = practice.missed.length
? `<p>Words to keep working on:</p><ul>${practice.missed.map((m) => `<li>📌 ${esc(m)}</li>`).join('')}</ul>`
: `<p>Perfect round — every word right on the first try! 🏆</p>`;
$('#practice-done').innerHTML = `
<div>🎉 Practice complete!</div>
<div class="big-score">${practice.firstTryCorrect} / ${practice.words.length}</div>
<p>right on the first try</p>
${missedHtml}
<button id="practice-home">Back</button>`;
$('#practice-done').hidden = false;
$('#practice-home').addEventListener('click', () => backTarget());
}
// ---------- spelling: test ----------
const test = { list: null, words: [], i: 0, answers: [], itemId: null };
async function startTest({ listId, itemId } = {}) {
const url = listId ? `/api/test/${currentStudent.id}?listId=${listId}` : `/api/test/${currentStudent.id}`;
const data = await api(url);
Object.assign(test, { list: data.list, words: data.words, i: 0, answers: [], itemId: itemId || null });
$('#test-card').hidden = false;
$('#test-done').hidden = true;
show('test');
presentTestWord();
}
function presentTestWord() {
const w = test.words[test.i];
$('#test-progress').textContent = `Word ${test.i + 1} of ${test.words.length}`;
$('#test-bar').style.width = `${(test.i / test.words.length) * 100}%`;
const input = $('#test-input');
input.value = '';
input.focus();
speakWord(w);
}
$('#btn-test-speak').addEventListener('click', () => speakWord(test.words[test.i]));
$('#test-form').addEventListener('submit', async (e) => {
e.preventDefault();
const typed = $('#test-input').value.trim();
if (!typed) return;
test.answers.push({ wordId: test.words[test.i].id, typed });
test.i++;
if (test.i < test.words.length) return presentTestWord();
await finishTest();
});
async function finishTest() {
speechSynthesis.cancel();
const result = await api(`/api/test/${currentStudent.id}`, {
method: 'POST',
body: { listId: test.list.id, answers: test.answers, itemId: test.itemId, date: todayStr() },
});
$('#test-bar').style.width = '100%';
$('#test-progress').textContent = 'Test finished!';
$('#test-card').hidden = true;
const rows = result.graded
.map((g) =>
g.correct
? `<li>✅ ${esc(g.word)}</li>`
: `<li>❌ ${esc(g.word)} <small>(you wrote "${esc(g.typed)}")</small></li>`
)
.join('');
$('#test-done').innerHTML = `
<div>⭐ Test complete!</div>
<div class="big-score">${result.score} / ${result.total}</div>
<ul>${rows}</ul>
<button id="test-home">Back</button>`;
$('#test-done').hidden = false;
$('#test-home').addEventListener('click', () => backTarget());
}
$('#btn-practice').addEventListener('click', () => {
backTarget = () => openKid(currentStudent.id, 'spelling');
startPractice();
});
$('#btn-test').addEventListener('click', () => {
backTarget = () => openKid(currentStudent.id, 'spelling');
startTest();
});
// ---------- boot ----------
loadHome();
+152
View File
@@ -0,0 +1,152 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Homeschool</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<!-- Kid picker -->
<section id="view-home" class="view">
<h1 class="app-title">🎓 Homeschool</h1>
<p class="subtitle">Who's learning today?</p>
<div id="kid-cards" class="kid-cards"></div>
<p id="no-kids" class="hint" hidden>No kids yet — a parent needs to add them.</p>
<a class="link-btn" href="admin.html">Parent dashboard</a>
</section>
<!-- Kid home: Today's work + nav -->
<section id="view-kid" class="view" hidden>
<button class="back-btn" data-nav="home">← Switch</button>
<h1 id="kid-greeting"></h1>
<div class="kid-nav">
<button class="nav-pill active" data-tab="today">📋 Today</button>
<button class="nav-pill" data-tab="courses">📚 My Courses</button>
<button class="nav-pill" data-tab="spelling">✏️ Spelling</button>
</div>
<div id="tab-today" class="tab-panel">
<div id="today-list" class="today-list"></div>
<p id="today-empty" class="hint" hidden>Nothing scheduled for today — check My Courses for anything else to work on!</p>
</div>
<div id="tab-courses" class="tab-panel" hidden>
<div id="course-cards" class="course-cards"></div>
<p id="courses-empty" class="hint" hidden>Not enrolled in any courses yet.</p>
</div>
<div id="tab-spelling" class="tab-panel" hidden>
<div id="kid-week" class="week-card"></div>
<div class="big-actions">
<button id="btn-practice" class="big-btn practice">✏️ Practice</button>
<button id="btn-test" class="big-btn test">⭐ Friday Test</button>
</div>
</div>
</section>
<!-- Course detail: units + items -->
<section id="view-course" class="view" hidden>
<button class="back-btn" data-back="course">← Back</button>
<h1 id="course-title"></h1>
<div id="course-units"></div>
</section>
<!-- Lesson viewer -->
<section id="view-lesson" class="view" hidden>
<button class="back-btn" data-back="lesson">← Back</button>
<div class="item-card">
<p class="item-kicker" id="lesson-kicker"></p>
<h1 id="lesson-title"></h1>
<div id="lesson-body" class="lesson-body"></div>
<button id="lesson-done-btn" class="check-btn">Done reading ✓</button>
</div>
</section>
<!-- Assignment viewer -->
<section id="view-assignment" class="view" hidden>
<button class="back-btn" data-back="assignment">← Back</button>
<div class="item-card">
<p class="item-kicker" id="assignment-kicker"></p>
<h1 id="assignment-title"></h1>
<p id="assignment-points" class="points-label"></p>
<div id="assignment-body" class="lesson-body"></div>
<button id="assignment-done-btn" class="check-btn">Mark as done ✓</button>
<div id="assignment-status" class="status-banner" hidden></div>
</div>
</section>
<!-- Quiz -->
<section id="view-quiz" class="view" hidden>
<button class="back-btn" data-back="quiz">← Back</button>
<div class="item-card">
<p class="item-kicker" id="quiz-kicker"></p>
<h1 id="quiz-title"></h1>
<form id="quiz-form"></form>
<div id="quiz-result" class="status-banner" hidden></div>
</div>
</section>
<!-- Flashcards -->
<section id="view-flashcards" class="view" hidden>
<button class="back-btn" data-back="flashcards">← Stop</button>
<p id="flash-progress" class="progress-label"></p>
<div class="progress-track"><div id="flash-bar" class="progress-fill"></div></div>
<div id="flash-card" class="word-card flip-card">
<p class="prompt" id="flash-deck-name"></p>
<div class="flash-face" id="flash-front"></div>
<div class="flash-face" id="flash-back" hidden></div>
<button id="flash-reveal" class="check-btn">Show answer</button>
<div id="flash-grade-buttons" class="flash-grade-row" hidden>
<button id="flash-again" class="grade-btn again">🔁 Study more</button>
<button id="flash-got-it" class="grade-btn got-it">✅ Got it!</button>
</div>
</div>
<div id="flash-done" class="done-card" hidden></div>
</section>
<!-- Practice (spelling) -->
<section id="view-practice" class="view" hidden>
<button class="back-btn" data-back="practice">← Stop</button>
<p id="practice-progress" class="progress-label"></p>
<div class="progress-track"><div id="practice-bar" class="progress-fill"></div></div>
<div id="practice-card" class="word-card">
<button id="btn-speak" class="speak-btn" title="Hear the word again">🔊 Say it again</button>
<p id="practice-prompt" class="prompt">Listen, then type the word:</p>
<div id="feedback" class="feedback"></div>
<form id="practice-form" autocomplete="off">
<input id="practice-input" class="spell-input" type="text" autocomplete="off"
autocapitalize="off" autocorrect="off" spellcheck="false" aria-label="Type the word">
<button type="submit" class="check-btn">Check ✓</button>
</form>
</div>
<div id="practice-done" class="done-card" hidden></div>
</section>
<!-- Friday test (spelling) -->
<section id="view-test" class="view" hidden>
<button class="back-btn" data-back="test">← Cancel</button>
<p id="test-progress" class="progress-label"></p>
<div class="progress-track"><div id="test-bar" class="progress-fill test-fill"></div></div>
<div id="test-card" class="word-card">
<button id="btn-test-speak" class="speak-btn">🔊 Say it again</button>
<p class="prompt">Spell the word. No hints — you've got this!</p>
<form id="test-form" autocomplete="off">
<input id="test-input" class="spell-input" type="text" autocomplete="off"
autocapitalize="off" autocorrect="off" spellcheck="false" aria-label="Spell the word">
<button type="submit" class="check-btn">Next →</button>
</form>
</div>
<div id="test-done" class="done-card" hidden></div>
</section>
<script src="app.js"></script>
</body>
</html>
+394
View File
@@ -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; }
+34
View File
@@ -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"}
]
}
]
+42
View File
@@ -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."}
]
}
]
+1087
View File
File diff suppressed because it is too large. Load diff