LAPractice: Karteikarten-Trainer für die LAP-Theoriefragen
Web-App zum Üben der Repetitionsfragen: Fragensets werden als JSON importiert, Fragen und Antwort-Statistik liegen in SQLite. Ablauf beim Üben ist selbstkontrolliert – Frage sehen, im Kopf oder dem Partner antworten, Karte aufdecken, RICHTIG/FALSCH bewerten. - Server ohne Abhängigkeiten (node:http, node:sqlite, Node 22.5+) - Import führt Sets mit gleichem Titel zusammen: geänderte Antworten werden aktualisiert, fehlende Fragen archiviert, die Statistik bleibt - Auswahl nach Kategorien/Unterkategorien, Filter auf falsch beantwortete oder noch nie gesehene Fragen - Reihenfolge frei kombinierbar: mischen und/oder Schwächen zuerst - Endlos-Modus wiederholt, bis die Runde beendet wird; falsche Karten kommen in derselben Runde erneut - Tastatursteuerung, Rundenauswertung und Statistik mit Kategorie-Quoten, schwächsten Fragen und 14-Tage-Verlauf Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>main
commit
e5c4842784
|
|
@ -0,0 +1,5 @@
|
|||
node_modules/
|
||||
data/*.db
|
||||
data/*.db-journal
|
||||
data/*.db-wal
|
||||
data/*.db-shm
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
# LAPractice
|
||||
|
||||
Karteikarten-Trainer für die LAP-Theoriefragen. Fragen kommen als JSON herein,
|
||||
Fragen und Antwort-Statistik liegen danach in einer SQLite-Datei.
|
||||
|
||||
Der Ablauf beim Üben ist bewusst selbstkontrolliert:
|
||||
**Frage sehen → im Kopf (oder dem Partner) beantworten → Karte aufdecken → RICHTIG / FALSCH.**
|
||||
|
||||
## Starten
|
||||
|
||||
```sh
|
||||
npm start # http://127.0.0.1:4173
|
||||
npm run dev # dasselbe, startet bei Dateiänderungen neu
|
||||
```
|
||||
|
||||
Keine Abhängigkeiten – der Server nutzt nur die Node-Standardbibliothek
|
||||
(`node:http`, `node:sqlite`). Voraussetzung ist **Node 22.5+**.
|
||||
|
||||
Konfiguration über Umgebungsvariablen: `PORT`, `HOST`, `LAP_DB` (Pfad zur
|
||||
Datenbank, Standard `data/lapractice.db`).
|
||||
|
||||
## Fragenset-Format
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"title": "Arbeitsunterlagen und Repetitionsfragen zur LAP …",
|
||||
"categories": [
|
||||
{
|
||||
"id": 1, // optional
|
||||
"title": "Netzwerktechnik",
|
||||
"subcategories": [
|
||||
{
|
||||
"name": "Grundlagen",
|
||||
"questions": [
|
||||
{ "q": "Erklären Sie den Begriff TP-Kabel.", "a": "Twisted-Pair-Kabel …" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Toleranzen beim Import: `name` statt `title`, `question`/`answer` statt `q`/`a`,
|
||||
und eine Kategorie darf ihre `questions` auch direkt (ohne `subcategories`)
|
||||
tragen. Fragen ohne Text oder ohne Antwort werden übersprungen.
|
||||
|
||||
**Erneuter Import:** Ein Set mit gleichem `title` wird zusammengeführt statt
|
||||
dupliziert – Fragen werden über Kategorie + Unterkategorie + Fragetext
|
||||
wiedererkannt, geänderte Antworten aktualisiert, neue ergänzt und die
|
||||
Statistik bleibt erhalten. Fragen, die in der neuen Datei fehlen, werden
|
||||
archiviert (nicht gelöscht) und tauchen beim Üben nicht mehr auf.
|
||||
|
||||
## Üben
|
||||
|
||||
* **Auswahl:** Kategorien und Unterkategorien einzeln an-/abwählbar, die
|
||||
Auswahl wird pro Fragenset im Browser gemerkt.
|
||||
* **Fragen** (Filter): alle · nur zuletzt falsch beantwortete · nur noch nie
|
||||
gesehene. Die Poolgröße im Startbalken zeigt, wie viele Karten übrig bleiben.
|
||||
* **Fragen mischen:** an = zufällige Reihenfolge, aus = Reihenfolge des Skriptums.
|
||||
* **Schwächen zuerst:** jede dritte Karte ist eine fällige Wiederholung, der Rest
|
||||
neues Material. Lässt sich mit dem Mischen frei kombinieren – aus/aus ergibt
|
||||
exakt die Skriptum-Reihenfolge.
|
||||
* **Endlos üben:** die Runde läuft, bis du sie selbst beendest (`Esc` oder
|
||||
„Beenden"). Ist ein Stapel durch, wird der nächste mit dem aktuellen
|
||||
Wissensstand geholt; falsch beantwortete Karten kommen zusätzlich noch in
|
||||
derselben Runde wieder. Die Anzahl gilt dann als Karten *pro Runde*.
|
||||
* **Tastatur:** `Leertaste` aufdecken · `1`/`←` falsch · `2`/`→` richtig ·
|
||||
`S` überspringen (zählt nicht in die Statistik) · `Esc` Runde beenden.
|
||||
* Nach der Runde: Zusammenfassung mit Trefferquote und allen Karten, dazu
|
||||
„Falsche wiederholen" für einen direkten zweiten Durchgang.
|
||||
|
||||
Als *fällig* gilt eine Frage, deren letzte Antwort falsch war, deren
|
||||
Trefferquote unter 70 % liegt oder die länger als 7 Tage nicht dran war.
|
||||
|
||||
## Datenmodell
|
||||
|
||||
```
|
||||
sets ─┬─ categories ─┬─ subcategories ─┬─ questions ─┬─ reviews
|
||||
│ │ │ │
|
||||
title title name q, a, archived correct, created_at
|
||||
```
|
||||
|
||||
`reviews` speichert jede einzelne Bewertung mit Zeitstempel; alle Kennzahlen
|
||||
(Trefferquote, schwächste Fragen, Tagesverlauf) werden daraus berechnet.
|
||||
Das Zurücksetzen des Fortschritts löscht nur `reviews`, nie die Fragen.
|
||||
|
||||
## API
|
||||
|
||||
| Methode | Pfad | Zweck |
|
||||
| -------- | --------------------------- | --------------------------------------- |
|
||||
| `GET` | `/api/sets` | Fragensets mit Fortschritt |
|
||||
| `POST` | `/api/sets` | Import (`{ data, filename }`) |
|
||||
| `GET` | `/api/sets/:id` | Kategoriebaum mit Zählern |
|
||||
| `DELETE` | `/api/sets/:id` | Set samt Statistik löschen |
|
||||
| `POST` | `/api/sets/:id/reset` | nur die Statistik löschen |
|
||||
| `GET` | `/api/sets/:id/stats` | Auswertung |
|
||||
| `POST` | `/api/sets/:id/session` | Runde zusammenstellen (`filter`, `shuffle`, `smart`, `limit`) |
|
||||
| `POST` | `/api/reviews` | Bewertung speichern |
|
||||
|
|
@ -0,0 +1,513 @@
|
|||
'use strict';
|
||||
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const path = require('node:path');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const DATA_DIR = path.join(__dirname, 'data');
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
|
||||
const db = new DatabaseSync(process.env.LAP_DB || path.join(DATA_DIR, 'lapractice.db'));
|
||||
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS sets (
|
||||
id INTEGER PRIMARY KEY,
|
||||
title TEXT NOT NULL UNIQUE,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS categories (
|
||||
id INTEGER PRIMARY KEY,
|
||||
set_id INTEGER NOT NULL REFERENCES sets(id) ON DELETE CASCADE,
|
||||
ext_id INTEGER,
|
||||
title TEXT NOT NULL,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
UNIQUE (set_id, title)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS subcategories (
|
||||
id INTEGER PRIMARY KEY,
|
||||
category_id INTEGER NOT NULL REFERENCES categories(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
UNIQUE (category_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS questions (
|
||||
id INTEGER PRIMARY KEY,
|
||||
subcategory_id INTEGER NOT NULL REFERENCES subcategories(id) ON DELETE CASCADE,
|
||||
q TEXT NOT NULL,
|
||||
a TEXT NOT NULL,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
archived INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE (subcategory_id, q)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS reviews (
|
||||
id INTEGER PRIMARY KEY,
|
||||
question_id INTEGER NOT NULL REFERENCES questions(id) ON DELETE CASCADE,
|
||||
correct INTEGER NOT NULL CHECK (correct IN (0, 1)),
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_reviews_question ON reviews(question_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_reviews_created ON reviews(created_at);
|
||||
`);
|
||||
|
||||
const now = () => new Date().toISOString();
|
||||
|
||||
function tx(fn) {
|
||||
db.exec('BEGIN');
|
||||
try {
|
||||
const result = fn();
|
||||
db.exec('COMMIT');
|
||||
return result;
|
||||
} catch (err) {
|
||||
db.exec('ROLLBACK');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- import -- */
|
||||
|
||||
/**
|
||||
* Validates the shape of an imported question set and returns a normalised
|
||||
* structure. Throws an Error with a human readable message on bad input.
|
||||
*/
|
||||
function normalizeSet(raw, fallbackTitle) {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
throw new Error('Die Datei enthaelt kein JSON-Objekt.');
|
||||
}
|
||||
const title = String(raw.title || fallbackTitle || '').trim();
|
||||
if (!title) throw new Error('Dem Fragenset fehlt ein "title".');
|
||||
if (!Array.isArray(raw.categories) || raw.categories.length === 0) {
|
||||
throw new Error('Dem Fragenset fehlt ein nicht-leeres "categories"-Array.');
|
||||
}
|
||||
|
||||
const categories = raw.categories.map((cat, ci) => {
|
||||
const catTitle = String(cat?.title ?? cat?.name ?? '').trim();
|
||||
if (!catTitle) throw new Error(`Kategorie #${ci + 1} hat keinen "title".`);
|
||||
|
||||
// A category may either carry subcategories or questions directly; a flat
|
||||
// category is modelled as a single subcategory with the same name.
|
||||
const subsRaw = Array.isArray(cat.subcategories)
|
||||
? cat.subcategories
|
||||
: [{ name: catTitle, questions: cat.questions }];
|
||||
|
||||
const subcategories = subsRaw.map((sub, si) => {
|
||||
const subName = String(sub?.name ?? sub?.title ?? '').trim() || catTitle;
|
||||
const qsRaw = Array.isArray(sub?.questions) ? sub.questions : [];
|
||||
const questions = [];
|
||||
const seen = new Set();
|
||||
for (const item of qsRaw) {
|
||||
const q = String(item?.q ?? item?.question ?? '').trim();
|
||||
const a = String(item?.a ?? item?.answer ?? '').trim();
|
||||
if (!q || !a) continue;
|
||||
if (seen.has(q)) continue; // duplicates within one subcategory
|
||||
seen.add(q);
|
||||
questions.push({ q, a });
|
||||
}
|
||||
return { name: subName, position: si, questions };
|
||||
}).filter((sub) => sub.questions.length > 0);
|
||||
|
||||
return {
|
||||
extId: Number.isFinite(cat.id) ? cat.id : ci + 1,
|
||||
title: catTitle,
|
||||
position: ci,
|
||||
subcategories,
|
||||
};
|
||||
}).filter((cat) => cat.subcategories.length > 0);
|
||||
|
||||
const total = categories.reduce(
|
||||
(sum, c) => sum + c.subcategories.reduce((s, sc) => s + sc.questions.length, 0), 0);
|
||||
if (total === 0) throw new Error('Im Fragenset wurde keine einzige Frage gefunden.');
|
||||
|
||||
return { title, categories, total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports (or re-imports) a set. Re-importing a set with the same title merges:
|
||||
* questions are matched by category/subcategory/question text so that existing
|
||||
* answer statistics survive. Questions missing from the new file are archived
|
||||
* instead of deleted.
|
||||
*/
|
||||
function importSet(raw, fallbackTitle) {
|
||||
const set = normalizeSet(raw, fallbackTitle);
|
||||
const ts = now();
|
||||
|
||||
return tx(() => {
|
||||
let row = db.prepare('SELECT id FROM sets WHERE title = ?').get(set.title);
|
||||
let setId;
|
||||
let isNew = false;
|
||||
if (row) {
|
||||
setId = row.id;
|
||||
db.prepare('UPDATE sets SET updated_at = ? WHERE id = ?').run(ts, setId);
|
||||
} else {
|
||||
isNew = true;
|
||||
setId = db.prepare('INSERT INTO sets (title, created_at, updated_at) VALUES (?, ?, ?)')
|
||||
.run(set.title, ts, ts).lastInsertRowid;
|
||||
}
|
||||
|
||||
const stats = { added: 0, updated: 0, unchanged: 0, archived: 0 };
|
||||
const keptIds = new Set();
|
||||
|
||||
for (const cat of set.categories) {
|
||||
let catRow = db.prepare('SELECT id FROM categories WHERE set_id = ? AND title = ?')
|
||||
.get(setId, cat.title);
|
||||
let catId;
|
||||
if (catRow) {
|
||||
catId = catRow.id;
|
||||
db.prepare('UPDATE categories SET ext_id = ?, position = ? WHERE id = ?')
|
||||
.run(cat.extId, cat.position, catId);
|
||||
} else {
|
||||
catId = db.prepare(
|
||||
'INSERT INTO categories (set_id, ext_id, title, position) VALUES (?, ?, ?, ?)')
|
||||
.run(setId, cat.extId, cat.title, cat.position).lastInsertRowid;
|
||||
}
|
||||
|
||||
for (const sub of cat.subcategories) {
|
||||
let subRow = db.prepare('SELECT id FROM subcategories WHERE category_id = ? AND name = ?')
|
||||
.get(catId, sub.name);
|
||||
let subId;
|
||||
if (subRow) {
|
||||
subId = subRow.id;
|
||||
db.prepare('UPDATE subcategories SET position = ? WHERE id = ?').run(sub.position, subId);
|
||||
} else {
|
||||
subId = db.prepare(
|
||||
'INSERT INTO subcategories (category_id, name, position) VALUES (?, ?, ?)')
|
||||
.run(catId, sub.name, sub.position).lastInsertRowid;
|
||||
}
|
||||
|
||||
sub.questions.forEach((item, qi) => {
|
||||
const existing = db.prepare(
|
||||
'SELECT id, a FROM questions WHERE subcategory_id = ? AND q = ?').get(subId, item.q);
|
||||
if (existing) {
|
||||
keptIds.add(existing.id);
|
||||
if (existing.a !== item.a) stats.updated++; else stats.unchanged++;
|
||||
db.prepare('UPDATE questions SET a = ?, position = ?, archived = 0 WHERE id = ?')
|
||||
.run(item.a, qi, existing.id);
|
||||
} else {
|
||||
const id = db.prepare(
|
||||
'INSERT INTO questions (subcategory_id, q, a, position, created_at) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(subId, item.q, item.a, qi, ts).lastInsertRowid;
|
||||
keptIds.add(id);
|
||||
stats.added++;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!isNew) {
|
||||
const all = db.prepare(`
|
||||
SELECT q.id FROM questions q
|
||||
JOIN subcategories s ON s.id = q.subcategory_id
|
||||
JOIN categories c ON c.id = s.category_id
|
||||
WHERE c.set_id = ?`).all(setId);
|
||||
const stale = all.map((r) => r.id).filter((id) => !keptIds.has(id));
|
||||
const archive = db.prepare('UPDATE questions SET archived = 1 WHERE id = ?');
|
||||
for (const id of stale) { archive.run(id); stats.archived++; }
|
||||
}
|
||||
|
||||
return { setId, title: set.title, isNew, total: set.total, ...stats };
|
||||
});
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- reads -- */
|
||||
|
||||
const QUESTION_STATS = `
|
||||
(SELECT COUNT(*) FROM reviews r WHERE r.question_id = q.id) AS reviews,
|
||||
(SELECT COUNT(*) FROM reviews r WHERE r.question_id = q.id AND r.correct = 1) AS correct,
|
||||
(SELECT COUNT(*) FROM reviews r WHERE r.question_id = q.id AND r.correct = 0) AS wrong,
|
||||
(SELECT r.correct FROM reviews r WHERE r.question_id = q.id
|
||||
ORDER BY r.id DESC LIMIT 1) AS last_correct,
|
||||
(SELECT r.created_at FROM reviews r WHERE r.question_id = q.id
|
||||
ORDER BY r.id DESC LIMIT 1) AS last_reviewed
|
||||
`;
|
||||
|
||||
function listSets() {
|
||||
return db.prepare(`
|
||||
SELECT s.id, s.title, s.created_at, s.updated_at,
|
||||
(SELECT COUNT(*) FROM questions q
|
||||
JOIN subcategories sc ON sc.id = q.subcategory_id
|
||||
JOIN categories c ON c.id = sc.category_id
|
||||
WHERE c.set_id = s.id AND q.archived = 0) AS questions,
|
||||
(SELECT COUNT(DISTINCT q.id) FROM questions q
|
||||
JOIN subcategories sc ON sc.id = q.subcategory_id
|
||||
JOIN categories c ON c.id = sc.category_id
|
||||
JOIN reviews r ON r.question_id = q.id
|
||||
WHERE c.set_id = s.id AND q.archived = 0) AS seen,
|
||||
(SELECT COUNT(*) FROM reviews r
|
||||
JOIN questions q ON q.id = r.question_id
|
||||
JOIN subcategories sc ON sc.id = q.subcategory_id
|
||||
JOIN categories c ON c.id = sc.category_id
|
||||
WHERE c.set_id = s.id) AS answers,
|
||||
(SELECT COUNT(*) FROM reviews r
|
||||
JOIN questions q ON q.id = r.question_id
|
||||
JOIN subcategories sc ON sc.id = q.subcategory_id
|
||||
JOIN categories c ON c.id = sc.category_id
|
||||
WHERE c.set_id = s.id AND r.correct = 1) AS answers_correct
|
||||
FROM sets s
|
||||
ORDER BY s.updated_at DESC`).all().map((r) => ({ ...r }));
|
||||
}
|
||||
|
||||
function getSet(setId) {
|
||||
const row = db.prepare('SELECT id, title, created_at, updated_at FROM sets WHERE id = ?').get(setId);
|
||||
return row ? { ...row } : null;
|
||||
}
|
||||
|
||||
/** Full category tree of a set including per-subcategory progress counters. */
|
||||
function getTree(setId) {
|
||||
const rows = db.prepare(`
|
||||
SELECT c.id AS category_id, c.title AS category, c.position AS cpos,
|
||||
sc.id AS subcategory_id, sc.name AS subcategory, sc.position AS spos,
|
||||
COUNT(q.id) AS total,
|
||||
SUM(CASE WHEN (SELECT COUNT(*) FROM reviews r WHERE r.question_id = q.id) > 0
|
||||
THEN 1 ELSE 0 END) AS seen,
|
||||
SUM(CASE WHEN (SELECT r.correct FROM reviews r WHERE r.question_id = q.id
|
||||
ORDER BY r.id DESC LIMIT 1) = 1 THEN 1 ELSE 0 END) AS last_correct,
|
||||
SUM(CASE WHEN (SELECT r.correct FROM reviews r WHERE r.question_id = q.id
|
||||
ORDER BY r.id DESC LIMIT 1) = 0 THEN 1 ELSE 0 END) AS last_wrong
|
||||
FROM categories c
|
||||
JOIN subcategories sc ON sc.category_id = c.id
|
||||
JOIN questions q ON q.subcategory_id = sc.id AND q.archived = 0
|
||||
WHERE c.set_id = ?
|
||||
GROUP BY sc.id
|
||||
ORDER BY c.position, c.id, sc.position, sc.id`).all(setId);
|
||||
|
||||
const byCategory = new Map();
|
||||
for (const r of rows) {
|
||||
if (!byCategory.has(r.category_id)) {
|
||||
byCategory.set(r.category_id, {
|
||||
id: r.category_id, title: r.category, total: 0, seen: 0,
|
||||
lastCorrect: 0, lastWrong: 0, subcategories: [],
|
||||
});
|
||||
}
|
||||
const cat = byCategory.get(r.category_id);
|
||||
cat.subcategories.push({
|
||||
id: r.subcategory_id, name: r.subcategory, total: r.total,
|
||||
seen: r.seen, lastCorrect: r.last_correct, lastWrong: r.last_wrong,
|
||||
});
|
||||
cat.total += r.total;
|
||||
cat.seen += r.seen;
|
||||
cat.lastCorrect += r.last_correct;
|
||||
cat.lastWrong += r.last_wrong;
|
||||
}
|
||||
return [...byCategory.values()];
|
||||
}
|
||||
|
||||
const FILTERS = new Set(['all', 'wrong', 'unseen']);
|
||||
|
||||
/**
|
||||
* Builds a practice session: takes the questions of the chosen subcategories,
|
||||
* applies the filter, orders them (shuffled and/or weakest-first) and caps them
|
||||
* at `limit`. Order and filter are independent, so every combination works.
|
||||
*/
|
||||
function buildSession({
|
||||
setId, subcategoryIds = [], filter = 'all',
|
||||
shuffle: doShuffle = true, smart = true, limit = 0,
|
||||
}) {
|
||||
if (!FILTERS.has(filter)) filter = 'all';
|
||||
|
||||
const params = [setId];
|
||||
let scopeSql = '';
|
||||
if (subcategoryIds.length) {
|
||||
scopeSql = ` AND sc.id IN (${subcategoryIds.map(() => '?').join(',')})`;
|
||||
params.push(...subcategoryIds);
|
||||
}
|
||||
|
||||
const rows = db.prepare(`
|
||||
SELECT q.id, q.q, q.a, q.position,
|
||||
sc.id AS subcategory_id, sc.name AS subcategory,
|
||||
c.id AS category_id, c.title AS category,
|
||||
c.position AS cpos, sc.position AS spos,
|
||||
${QUESTION_STATS}
|
||||
FROM questions q
|
||||
JOIN subcategories sc ON sc.id = q.subcategory_id
|
||||
JOIN categories c ON c.id = sc.category_id
|
||||
WHERE c.set_id = ? AND q.archived = 0${scopeSql}
|
||||
ORDER BY c.position, c.id, sc.position, sc.id, q.position, q.id`).all(...params);
|
||||
|
||||
let pool = rows.map((r) => ({
|
||||
id: r.id,
|
||||
q: r.q,
|
||||
a: r.a,
|
||||
category: r.category,
|
||||
subcategory: r.subcategory,
|
||||
stats: {
|
||||
reviews: r.reviews,
|
||||
correct: r.correct,
|
||||
wrong: r.wrong,
|
||||
lastCorrect: r.last_correct === null ? null : !!r.last_correct,
|
||||
lastReviewed: r.last_reviewed,
|
||||
},
|
||||
}));
|
||||
|
||||
if (filter === 'wrong') pool = pool.filter((x) => x.stats.lastCorrect === false);
|
||||
if (filter === 'unseen') pool = pool.filter((x) => x.stats.reviews === 0);
|
||||
|
||||
if (smart) pool = smartOrder(pool, doShuffle);
|
||||
else if (doShuffle) shuffle(pool);
|
||||
// sonst: Reihenfolge des Skriptums, so wie sortiert geladen
|
||||
|
||||
if (limit > 0) pool = pool.slice(0, limit);
|
||||
return pool;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mixes repetition and new material: every third card is the weakest question
|
||||
* that is due again, the rest is fresh material. Cards that are already sitting
|
||||
* well (last answer correct, good accuracy, recently seen) go to the back.
|
||||
*/
|
||||
function smartOrder(pool, doShuffle = true) {
|
||||
const unseen = pool.filter((x) => x.stats.reviews === 0);
|
||||
const fresh = doShuffle ? shuffle(unseen) : unseen;
|
||||
const seen = pool.filter((x) => x.stats.reviews > 0);
|
||||
const due = seen.filter(needsRepeat).sort((a, b) => weight(b) - weight(a));
|
||||
const solid = seen.filter((x) => !needsRepeat(x)).sort((a, b) => weight(b) - weight(a));
|
||||
|
||||
const out = [];
|
||||
while (fresh.length || due.length) {
|
||||
const takeDue = due.length && (out.length % 3 === 2 || !fresh.length);
|
||||
out.push(takeDue ? due.shift() : fresh.shift());
|
||||
}
|
||||
return out.concat(solid);
|
||||
}
|
||||
|
||||
function needsRepeat(item) {
|
||||
const s = item.stats;
|
||||
if (s.lastCorrect === false) return true;
|
||||
if (s.correct / s.reviews < 0.7) return true;
|
||||
const ageDays = s.lastReviewed ? (Date.now() - Date.parse(s.lastReviewed)) / 86400000 : 0;
|
||||
return ageDays >= 7;
|
||||
}
|
||||
|
||||
function weight(item) {
|
||||
const s = item.stats;
|
||||
if (s.reviews === 0) return 1000;
|
||||
const accuracy = s.correct / s.reviews;
|
||||
let w = (1 - accuracy) * 500;
|
||||
if (s.lastCorrect === false) w += 300;
|
||||
const ageDays = s.lastReviewed
|
||||
? (Date.now() - Date.parse(s.lastReviewed)) / 86400000 : 0;
|
||||
return w + Math.min(ageDays, 60);
|
||||
}
|
||||
|
||||
function shuffle(arr) {
|
||||
for (let i = arr.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
function recordReview(questionId, correct) {
|
||||
const exists = db.prepare('SELECT id FROM questions WHERE id = ?').get(questionId);
|
||||
if (!exists) throw new Error('Unbekannte Frage.');
|
||||
db.prepare('INSERT INTO reviews (question_id, correct, created_at) VALUES (?, ?, ?)')
|
||||
.run(questionId, correct ? 1 : 0, now());
|
||||
const row = db.prepare(`SELECT ${QUESTION_STATS} FROM questions q WHERE q.id = ?`).get(questionId);
|
||||
return {
|
||||
reviews: row.reviews,
|
||||
correct: row.correct,
|
||||
wrong: row.wrong,
|
||||
lastCorrect: !!row.last_correct,
|
||||
lastReviewed: row.last_reviewed,
|
||||
};
|
||||
}
|
||||
|
||||
function undoReview(reviewId) {
|
||||
const row = db.prepare('SELECT id FROM reviews WHERE id = ?').get(reviewId);
|
||||
if (!row) return false;
|
||||
db.prepare('DELETE FROM reviews WHERE id = ?').run(reviewId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Overall stats, per-category accuracy, weakest questions and a 14 day chart. */
|
||||
function getStats(setId) {
|
||||
const totals = db.prepare(`
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM questions q
|
||||
JOIN subcategories sc ON sc.id = q.subcategory_id
|
||||
JOIN categories c ON c.id = sc.category_id
|
||||
WHERE c.set_id = ? AND q.archived = 0) AS questions,
|
||||
(SELECT COUNT(DISTINCT r.question_id) FROM reviews r
|
||||
JOIN questions q ON q.id = r.question_id
|
||||
JOIN subcategories sc ON sc.id = q.subcategory_id
|
||||
JOIN categories c ON c.id = sc.category_id
|
||||
WHERE c.set_id = ? AND q.archived = 0) AS seen,
|
||||
(SELECT COUNT(*) FROM reviews r
|
||||
JOIN questions q ON q.id = r.question_id
|
||||
JOIN subcategories sc ON sc.id = q.subcategory_id
|
||||
JOIN categories c ON c.id = sc.category_id
|
||||
WHERE c.set_id = ?) AS answers,
|
||||
(SELECT COUNT(*) FROM reviews r
|
||||
JOIN questions q ON q.id = r.question_id
|
||||
JOIN subcategories sc ON sc.id = q.subcategory_id
|
||||
JOIN categories c ON c.id = sc.category_id
|
||||
WHERE c.set_id = ? AND r.correct = 1) AS answers_correct`)
|
||||
.get(setId, setId, setId, setId);
|
||||
|
||||
const categories = db.prepare(`
|
||||
SELECT c.id, c.title,
|
||||
COUNT(q.id) AS total,
|
||||
SUM(CASE WHEN (SELECT COUNT(*) FROM reviews r WHERE r.question_id = q.id) > 0
|
||||
THEN 1 ELSE 0 END) AS seen,
|
||||
SUM(CASE WHEN (SELECT r.correct FROM reviews r WHERE r.question_id = q.id
|
||||
ORDER BY r.id DESC LIMIT 1) = 1 THEN 1 ELSE 0 END) AS last_correct,
|
||||
SUM(CASE WHEN (SELECT r.correct FROM reviews r WHERE r.question_id = q.id
|
||||
ORDER BY r.id DESC LIMIT 1) = 0 THEN 1 ELSE 0 END) AS last_wrong
|
||||
FROM categories c
|
||||
JOIN subcategories sc ON sc.category_id = c.id
|
||||
JOIN questions q ON q.subcategory_id = sc.id AND q.archived = 0
|
||||
WHERE c.set_id = ?
|
||||
GROUP BY c.id
|
||||
ORDER BY c.position, c.id`).all(setId).map((r) => ({ ...r }));
|
||||
|
||||
const weakest = db.prepare(`
|
||||
SELECT q.id, q.q, c.title AS category, sc.name AS subcategory, ${QUESTION_STATS}
|
||||
FROM questions q
|
||||
JOIN subcategories sc ON sc.id = q.subcategory_id
|
||||
JOIN categories c ON c.id = sc.category_id
|
||||
WHERE c.set_id = ? AND q.archived = 0
|
||||
AND (SELECT COUNT(*) FROM reviews r WHERE r.question_id = q.id AND r.correct = 0) > 0
|
||||
ORDER BY wrong DESC, correct ASC, last_reviewed DESC
|
||||
LIMIT 25`).all(setId).map((r) => ({ ...r, last_correct: r.last_correct }));
|
||||
|
||||
const daily = db.prepare(`
|
||||
SELECT substr(r.created_at, 1, 10) AS day,
|
||||
COUNT(*) AS answers,
|
||||
SUM(r.correct) AS correct
|
||||
FROM reviews r
|
||||
JOIN questions q ON q.id = r.question_id
|
||||
JOIN subcategories sc ON sc.id = q.subcategory_id
|
||||
JOIN categories c ON c.id = sc.category_id
|
||||
WHERE c.set_id = ?
|
||||
GROUP BY day
|
||||
ORDER BY day DESC
|
||||
LIMIT 14`).all(setId).map((r) => ({ ...r })).reverse();
|
||||
|
||||
return { totals: { ...totals }, categories, weakest, daily };
|
||||
}
|
||||
|
||||
function resetStats(setId) {
|
||||
return tx(() => db.prepare(`
|
||||
DELETE FROM reviews WHERE question_id IN (
|
||||
SELECT q.id FROM questions q
|
||||
JOIN subcategories sc ON sc.id = q.subcategory_id
|
||||
JOIN categories c ON c.id = sc.category_id
|
||||
WHERE c.set_id = ?)`).run(setId).changes);
|
||||
}
|
||||
|
||||
function deleteSet(setId) {
|
||||
return tx(() => db.prepare('DELETE FROM sets WHERE id = ?').run(setId).changes);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
db, importSet, normalizeSet, listSets, getSet, getTree, buildSession,
|
||||
recordReview, undoReview, getStats, resetStats, deleteSet,
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"name": "lapractice",
|
||||
"version": "1.0.0",
|
||||
"description": "Karteikarten-Trainer fuer die LAP-Theoriefragen (Import via JSON, Fortschritt in SQLite)",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node --disable-warning=ExperimentalWarning server.js",
|
||||
"dev": "node --disable-warning=ExperimentalWarning --watch server.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.5.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,607 @@
|
|||
// LAPractice – Karteikarten-Trainer. Alles state-in-memory, Persistenz via API.
|
||||
|
||||
const $ = (sel, root = document) => root.querySelector(sel);
|
||||
const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
|
||||
|
||||
const state = {
|
||||
sets: [],
|
||||
setId: Number(localStorage.getItem('lap.setId')) || null,
|
||||
tree: [],
|
||||
selection: new Set(),
|
||||
session: null,
|
||||
};
|
||||
|
||||
/* -------------------------------------------------------------------- api */
|
||||
|
||||
async function api(method, url, body) {
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: body === undefined ? {} : { 'Content-Type': 'application/json' },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
let data = {};
|
||||
try { data = await res.json(); } catch { /* leerer Body */ }
|
||||
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ utils */
|
||||
|
||||
const esc = (s) => String(s).replace(/[&<>"']/g,
|
||||
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
|
||||
const pct = (part, whole) => (whole ? Math.round((part / whole) * 100) : 0);
|
||||
|
||||
function toast(message, kind = '') {
|
||||
const el = document.createElement('div');
|
||||
el.className = `toast ${kind}`;
|
||||
el.textContent = message;
|
||||
$('#toasts').append(el);
|
||||
setTimeout(() => el.remove(), kind === 'error' ? 6000 : 3500);
|
||||
}
|
||||
|
||||
function bar(good, bad, total) {
|
||||
const rest = Math.max(total - good - bad, 0);
|
||||
return `<div class="bar">
|
||||
<span class="ok" style="width:${pct(good, total)}%"></span>
|
||||
<span class="no" style="width:${pct(bad, total)}%"></span>
|
||||
<span class="neutral" style="width:${pct(rest, total)}%"></span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ navigation */
|
||||
|
||||
function navigate(view) {
|
||||
$$('.view').forEach((v) => v.classList.toggle('active', v.id === `view-${view}`));
|
||||
$$('[data-nav]').forEach((b) => b.classList.toggle('active', b.dataset.nav === view));
|
||||
window.scrollTo({ top: 0, behavior: 'instant' });
|
||||
if (view === 'stats') loadStats();
|
||||
}
|
||||
|
||||
document.addEventListener('click', (ev) => {
|
||||
const nav = ev.target.closest('[data-nav]');
|
||||
if (nav) navigate(nav.dataset.nav);
|
||||
});
|
||||
|
||||
/* --------------------------------------------------------------- library */
|
||||
|
||||
async function loadSets() {
|
||||
const { sets } = await api('GET', '/api/sets');
|
||||
state.sets = sets;
|
||||
if (!sets.some((s) => s.id === state.setId)) state.setId = sets[0]?.id ?? null;
|
||||
renderLibrary();
|
||||
updateNav();
|
||||
if (state.setId) await loadTree();
|
||||
}
|
||||
|
||||
function updateNav() {
|
||||
const has = state.setId !== null;
|
||||
$('#nav-setup').hidden = !has;
|
||||
$('#nav-stats').hidden = !has;
|
||||
}
|
||||
|
||||
function renderLibrary() {
|
||||
const list = $('#set-list');
|
||||
if (!state.sets.length) {
|
||||
list.innerHTML = `<div class="empty">Noch kein Fragenset importiert.<br>
|
||||
Lade oben eine JSON-Datei hoch, um loszulegen.</div>`;
|
||||
return;
|
||||
}
|
||||
list.innerHTML = state.sets.map((s) => {
|
||||
const known = s.questions - s.seen;
|
||||
return `<article class="card set-card" data-set="${s.id}">
|
||||
<div>
|
||||
<div class="title">${esc(s.title)}</div>
|
||||
<div class="faint">zuletzt aktualisiert ${new Date(s.updated_at).toLocaleString('de-AT')}</div>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<span class="pill">${s.questions} Fragen</span>
|
||||
<span class="pill">${s.seen} geübt</span>
|
||||
<span class="pill">${known} offen</span>
|
||||
<span class="pill ${s.answers_correct / (s.answers || 1) >= 0.7 ? 'good' : ''}">
|
||||
${s.answers} Antworten · ${pct(s.answers_correct, s.answers)}% richtig</span>
|
||||
</div>
|
||||
<div class="bar">
|
||||
<span class="seen" style="width:${pct(s.seen, s.questions)}%"></span>
|
||||
<span class="neutral" style="width:${100 - pct(s.seen, s.questions)}%"></span>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="primary" data-act="practice">Üben</button>
|
||||
<button class="ghost" data-act="stats">Statistik</button>
|
||||
<button class="ghost" data-act="reset">Fortschritt zurücksetzen</button>
|
||||
<div class="spacer"></div>
|
||||
<button class="danger ghost" data-act="delete">Löschen</button>
|
||||
</div>
|
||||
</article>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
$('#set-list').addEventListener('click', async (ev) => {
|
||||
const btn = ev.target.closest('[data-act]');
|
||||
if (!btn) return;
|
||||
const setId = Number(btn.closest('[data-set]').dataset.set);
|
||||
const set = state.sets.find((s) => s.id === setId);
|
||||
try {
|
||||
if (btn.dataset.act === 'practice') {
|
||||
await selectSet(setId);
|
||||
navigate('setup');
|
||||
} else if (btn.dataset.act === 'stats') {
|
||||
await selectSet(setId);
|
||||
navigate('stats');
|
||||
} else if (btn.dataset.act === 'reset') {
|
||||
if (!confirm(`Alle Antwort-Statistiken von „${set.title}" löschen?`)) return;
|
||||
const { deletedReviews } = await api('POST', `/api/sets/${setId}/reset`);
|
||||
toast(`${deletedReviews} Antworten gelöscht.`, 'success');
|
||||
await loadSets();
|
||||
} else if (btn.dataset.act === 'delete') {
|
||||
if (!confirm(`Fragenset „${set.title}" mit allen Statistiken endgültig löschen?`)) return;
|
||||
await api('DELETE', `/api/sets/${setId}`);
|
||||
if (state.setId === setId) state.setId = null;
|
||||
toast('Fragenset gelöscht.', 'success');
|
||||
await loadSets();
|
||||
}
|
||||
} catch (err) {
|
||||
toast(err.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
async function selectSet(setId) {
|
||||
state.setId = setId;
|
||||
localStorage.setItem('lap.setId', String(setId));
|
||||
updateNav();
|
||||
await loadTree();
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- import */
|
||||
|
||||
const dropzone = $('#dropzone');
|
||||
$('#pick-file').addEventListener('click', () => $('#file-input').click());
|
||||
$('#file-input').addEventListener('change', (ev) => importFiles([...ev.target.files]));
|
||||
|
||||
['dragenter', 'dragover'].forEach((type) =>
|
||||
dropzone.addEventListener(type, (ev) => { ev.preventDefault(); dropzone.classList.add('drag'); }));
|
||||
['dragleave', 'drop'].forEach((type) =>
|
||||
dropzone.addEventListener(type, (ev) => { ev.preventDefault(); dropzone.classList.remove('drag'); }));
|
||||
dropzone.addEventListener('drop', (ev) => importFiles([...(ev.dataTransfer?.files || [])]));
|
||||
|
||||
async function importFiles(files) {
|
||||
const usable = files.filter((f) => /\.json$/i.test(f.name) || f.type.includes('json'));
|
||||
for (const file of files.filter((f) => !usable.includes(f))) {
|
||||
toast(`${file.name}: keine JSON-Datei – übersprungen.`, 'error');
|
||||
}
|
||||
for (const file of usable) {
|
||||
try {
|
||||
const data = JSON.parse(await file.text());
|
||||
const { import: r } = await api('POST', '/api/sets', {
|
||||
data,
|
||||
filename: file.name.replace(/\.json$/i, ''),
|
||||
});
|
||||
toast(r.isNew
|
||||
? `„${r.title}" importiert: ${r.total} Fragen.`
|
||||
: `„${r.title}" aktualisiert: ${r.added} neu, ${r.updated} geändert, ${r.archived} entfernt.`,
|
||||
'success');
|
||||
await loadSets();
|
||||
await selectSet(r.setId);
|
||||
} catch (err) {
|
||||
toast(`${file.name}: ${err.message}`, 'error');
|
||||
}
|
||||
}
|
||||
$('#file-input').value = '';
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- setup */
|
||||
|
||||
async function loadTree() {
|
||||
const { set, categories } = await api('GET', `/api/sets/${state.setId}`);
|
||||
state.tree = categories;
|
||||
$('#setup-title').textContent = set.title;
|
||||
$('#stats-title').textContent = `Statistik – ${set.title}`;
|
||||
|
||||
const stored = JSON.parse(localStorage.getItem(`lap.sel.${set.id}`) || 'null');
|
||||
const all = categories.flatMap((c) => c.subcategories.map((s) => s.id));
|
||||
state.selection = new Set(stored ? stored.filter((id) => all.includes(id)) : all);
|
||||
renderTree();
|
||||
}
|
||||
|
||||
function renderTree() {
|
||||
$('#tree').innerHTML = state.tree.map((cat) => {
|
||||
const picked = cat.subcategories.filter((s) => state.selection.has(s.id)).length;
|
||||
return `<div class="tree-cat ${picked ? 'open' : ''}" data-cat="${cat.id}">
|
||||
<header>
|
||||
<span class="twist">▶</span>
|
||||
<input type="checkbox" data-cat-check="${cat.id}"
|
||||
${picked === cat.subcategories.length ? 'checked' : ''}>
|
||||
<span class="cat-title">${esc(cat.title)}</span>
|
||||
<span class="faint">${cat.total}</span>
|
||||
${bar(cat.lastCorrect, cat.lastWrong, cat.total)}
|
||||
</header>
|
||||
<div class="tree-subs">
|
||||
${cat.subcategories.map((sub) => `
|
||||
<label>
|
||||
<input type="checkbox" data-sub="${sub.id}" ${state.selection.has(sub.id) ? 'checked' : ''}>
|
||||
<span>${esc(sub.name)}</span>
|
||||
<span class="count">${sub.lastCorrect}✓ ${sub.lastWrong}✗ / ${sub.total}</span>
|
||||
</label>`).join('')}
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
updateSelectionInfo();
|
||||
}
|
||||
|
||||
$('#tree').addEventListener('click', (ev) => {
|
||||
const subBox = ev.target.closest('[data-sub]');
|
||||
if (subBox) {
|
||||
const id = Number(subBox.dataset.sub);
|
||||
subBox.checked ? state.selection.add(id) : state.selection.delete(id);
|
||||
syncCategoryChecks();
|
||||
persistSelection();
|
||||
return;
|
||||
}
|
||||
const catBox = ev.target.closest('[data-cat-check]');
|
||||
if (catBox) {
|
||||
ev.stopPropagation();
|
||||
const cat = state.tree.find((c) => c.id === Number(catBox.dataset.catCheck));
|
||||
for (const sub of cat.subcategories) {
|
||||
catBox.checked ? state.selection.add(sub.id) : state.selection.delete(sub.id);
|
||||
}
|
||||
renderTree();
|
||||
persistSelection();
|
||||
return;
|
||||
}
|
||||
const header = ev.target.closest('.tree-cat > header');
|
||||
if (header) header.parentElement.classList.toggle('open');
|
||||
});
|
||||
|
||||
function syncCategoryChecks() {
|
||||
for (const cat of state.tree) {
|
||||
const box = $(`[data-cat-check="${cat.id}"]`);
|
||||
if (!box) continue;
|
||||
const picked = cat.subcategories.filter((s) => state.selection.has(s.id)).length;
|
||||
box.checked = picked === cat.subcategories.length;
|
||||
box.indeterminate = picked > 0 && picked < cat.subcategories.length;
|
||||
}
|
||||
updateSelectionInfo();
|
||||
}
|
||||
|
||||
function persistSelection() {
|
||||
localStorage.setItem(`lap.sel.${state.setId}`, JSON.stringify([...state.selection]));
|
||||
}
|
||||
|
||||
function selectedTotals() {
|
||||
let total = 0; let seen = 0; let wrong = 0;
|
||||
for (const cat of state.tree) {
|
||||
for (const sub of cat.subcategories) {
|
||||
if (!state.selection.has(sub.id)) continue;
|
||||
total += sub.total; seen += sub.seen; wrong += sub.lastWrong;
|
||||
}
|
||||
}
|
||||
return { total, unseen: total - seen, wrong };
|
||||
}
|
||||
|
||||
function poolSize() {
|
||||
const t = selectedTotals();
|
||||
const filter = $('#filter').value;
|
||||
return { pool: filter === 'wrong' ? t.wrong : filter === 'unseen' ? t.unseen : t.total, ...t };
|
||||
}
|
||||
|
||||
function updateSelectionInfo() {
|
||||
const { pool, total } = poolSize();
|
||||
const subs = state.selection.size;
|
||||
const opts = [
|
||||
$('#shuffle').checked ? 'gemischt' : 'Skriptum-Reihenfolge',
|
||||
$('#smart').checked ? 'Schwächen zuerst' : null,
|
||||
$('#endless').checked ? 'endlos' : null,
|
||||
].filter(Boolean).join(' · ');
|
||||
$('#selection-info').textContent = subs
|
||||
? `${subs} Themen · ${pool} Fragen im Pool${pool === total ? '' : ` (von ${total})`} · ${opts}`
|
||||
: 'Nichts ausgewählt';
|
||||
$('#start').disabled = subs === 0 || pool === 0;
|
||||
}
|
||||
|
||||
$('#setup-all').addEventListener('click', () => {
|
||||
state.selection = new Set(state.tree.flatMap((c) => c.subcategories.map((s) => s.id)));
|
||||
renderTree(); persistSelection();
|
||||
});
|
||||
$('#setup-none').addEventListener('click', () => {
|
||||
state.selection.clear();
|
||||
renderTree(); persistSelection();
|
||||
});
|
||||
for (const id of ['filter', 'shuffle', 'smart', 'endless', 'limit']) {
|
||||
$(`#${id}`).addEventListener('change', () => {
|
||||
const el = $(`#${id}`);
|
||||
localStorage.setItem(`lap.${id}`, el.type === 'checkbox' ? String(el.checked) : el.value);
|
||||
if (id === 'endless') {
|
||||
$('#limit-row').querySelector('.label').textContent = el.checked ? 'Pro Runde' : 'Anzahl';
|
||||
}
|
||||
updateSelectionInfo();
|
||||
});
|
||||
}
|
||||
|
||||
/** Die im Setup gewählten Optionen als Session-Konfiguration. */
|
||||
function readOptions() {
|
||||
return {
|
||||
subcategoryIds: [...state.selection],
|
||||
filter: $('#filter').value,
|
||||
shuffle: $('#shuffle').checked,
|
||||
smart: $('#smart').checked,
|
||||
endless: $('#endless').checked,
|
||||
limit: Number($('#limit').value),
|
||||
};
|
||||
}
|
||||
|
||||
const apiPayload = ({ endless, ...rest }) => rest;
|
||||
|
||||
/* -------------------------------------------------------------- practice */
|
||||
|
||||
$('#start').addEventListener('click', () => startSession());
|
||||
|
||||
async function startSession(override) {
|
||||
const config = override || readOptions();
|
||||
try {
|
||||
const { questions } = await api('POST', `/api/sets/${state.setId}/session`, apiPayload(config));
|
||||
if (!questions.length) {
|
||||
toast('Keine Fragen gefunden – andere Auswahl oder Filter probieren.', 'error');
|
||||
return;
|
||||
}
|
||||
state.session = { questions, index: 0, results: [], config, endless: !!config.endless, cycle: 1 };
|
||||
navigate('practice');
|
||||
renderCard();
|
||||
} catch (err) {
|
||||
toast(err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Endlos-Modus: ist der Stapel durch, wird die naechste Runde geholt. Der
|
||||
* Server sortiert dabei mit den frisch gespeicherten Ergebnissen, sodass gerade
|
||||
* falsch beantwortete Fragen wieder nach vorne rutschen.
|
||||
*/
|
||||
async function nextRound() {
|
||||
const s = state.session;
|
||||
$('#p-question').textContent = 'Nächste Runde wird geladen …';
|
||||
let next = [];
|
||||
try {
|
||||
({ questions: next } = await api('POST', `/api/sets/${state.setId}/session`, apiPayload(s.config)));
|
||||
} catch (err) {
|
||||
toast(err.message, 'error');
|
||||
}
|
||||
if (!next.length) next = s.questions.slice(); // Fallback: dieselben Karten nochmal
|
||||
if (!next.length) return finishSession();
|
||||
s.cycle++;
|
||||
s.questions = next;
|
||||
s.index = 0;
|
||||
toast(`Runde ${s.cycle} · ${next.length} Karten`);
|
||||
renderCard();
|
||||
}
|
||||
|
||||
function currentCard() {
|
||||
const s = state.session;
|
||||
return s ? s.questions[s.index] : null;
|
||||
}
|
||||
|
||||
function renderCard() {
|
||||
const s = state.session;
|
||||
const card = currentCard();
|
||||
if (!card) return s.endless ? nextRound() : finishSession();
|
||||
|
||||
$('#p-position').textContent = s.endless
|
||||
? `Karte ${s.results.length + 1} · Runde ${s.cycle}`
|
||||
: `${s.index + 1} / ${s.questions.length}`;
|
||||
$('#p-progress').style.width = `${pct(s.index, s.questions.length)}%`;
|
||||
$('#p-correct').textContent = `✓ ${s.results.filter((r) => r.correct).length}`;
|
||||
$('#p-wrong').textContent = `✗ ${s.results.filter((r) => r.correct === false).length}`;
|
||||
$('#p-category').textContent = card.category;
|
||||
$('#p-subcategory').textContent = card.subcategory;
|
||||
|
||||
const hist = $('#p-history');
|
||||
if (card.stats.reviews > 0) {
|
||||
hist.hidden = false;
|
||||
hist.className = `pill ${card.stats.lastCorrect ? 'good' : 'bad'}`;
|
||||
hist.textContent = `bisher ${card.stats.correct}✓ ${card.stats.wrong}✗`;
|
||||
} else {
|
||||
hist.hidden = false;
|
||||
hist.className = 'pill accent';
|
||||
hist.textContent = 'neu';
|
||||
}
|
||||
|
||||
$('#p-question').textContent = card.q;
|
||||
$('#p-answer-text').textContent = card.a;
|
||||
setRevealed(false);
|
||||
}
|
||||
|
||||
function setRevealed(revealed) {
|
||||
state.session.revealed = revealed;
|
||||
$('#p-answer').hidden = !revealed;
|
||||
$('#p-judge').hidden = !revealed;
|
||||
$('#p-reveal').hidden = revealed;
|
||||
if (!revealed) $('#p-reveal').focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
$('#p-reveal').addEventListener('click', () => setRevealed(true));
|
||||
$('#p-yes').addEventListener('click', () => judge(true));
|
||||
$('#p-no').addEventListener('click', () => judge(false));
|
||||
$('#p-quit').addEventListener('click', () => {
|
||||
if (state.session?.results.length) finishSession();
|
||||
else navigate('setup');
|
||||
});
|
||||
|
||||
async function judge(correct) {
|
||||
const s = state.session;
|
||||
const card = currentCard();
|
||||
if (!s || !card || !s.revealed) return;
|
||||
|
||||
s.results.push({ ...card, correct });
|
||||
if (s.endless && !correct) {
|
||||
// Falsche Karte kommt noch in dieser Runde wieder.
|
||||
s.questions.splice(Math.min(s.index + 5, s.questions.length), 0, card);
|
||||
}
|
||||
s.index++;
|
||||
renderCard();
|
||||
|
||||
try {
|
||||
const { stats } = await api('POST', '/api/reviews', { questionId: card.id, correct });
|
||||
card.stats = stats;
|
||||
} catch (err) {
|
||||
toast(`Antwort nicht gespeichert: ${err.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function skipCard() {
|
||||
const s = state.session;
|
||||
if (!s) return;
|
||||
s.results.push({ ...currentCard(), correct: null });
|
||||
s.index++;
|
||||
renderCard();
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', (ev) => {
|
||||
if (!$('#view-practice').classList.contains('active')) return;
|
||||
if (ev.target.matches('input, select, textarea')) return;
|
||||
const s = state.session;
|
||||
if (!s) return;
|
||||
|
||||
if (ev.key === 'Escape') { ev.preventDefault(); $('#p-quit').click(); return; }
|
||||
if (ev.code === 'Space' || ev.key === 'Enter') {
|
||||
ev.preventDefault();
|
||||
if (!s.revealed) setRevealed(true);
|
||||
return;
|
||||
}
|
||||
if (ev.key.toLowerCase() === 's') { ev.preventDefault(); skipCard(); return; }
|
||||
if (!s.revealed) return;
|
||||
if (ev.key === '2' || ev.key === 'ArrowRight') { ev.preventDefault(); judge(true); }
|
||||
else if (ev.key === '1' || ev.key === 'ArrowLeft') { ev.preventDefault(); judge(false); }
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------- summary */
|
||||
|
||||
function finishSession() {
|
||||
const s = state.session;
|
||||
const answered = s.results.filter((r) => r.correct !== null);
|
||||
const correct = answered.filter((r) => r.correct).length;
|
||||
const wrong = answered.length - correct;
|
||||
const skipped = s.results.length - answered.length;
|
||||
|
||||
$('#summary-scores').innerHTML = `
|
||||
<div class="score"><div class="num">${answered.length}</div><div class="lbl">beantwortet</div></div>
|
||||
<div class="score ok"><div class="num">${correct}</div><div class="lbl">richtig</div></div>
|
||||
<div class="score no"><div class="num">${wrong}</div><div class="lbl">falsch</div></div>
|
||||
<div class="score"><div class="num">${pct(correct, answered.length)}%</div><div class="lbl">Trefferquote</div></div>
|
||||
${skipped ? `<div class="score"><div class="num">${skipped}</div><div class="lbl">übersprungen</div></div>` : ''}`;
|
||||
|
||||
const order = [...s.results].sort((a, b) => (a.correct === false ? -1 : 1) - (b.correct === false ? -1 : 1));
|
||||
$('#summary-list').innerHTML = order.map((r) => `
|
||||
<div class="review-item ${r.correct ? 'ok' : ''}">
|
||||
<div class="q">${r.correct === null ? '⤼' : r.correct ? '✓' : '✗'} ${esc(r.q)}</div>
|
||||
<div class="a">${esc(r.a)}</div>
|
||||
<div class="faint" style="margin-top:6px">${esc(r.category)} › ${esc(r.subcategory)}</div>
|
||||
</div>`).join('');
|
||||
|
||||
$('#again-wrong').disabled = wrong === 0;
|
||||
$('#again-wrong').dataset.ids = JSON.stringify(
|
||||
s.results.filter((r) => r.correct === false).map((r) => r.id));
|
||||
|
||||
navigate('summary');
|
||||
loadSets().catch(() => {});
|
||||
}
|
||||
|
||||
$('#again-wrong').addEventListener('click', () => {
|
||||
const ids = new Set(JSON.parse($('#again-wrong').dataset.ids || '[]'));
|
||||
const questions = state.session.results
|
||||
.filter((r) => ids.has(r.id))
|
||||
.map(({ correct, ...q }) => q);
|
||||
state.session = {
|
||||
questions, index: 0, results: [],
|
||||
config: { ...state.session.config, endless: false }, endless: false, cycle: 1,
|
||||
};
|
||||
navigate('practice');
|
||||
renderCard();
|
||||
});
|
||||
|
||||
$('#again-same').addEventListener('click', () => startSession(state.session.config));
|
||||
|
||||
/* ----------------------------------------------------------------- stats */
|
||||
|
||||
async function loadStats() {
|
||||
if (!state.setId) return;
|
||||
try {
|
||||
const data = await api('GET', `/api/sets/${state.setId}/stats`);
|
||||
renderStats(data);
|
||||
} catch (err) {
|
||||
toast(err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function renderStats({ set, totals, categories, weakest, daily }) {
|
||||
$('#stats-title').textContent = `Statistik – ${set.title}`;
|
||||
$('#stats-scores').innerHTML = `
|
||||
<div class="score"><div class="num">${totals.questions}</div><div class="lbl">Fragen</div></div>
|
||||
<div class="score"><div class="num">${totals.seen}</div><div class="lbl">davon geübt</div></div>
|
||||
<div class="score"><div class="num">${totals.answers}</div><div class="lbl">Antworten</div></div>
|
||||
<div class="score ${pct(totals.answers_correct, totals.answers) >= 70 ? 'ok' : 'no'}">
|
||||
<div class="num">${pct(totals.answers_correct, totals.answers)}%</div>
|
||||
<div class="lbl">richtig gesamt</div></div>`;
|
||||
|
||||
const byDay = new Map(daily.map((d) => [d.day, d]));
|
||||
const days = [...Array(14)].map((_, i) => {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - (13 - i));
|
||||
const key = date.toISOString().slice(0, 10);
|
||||
return byDay.get(key) || { day: key, answers: 0, correct: 0 };
|
||||
});
|
||||
const max = Math.max(1, ...days.map((d) => d.answers));
|
||||
$('#stats-days').innerHTML = days.map((d) => {
|
||||
const h = d.answers ? Math.round((d.answers / max) * 68) + 4 : 3;
|
||||
const okShare = pct(d.correct, d.answers);
|
||||
return `<div class="day" title="${d.day}: ${d.correct}/${d.answers} richtig">
|
||||
<div class="stack ${d.answers ? '' : 'idle'}" style="height:${h}px">
|
||||
${d.answers ? `<span class="ok" style="height:${okShare}%"></span>
|
||||
<span class="no" style="height:${100 - okShare}%"></span>` : ''}
|
||||
</div>
|
||||
<div class="lbl">${d.day.slice(8)}.${d.day.slice(5, 7)}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
$('#stats-categories').innerHTML = categories.map((c) => `
|
||||
<div class="stat-row">
|
||||
<span class="name" title="${esc(c.title)}">${esc(c.title)}</span>
|
||||
${bar(c.last_correct, c.last_wrong, c.total)}
|
||||
<span class="pct">${pct(c.last_correct, c.total)}% · ${c.total}</span>
|
||||
</div>`).join('');
|
||||
|
||||
$('#stats-weakest').innerHTML = weakest.length
|
||||
? weakest.map((w) => `
|
||||
<div class="stat-row">
|
||||
<span class="name" title="${esc(w.q)}">${esc(w.q)}
|
||||
<span class="faint"> · ${esc(w.category)}</span></span>
|
||||
${bar(w.correct, w.wrong, w.reviews)}
|
||||
<span class="pct">${w.correct}✓ ${w.wrong}✗</span>
|
||||
</div>`).join('')
|
||||
: '<div class="faint">Keine falsch beantworteten Fragen – stark.</div>';
|
||||
}
|
||||
|
||||
$('#stats-reset').addEventListener('click', async () => {
|
||||
const set = state.sets.find((s) => s.id === state.setId);
|
||||
if (!confirm(`Alle Antwort-Statistiken von „${set?.title}" löschen?`)) return;
|
||||
try {
|
||||
const { deletedReviews } = await api('POST', `/api/sets/${state.setId}/reset`);
|
||||
toast(`${deletedReviews} Antworten gelöscht.`, 'success');
|
||||
await loadSets();
|
||||
await loadStats();
|
||||
} catch (err) {
|
||||
toast(err.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ boot */
|
||||
|
||||
$('#filter').value = localStorage.getItem('lap.filter') || 'all';
|
||||
$('#limit').value = localStorage.getItem('lap.limit') || '20';
|
||||
for (const id of ['shuffle', 'smart', 'endless']) {
|
||||
const stored = localStorage.getItem(`lap.${id}`);
|
||||
if (stored !== null) $(`#${id}`).checked = stored === 'true';
|
||||
}
|
||||
$('#limit-row').querySelector('.label').textContent = $('#endless').checked ? 'Pro Runde' : 'Anzahl';
|
||||
|
||||
loadSets()
|
||||
.then(() => navigate(state.setId ? 'setup' : 'library'))
|
||||
.catch((err) => { toast(err.message, 'error'); navigate('library'); });
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="color-scheme" content="dark light">
|
||||
<title>LAPractice – LAP Lernkarten</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎓</text></svg>">
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
|
||||
<header class="topbar">
|
||||
<div class="brand"><span class="logo">LP</span> LAPractice</div>
|
||||
<nav>
|
||||
<button class="tab ghost" data-nav="library">Fragensets</button>
|
||||
<button class="tab ghost" data-nav="setup" id="nav-setup" hidden>Üben</button>
|
||||
<button class="tab ghost" data-nav="stats" id="nav-stats" hidden>Statistik</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<!-- ------------------------------------------------------------ library -->
|
||||
<section class="view" id="view-library">
|
||||
<div class="section-head">
|
||||
<h2>Fragenset importieren</h2>
|
||||
<span class="hint">JSON-Datei mit Kategorien, Unterkategorien und Fragen</span>
|
||||
</div>
|
||||
|
||||
<div class="dropzone" id="dropzone">
|
||||
<h3>Datei hierher ziehen</h3>
|
||||
<p class="muted">oder</p>
|
||||
<button class="primary" id="pick-file">Datei auswählen</button>
|
||||
<input type="file" id="file-input" accept=".json,application/json" hidden multiple>
|
||||
<p class="faint" style="margin-top:14px">
|
||||
Erwartetes Format:
|
||||
<code>{ "title": …, "categories": [ { "title": …, "subcategories": [ { "name": …, "questions": [ { "q": …, "a": … } ] } ] } ] }</code><br>
|
||||
Ein Set mit gleichem Titel erneut importieren aktualisiert die Fragen und behält die Statistik.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="section-head">
|
||||
<h2>Meine Fragensets</h2>
|
||||
<div class="spacer"></div>
|
||||
</div>
|
||||
<div class="set-list" id="set-list"></div>
|
||||
</section>
|
||||
|
||||
<!-- -------------------------------------------------------------- setup -->
|
||||
<section class="view" id="view-setup">
|
||||
<div class="section-head">
|
||||
<h2 id="setup-title">Übung zusammenstellen</h2>
|
||||
<div class="spacer"></div>
|
||||
<button class="ghost" id="setup-all">Alle wählen</button>
|
||||
<button class="ghost" id="setup-none">Keine</button>
|
||||
</div>
|
||||
|
||||
<div class="setup-grid">
|
||||
<div class="card options">
|
||||
<div class="option-row">
|
||||
<span class="label">Fragen</span>
|
||||
<select id="filter">
|
||||
<option value="all">Alle ausgewählten Fragen</option>
|
||||
<option value="wrong">Nur zuletzt falsch beantwortete</option>
|
||||
<option value="unseen">Nur noch nie gesehene</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="option-row">
|
||||
<span class="label">Reihenfolge</span>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="shuffle" checked>
|
||||
<span>Fragen mischen</span>
|
||||
<span class="faint">– zufällige Reihenfolge statt Skriptum</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="option-row">
|
||||
<span class="label"></span>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="smart" checked>
|
||||
<span>Schwächen zuerst</span>
|
||||
<span class="faint">– neue und falsch beantwortete Fragen nach vorne</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="option-row">
|
||||
<span class="label">Umfang</span>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="endless">
|
||||
<span>Endlos üben</span>
|
||||
<span class="faint">– wiederholt, bis ich die Runde beende</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="option-row" id="limit-row">
|
||||
<span class="label">Anzahl</span>
|
||||
<select id="limit">
|
||||
<option value="10">10 Karten</option>
|
||||
<option value="20" selected>20 Karten</option>
|
||||
<option value="30">30 Karten</option>
|
||||
<option value="50">50 Karten</option>
|
||||
<option value="0">Alle</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tree" id="tree"></div>
|
||||
</div>
|
||||
|
||||
<div class="start-bar">
|
||||
<span class="muted" id="selection-info">Nichts ausgewählt</span>
|
||||
<div class="spacer"></div>
|
||||
<button class="ghost" data-nav="library">Zurück</button>
|
||||
<button class="primary big" id="start">Übung starten</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ----------------------------------------------------------- practice -->
|
||||
<section class="view" id="view-practice">
|
||||
<div class="practice-head">
|
||||
<div class="row">
|
||||
<span class="counter muted" id="p-position">–</span>
|
||||
<span class="pill good" id="p-correct">✓ 0</span>
|
||||
<span class="pill bad" id="p-wrong">✗ 0</span>
|
||||
<div class="spacer"></div>
|
||||
<button class="ghost" id="p-quit">Beenden</button>
|
||||
</div>
|
||||
<div class="progress"><span id="p-progress" style="width:0%"></span></div>
|
||||
</div>
|
||||
|
||||
<div class="flashcard">
|
||||
<div class="crumbs">
|
||||
<span class="pill accent" id="p-category">–</span>
|
||||
<span class="pill" id="p-subcategory">–</span>
|
||||
<span class="spacer"></span>
|
||||
<span class="pill" id="p-history" hidden></span>
|
||||
</div>
|
||||
|
||||
<div class="question" id="p-question"></div>
|
||||
|
||||
<div class="answer" id="p-answer" hidden>
|
||||
<span class="lbl">Antwort</span>
|
||||
<div id="p-answer-text"></div>
|
||||
</div>
|
||||
|
||||
<div class="card-actions">
|
||||
<button class="primary big" id="p-reveal" style="flex:1">Antwort aufdecken</button>
|
||||
<div class="judge" id="p-judge" hidden>
|
||||
<button class="wrong big" id="p-no">✗ Falsch</button>
|
||||
<button class="correct big" id="p-yes">✓ Richtig</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="keyhints">
|
||||
<span><kbd>Leertaste</kbd> aufdecken</span>
|
||||
<span><kbd>1</kbd> / <kbd>←</kbd> falsch</span>
|
||||
<span><kbd>2</kbd> / <kbd>→</kbd> richtig</span>
|
||||
<span><kbd>S</kbd> überspringen</span>
|
||||
<span><kbd>Esc</kbd> Runde beenden</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ------------------------------------------------------------ summary -->
|
||||
<section class="view" id="view-summary">
|
||||
<div class="section-head"><h2>Runde beendet</h2></div>
|
||||
<div class="score-grid" id="summary-scores"></div>
|
||||
|
||||
<div class="section-head">
|
||||
<h2>Durchgang</h2>
|
||||
<div class="spacer"></div>
|
||||
<button class="primary" id="again-wrong">Falsche wiederholen</button>
|
||||
<button class="ghost" id="again-same">Neue Runde</button>
|
||||
<button class="ghost" data-nav="setup">Auswahl ändern</button>
|
||||
</div>
|
||||
<div class="review-list" id="summary-list"></div>
|
||||
</section>
|
||||
|
||||
<!-- -------------------------------------------------------------- stats -->
|
||||
<section class="view" id="view-stats">
|
||||
<div class="section-head">
|
||||
<h2 id="stats-title">Statistik</h2>
|
||||
<div class="spacer"></div>
|
||||
<button class="danger ghost" id="stats-reset">Fortschritt zurücksetzen</button>
|
||||
</div>
|
||||
<div class="score-grid" id="stats-scores"></div>
|
||||
|
||||
<div class="section-head"><h2>Letzte 14 Tage</h2></div>
|
||||
<div class="card"><div class="days" id="stats-days"></div></div>
|
||||
|
||||
<div class="section-head">
|
||||
<h2>Kategorien</h2>
|
||||
<span class="hint">Anteil der Fragen, die zuletzt richtig beantwortet wurden</span>
|
||||
</div>
|
||||
<div class="stat-rows" id="stats-categories"></div>
|
||||
|
||||
<div class="section-head">
|
||||
<h2>Schwächste Fragen</h2>
|
||||
<span class="hint">nach Anzahl falscher Antworten</span>
|
||||
</div>
|
||||
<div class="stat-rows" id="stats-weakest"></div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
<div class="toasts" id="toasts"></div>
|
||||
<script type="module" src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,321 @@
|
|||
/* ------------------------------------------------------------------ theme */
|
||||
:root {
|
||||
--bg: #0f1116;
|
||||
--bg-elev: #171a21;
|
||||
--bg-elev-2: #1e222b;
|
||||
--border: #2a2f3a;
|
||||
--border-strong: #3a4150;
|
||||
--text: #e8eaef;
|
||||
--text-dim: #a0a7b6;
|
||||
--text-faint: #6d7484;
|
||||
--accent: #6a8dff;
|
||||
--accent-soft: rgba(106, 141, 255, 0.14);
|
||||
--good: #3ecf8e;
|
||||
--good-soft: rgba(62, 207, 142, 0.14);
|
||||
--bad: #ff6b6b;
|
||||
--bad-soft: rgba(255, 107, 107, 0.14);
|
||||
--warn: #f0b840;
|
||||
--radius: 14px;
|
||||
--radius-sm: 9px;
|
||||
--shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
|
||||
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--bg: #f4f5f8;
|
||||
--bg-elev: #ffffff;
|
||||
--bg-elev-2: #eef0f5;
|
||||
--border: #dfe3ec;
|
||||
--border-strong: #c6ccdb;
|
||||
--text: #171a21;
|
||||
--text-dim: #59606f;
|
||||
--text-faint: #878e9d;
|
||||
--accent: #3f63d8;
|
||||
--accent-soft: rgba(63, 99, 216, 0.1);
|
||||
--good: #14915d;
|
||||
--good-soft: rgba(20, 145, 93, 0.12);
|
||||
--bad: #d13d3d;
|
||||
--bad-soft: rgba(209, 61, 61, 0.12);
|
||||
--warn: #b07908;
|
||||
--shadow: 0 10px 30px rgba(20, 25, 40, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 16px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
h1, h2, h3 { margin: 0; line-height: 1.25; font-weight: 650; letter-spacing: -0.01em; }
|
||||
p { margin: 0 0 0.8em; }
|
||||
|
||||
/* ----------------------------------------------------------------- layout */
|
||||
.app { max-width: 980px; margin: 0 auto; padding: 0 18px 80px; }
|
||||
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 14px 0 12px;
|
||||
background: linear-gradient(var(--bg) 72%, transparent);
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 10px; font-weight: 700; letter-spacing: -0.02em; }
|
||||
.brand .logo {
|
||||
width: 30px; height: 30px; border-radius: 9px;
|
||||
background: linear-gradient(135deg, var(--accent), #a06aff);
|
||||
display: grid; place-items: center; color: #fff; font-size: 15px; font-weight: 800;
|
||||
}
|
||||
.topbar nav { display: flex; gap: 4px; margin-left: auto; flex-wrap: wrap; }
|
||||
|
||||
.view { display: none; animation: fade 0.18s ease; }
|
||||
.view.active { display: block; }
|
||||
@keyframes fade { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
|
||||
|
||||
.section-head {
|
||||
display: flex; align-items: baseline; gap: 12px;
|
||||
flex-wrap: wrap; margin: 26px 0 14px;
|
||||
}
|
||||
.section-head h2 { font-size: 20px; }
|
||||
.section-head .hint { color: var(--text-dim); font-size: 13px; }
|
||||
.spacer { flex: 1; }
|
||||
|
||||
/* ------------------------------------------------------------------ atoms */
|
||||
.card {
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
button, .btn {
|
||||
font: inherit;
|
||||
font-weight: 550;
|
||||
color: var(--text);
|
||||
background: var(--bg-elev-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 9px 15px;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, border-color 0.12s, transform 0.06s;
|
||||
}
|
||||
button:hover { border-color: var(--border-strong); }
|
||||
button:active { transform: translateY(1px); }
|
||||
button:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
button.primary { background: var(--accent); border-color: transparent; color: #fff; }
|
||||
button.primary:hover { filter: brightness(1.08); }
|
||||
button.ghost { background: transparent; }
|
||||
button.ghost:hover { background: var(--bg-elev-2); }
|
||||
button.danger { color: var(--bad); }
|
||||
button.danger:hover { background: var(--bad-soft); border-color: var(--bad); }
|
||||
button.tab.active { background: var(--accent-soft); border-color: transparent; color: var(--accent); }
|
||||
button.big { padding: 14px 22px; font-size: 16px; }
|
||||
|
||||
.pill {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 3px 10px; border-radius: 999px;
|
||||
background: var(--bg-elev-2); border: 1px solid var(--border);
|
||||
font-size: 12px; color: var(--text-dim); white-space: nowrap;
|
||||
}
|
||||
.pill.good { color: var(--good); background: var(--good-soft); border-color: transparent; }
|
||||
.pill.bad { color: var(--bad); background: var(--bad-soft); border-color: transparent; }
|
||||
.pill.accent { color: var(--accent); background: var(--accent-soft); border-color: transparent; }
|
||||
|
||||
.bar { height: 7px; border-radius: 999px; background: var(--bg-elev-2); overflow: hidden; display: flex; }
|
||||
.bar > span { display: block; height: 100%; }
|
||||
.bar .ok { background: var(--good); }
|
||||
.bar .no { background: var(--bad); }
|
||||
.bar .neutral { background: var(--border-strong); }
|
||||
.bar .seen { background: var(--accent); }
|
||||
|
||||
kbd {
|
||||
font: 11px/1 var(--mono);
|
||||
padding: 3px 6px; border-radius: 5px;
|
||||
background: var(--bg); border: 1px solid var(--border-strong); color: var(--text-dim);
|
||||
}
|
||||
|
||||
.muted { color: var(--text-dim); }
|
||||
.faint { color: var(--text-faint); font-size: 13px; }
|
||||
.empty {
|
||||
border: 1px dashed var(--border-strong); border-radius: var(--radius);
|
||||
padding: 34px; text-align: center; color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- library */
|
||||
.dropzone {
|
||||
border: 2px dashed var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
padding: 30px 20px;
|
||||
text-align: center;
|
||||
background: var(--bg-elev);
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.dropzone.drag { border-color: var(--accent); background: var(--accent-soft); }
|
||||
.dropzone h3 { font-size: 16px; margin-bottom: 6px; }
|
||||
|
||||
.set-list { display: grid; gap: 12px; }
|
||||
.set-card { display: grid; gap: 12px; }
|
||||
.set-card .title { font-size: 17px; font-weight: 650; }
|
||||
.set-card .meta { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.set-card .actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
|
||||
/* ----------------------------------------------------------------- setup */
|
||||
.setup-grid { display: grid; gap: 16px; grid-template-columns: 1fr; }
|
||||
.options { display: grid; gap: 10px; }
|
||||
.option-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||
.option-row > .label { min-width: 96px; color: var(--text-dim); font-size: 13px; }
|
||||
.toggle {
|
||||
display: flex; align-items: center; gap: 8px; cursor: pointer;
|
||||
padding: 5px 9px; border-radius: var(--radius-sm); border: 1px solid transparent;
|
||||
}
|
||||
.toggle:hover { background: var(--bg-elev-2); }
|
||||
.toggle:has(input:checked) { border-color: var(--border-strong); background: var(--accent-soft); }
|
||||
.toggle .faint { font-size: 12px; }
|
||||
@media (max-width: 620px) { .toggle .faint { display: none; } }
|
||||
|
||||
.tree { display: grid; gap: 8px; }
|
||||
.tree-cat {
|
||||
border: 1px solid var(--border); border-radius: var(--radius-sm);
|
||||
background: var(--bg-elev); overflow: hidden;
|
||||
}
|
||||
.tree-cat > header {
|
||||
display: flex; align-items: center; gap: 10px; padding: 11px 13px; cursor: pointer;
|
||||
}
|
||||
.tree-cat > header:hover { background: var(--bg-elev-2); }
|
||||
.tree-cat .cat-title { font-weight: 600; flex: 1; min-width: 0; }
|
||||
.tree-cat .bar { width: 110px; flex: none; }
|
||||
.tree-cat .twist { color: var(--text-faint); transition: transform 0.15s; font-size: 12px; }
|
||||
.tree-cat.open .twist { transform: rotate(90deg); }
|
||||
.tree-subs { display: none; padding: 2px 13px 12px 40px; }
|
||||
.tree-cat.open .tree-subs { display: grid; gap: 2px; }
|
||||
.tree-subs label {
|
||||
display: flex; align-items: center; gap: 9px; padding: 5px 6px;
|
||||
border-radius: 7px; cursor: pointer; font-size: 14px;
|
||||
}
|
||||
.tree-subs label:hover { background: var(--bg-elev-2); }
|
||||
.tree-subs .count { margin-left: auto; color: var(--text-faint); font-size: 12px; font-family: var(--mono); }
|
||||
input[type="checkbox"] { width: 16px; height: 16px; accent-color: var(--accent); cursor: pointer; }
|
||||
|
||||
select, input[type="number"] {
|
||||
font: inherit; color: var(--text); background: var(--bg-elev-2);
|
||||
border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 8px 11px;
|
||||
}
|
||||
|
||||
.start-bar {
|
||||
position: sticky; bottom: 0; margin-top: 16px;
|
||||
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
|
||||
padding: 13px; border-radius: var(--radius);
|
||||
background: var(--bg-elev); border: 1px solid var(--border); box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- practice */
|
||||
.practice-head { display: grid; gap: 10px; margin: 8px 0 18px; }
|
||||
.practice-head .row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.progress { height: 6px; border-radius: 999px; background: var(--bg-elev-2); overflow: hidden; }
|
||||
.progress > span { display: block; height: 100%; background: var(--accent); transition: width 0.25s ease; }
|
||||
.counter { font-family: var(--mono); font-size: 13px; }
|
||||
|
||||
.flashcard {
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 18px;
|
||||
box-shadow: var(--shadow);
|
||||
padding: 26px;
|
||||
min-height: 260px;
|
||||
display: flex; flex-direction: column; gap: 18px;
|
||||
}
|
||||
.flashcard .crumbs { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.flashcard .question {
|
||||
font-size: clamp(20px, 3vw, 26px); font-weight: 600; letter-spacing: -0.015em;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.flashcard .answer {
|
||||
border-top: 1px solid var(--border); padding-top: 18px;
|
||||
font-size: 17px; color: var(--text);
|
||||
animation: reveal 0.22s ease;
|
||||
}
|
||||
.flashcard .answer > div { white-space: pre-wrap; }
|
||||
.flashcard .answer .lbl {
|
||||
display: block; font-size: 11px; letter-spacing: 0.09em; text-transform: uppercase;
|
||||
color: var(--text-faint); margin-bottom: 7px;
|
||||
}
|
||||
@keyframes reveal { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: none; } }
|
||||
|
||||
.card-actions { margin-top: auto; display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
|
||||
.judge { display: flex; gap: 10px; flex: 1; }
|
||||
.judge button { flex: 1; display: flex; justify-content: center; align-items: center; gap: 9px; }
|
||||
button.correct { background: var(--good-soft); border-color: var(--good); color: var(--good); }
|
||||
button.correct:hover { background: var(--good); color: #06130d; }
|
||||
button.wrong { background: var(--bad-soft); border-color: var(--bad); color: var(--bad); }
|
||||
button.wrong:hover { background: var(--bad); color: #210909; }
|
||||
|
||||
.keyhints { display: flex; gap: 14px; flex-wrap: wrap; margin-top: 14px; color: var(--text-faint); font-size: 12px; }
|
||||
.keyhints span { display: inline-flex; align-items: center; gap: 5px; }
|
||||
|
||||
/* --------------------------------------------------------------- summary */
|
||||
.score-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; }
|
||||
.score {
|
||||
background: var(--bg-elev); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 16px;
|
||||
}
|
||||
.score .num { font-size: 30px; font-weight: 700; letter-spacing: -0.03em; }
|
||||
.score .lbl { font-size: 12px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.07em; }
|
||||
.score.ok .num { color: var(--good); }
|
||||
.score.no .num { color: var(--bad); }
|
||||
|
||||
.review-list { display: grid; gap: 8px; }
|
||||
.review-item {
|
||||
border: 1px solid var(--border); border-left: 3px solid var(--bad);
|
||||
border-radius: var(--radius-sm); background: var(--bg-elev); padding: 12px 14px;
|
||||
}
|
||||
.review-item.ok { border-left-color: var(--good); }
|
||||
.review-item .q { font-weight: 550; }
|
||||
.review-item .a { color: var(--text-dim); font-size: 14px; margin-top: 6px; white-space: pre-wrap; }
|
||||
|
||||
/* ----------------------------------------------------------------- stats */
|
||||
.stat-rows { display: grid; gap: 8px; }
|
||||
.stat-row {
|
||||
display: grid; grid-template-columns: 1fr 130px 84px; gap: 12px; align-items: center;
|
||||
padding: 10px 13px; border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm); background: var(--bg-elev);
|
||||
}
|
||||
.stat-row .name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.stat-row .pct { text-align: right; font-family: var(--mono); font-size: 13px; color: var(--text-dim); }
|
||||
.days { display: flex; gap: 6px; align-items: flex-end; height: 90px; }
|
||||
.days .day {
|
||||
flex: 1; max-width: 46px; display: flex; flex-direction: column;
|
||||
justify-content: flex-end; gap: 4px;
|
||||
}
|
||||
.days .day .stack.idle { background: var(--bg-elev-2); border-radius: 3px; }
|
||||
.days .day .stack { border-radius: 4px 4px 0 0; overflow: hidden; display: flex; flex-direction: column-reverse; }
|
||||
.days .day .ok { background: var(--good); }
|
||||
.days .day .no { background: var(--bad); }
|
||||
.days .lbl { font-size: 10px; color: var(--text-faint); text-align: center; }
|
||||
|
||||
/* ---------------------------------------------------------------- toasts */
|
||||
.toasts { position: fixed; right: 16px; bottom: 16px; display: grid; gap: 8px; z-index: 50; }
|
||||
.toast {
|
||||
background: var(--bg-elev); border: 1px solid var(--border-strong);
|
||||
border-left: 3px solid var(--accent); border-radius: var(--radius-sm);
|
||||
padding: 11px 15px; box-shadow: var(--shadow); max-width: 380px;
|
||||
animation: fade 0.2s ease;
|
||||
}
|
||||
.toast.error { border-left-color: var(--bad); }
|
||||
.toast.success { border-left-color: var(--good); }
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.app { padding: 0 12px 60px; }
|
||||
.topbar { flex-wrap: wrap; }
|
||||
.flashcard { padding: 20px; border-radius: 14px; }
|
||||
.stat-row { grid-template-columns: 1fr 70px; }
|
||||
.stat-row .bar { display: none; }
|
||||
.judge { flex-direction: column; }
|
||||
}
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
'use strict';
|
||||
|
||||
const http = require('node:http');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const store = require('./db');
|
||||
|
||||
const PORT = Number(process.env.PORT) || 4173;
|
||||
const HOST = process.env.HOST || '127.0.0.1';
|
||||
const PUBLIC_DIR = path.join(__dirname, 'public');
|
||||
const MAX_UPLOAD = 32 * 1024 * 1024; // 32 MB is plenty for a question set
|
||||
|
||||
const MIME = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.ico': 'image/x-icon',
|
||||
'.woff2': 'font/woff2',
|
||||
};
|
||||
|
||||
function sendJson(res, status, body) {
|
||||
const payload = JSON.stringify(body);
|
||||
res.writeHead(status, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Content-Length': Buffer.byteLength(payload),
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
res.end(payload);
|
||||
}
|
||||
|
||||
function readBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
req.on('data', (chunk) => {
|
||||
size += chunk.length;
|
||||
if (size > MAX_UPLOAD) {
|
||||
reject(Object.assign(new Error('Datei ist zu gross.'), { status: 413 }));
|
||||
req.destroy();
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
req.on('end', () => {
|
||||
const raw = Buffer.concat(chunks).toString('utf8');
|
||||
if (!raw.trim()) return resolve({});
|
||||
try {
|
||||
resolve(JSON.parse(raw));
|
||||
} catch {
|
||||
reject(Object.assign(new Error('Ungueltiges JSON.'), { status: 400 }));
|
||||
}
|
||||
});
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function serveStatic(req, res, pathname) {
|
||||
const rel = pathname === '/' ? 'index.html' : pathname.slice(1);
|
||||
const filePath = path.join(PUBLIC_DIR, rel);
|
||||
if (!filePath.startsWith(PUBLIC_DIR + path.sep)) {
|
||||
return sendJson(res, 403, { error: 'Verboten.' });
|
||||
}
|
||||
fs.readFile(filePath, (err, data) => {
|
||||
if (err) {
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||
return res.end('404 – nicht gefunden');
|
||||
}
|
||||
res.writeHead(200, {
|
||||
'Content-Type': MIME[path.extname(filePath)] || 'application/octet-stream',
|
||||
'Content-Length': data.length,
|
||||
'Cache-Control': 'no-cache',
|
||||
});
|
||||
res.end(data);
|
||||
});
|
||||
}
|
||||
|
||||
const routes = [
|
||||
['GET', /^\/api\/sets$/, () => ({ sets: store.listSets() })],
|
||||
|
||||
['POST', /^\/api\/sets$/, async (req, res, m, body) => {
|
||||
const raw = body && body.data !== undefined ? body.data : body;
|
||||
const result = store.importSet(raw, body && body.filename);
|
||||
return { import: result, sets: store.listSets() };
|
||||
}],
|
||||
|
||||
['GET', /^\/api\/sets\/(\d+)$/, (req, res, m) => {
|
||||
const set = store.getSet(Number(m[1]));
|
||||
if (!set) throw Object.assign(new Error('Fragenset nicht gefunden.'), { status: 404 });
|
||||
return { set, categories: store.getTree(set.id) };
|
||||
}],
|
||||
|
||||
['DELETE', /^\/api\/sets\/(\d+)$/, (req, res, m) => {
|
||||
const changes = store.deleteSet(Number(m[1]));
|
||||
if (!changes) throw Object.assign(new Error('Fragenset nicht gefunden.'), { status: 404 });
|
||||
return { deleted: true, sets: store.listSets() };
|
||||
}],
|
||||
|
||||
['POST', /^\/api\/sets\/(\d+)\/reset$/, (req, res, m) => ({
|
||||
deletedReviews: store.resetStats(Number(m[1])),
|
||||
})],
|
||||
|
||||
['GET', /^\/api\/sets\/(\d+)\/stats$/, (req, res, m) => {
|
||||
const set = store.getSet(Number(m[1]));
|
||||
if (!set) throw Object.assign(new Error('Fragenset nicht gefunden.'), { status: 404 });
|
||||
return { set, ...store.getStats(set.id) };
|
||||
}],
|
||||
|
||||
['POST', /^\/api\/sets\/(\d+)\/session$/, (req, res, m, body) => {
|
||||
const setId = Number(m[1]);
|
||||
if (!store.getSet(setId)) {
|
||||
throw Object.assign(new Error('Fragenset nicht gefunden.'), { status: 404 });
|
||||
}
|
||||
const ids = Array.isArray(body.subcategoryIds)
|
||||
? body.subcategoryIds.map(Number).filter(Number.isInteger) : [];
|
||||
const questions = store.buildSession({
|
||||
setId,
|
||||
subcategoryIds: ids,
|
||||
filter: String(body.filter || 'all'),
|
||||
shuffle: body.shuffle !== false,
|
||||
smart: body.smart !== false,
|
||||
limit: Number(body.limit) || 0,
|
||||
});
|
||||
return { questions };
|
||||
}],
|
||||
|
||||
['POST', /^\/api\/reviews$/, (req, res, m, body) => {
|
||||
const questionId = Number(body.questionId);
|
||||
if (!Number.isInteger(questionId)) {
|
||||
throw Object.assign(new Error('questionId fehlt.'), { status: 400 });
|
||||
}
|
||||
if (typeof body.correct !== 'boolean') {
|
||||
throw Object.assign(new Error('correct muss true oder false sein.'), { status: 400 });
|
||||
}
|
||||
return { stats: store.recordReview(questionId, body.correct) };
|
||||
}],
|
||||
];
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
||||
const pathname = decodeURIComponent(url.pathname);
|
||||
|
||||
if (!pathname.startsWith('/api/')) {
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
return sendJson(res, 405, { error: 'Methode nicht erlaubt.' });
|
||||
}
|
||||
return serveStatic(req, res, pathname);
|
||||
}
|
||||
|
||||
let pathMatched = false;
|
||||
for (const [method, pattern, handler] of routes) {
|
||||
const m = pattern.exec(pathname);
|
||||
if (!m) continue;
|
||||
pathMatched = true;
|
||||
if (method !== req.method) continue;
|
||||
try {
|
||||
const body = req.method === 'GET' || req.method === 'DELETE' ? {} : await readBody(req);
|
||||
const result = await handler(req, res, m, body);
|
||||
if (!res.writableEnded) sendJson(res, 200, result ?? { ok: true });
|
||||
} catch (err) {
|
||||
const status = err.status || 400;
|
||||
if (!err.status) console.error(err);
|
||||
if (!res.writableEnded) sendJson(res, status, { error: err.message || 'Fehler.' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathMatched) return sendJson(res, 405, { error: 'Methode nicht erlaubt.' });
|
||||
sendJson(res, 404, { error: 'Unbekannter Endpunkt.' });
|
||||
});
|
||||
|
||||
server.listen(PORT, HOST, () => {
|
||||
console.log(`LAPractice laeuft auf http://${HOST}:${PORT}`);
|
||||
});
|
||||
Loading…
Reference in New Issue