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:
commit
23b24b5d21
16 files changed
+4844
No files matched your search
@@ -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
@@ -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
@@ -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"]
|
||||||
@@ -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.
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
Generated
+865
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 & 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. <iframe src="https://www.youtube.com/embed/VIDEO_ID"></iframe>"></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: 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 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 & 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
@@ -0,0 +1,965 @@
|
|||||||
|
// ---------- tiny helpers ----------
|
||||||
|
|
||||||
|
const $ = (sel) => document.querySelector(sel);
|
||||||
|
const esc = (s) =>
|
||||||
|
String(s).replace(/[&<>"']/g, (c) =>
|
||||||
|
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])
|
||||||
|
);
|
||||||
|
|
||||||
|
let parentPin = sessionStorage.getItem('pin') || null;
|
||||||
|
|
||||||
|
async function api(path, opts = {}) {
|
||||||
|
const headers = { 'Content-Type': 'application/json' };
|
||||||
|
if (parentPin) headers['x-pin'] = parentPin;
|
||||||
|
const res = await fetch(path, {
|
||||||
|
method: opts.method || 'GET',
|
||||||
|
headers,
|
||||||
|
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Request failed');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function msg(text) {
|
||||||
|
$('#admin-msg').textContent = text;
|
||||||
|
setTimeout(() => ($('#admin-msg').textContent = ''), 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_ICON = {
|
||||||
|
lesson: '📖', assignment: '📝', quiz: '❓',
|
||||||
|
spelling_practice: '✏️', spelling_test: '⭐', flashcards: '🗂️',
|
||||||
|
};
|
||||||
|
const TYPE_LABEL = {
|
||||||
|
lesson: 'Lesson', assignment: 'Assignment', quiz: 'Quiz',
|
||||||
|
spelling_practice: 'Spelling Practice', spelling_test: 'Spelling Test', flashcards: 'Flashcards',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- PIN gate ----------
|
||||||
|
|
||||||
|
$('#pin-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const pin = $('#pin-input').value;
|
||||||
|
const { ok } = await api('/api/parent/verify', { method: 'POST', body: { pin } });
|
||||||
|
if (!ok) return ($('#pin-error').hidden = false);
|
||||||
|
parentPin = pin;
|
||||||
|
sessionStorage.setItem('pin', pin);
|
||||||
|
$('#pin-error').hidden = true;
|
||||||
|
unlock();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function unlock() {
|
||||||
|
$('#pin-gate').hidden = true;
|
||||||
|
$('#console').hidden = false;
|
||||||
|
showPanel('kids');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parentPin) unlock();
|
||||||
|
|
||||||
|
// ---------- navigation ----------
|
||||||
|
|
||||||
|
document.querySelectorAll('.admin-nav .nav-pill').forEach((pill) =>
|
||||||
|
pill.addEventListener('click', () => showPanel(pill.dataset.panel))
|
||||||
|
);
|
||||||
|
document.querySelectorAll('[data-nav]').forEach((btn) =>
|
||||||
|
btn.addEventListener('click', () => showPanel(btn.dataset.nav))
|
||||||
|
);
|
||||||
|
|
||||||
|
function showPanel(name) {
|
||||||
|
document.querySelectorAll('.panel').forEach((p) => (p.hidden = p.id !== `panel-${name}`));
|
||||||
|
document.querySelectorAll('.admin-nav .nav-pill').forEach((p) => p.classList.toggle('active', p.dataset.panel === name));
|
||||||
|
const loaders = {
|
||||||
|
kids: loadKids, courses: loadCourses, planner: loadPlannerPanel,
|
||||||
|
grading: loadGrading, gradebook: loadGradebookPanel, spelling: loadSpelling, decks: loadDecks,
|
||||||
|
settings: loadOllamaConfig,
|
||||||
|
};
|
||||||
|
if (loaders[name]) loaders[name]();
|
||||||
|
if (name === 'course-detail') openCourseDetail(currentCourseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Kids
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
async function loadKids() {
|
||||||
|
const students = await api('/api/students');
|
||||||
|
$('#student-rows').innerHTML = students.length
|
||||||
|
? students
|
||||||
|
.map(
|
||||||
|
(s) => `<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
@@ -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) =>
|
||||||
|
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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();
|
||||||
@@ -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>
|
||||||
@@ -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; }
|
||||||
@@ -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"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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."}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
Reference in new issue
Block a user