feat: quiz, cheat sheets, admin modules - MC quiz, search, user mgmt, content import

Dieser Commit ist enthalten in:
hafroese 2026-04-02 23:41:19 +02:00
Ursprung 03778e4bed
Commit 3cd865ccbc
6 geänderte Dateien mit 425 neuen und 0 gelöschten Zeilen

51
edu/api/admin.php Normale Datei
Datei anzeigen

@ -0,0 +1,51 @@
<?php
require_once __DIR__ . '/import.php';
$admin_action = $segments[1] ?? '';
// GET /api/admin/users
if (get_method() === 'GET' && $admin_action === 'users') {
require_admin();
$stmt = $pdo->query("SELECT id, username, display_name, is_admin, created_at FROM users ORDER BY id");
json_ok(['users' => $stmt->fetchAll()]);
}
// POST /api/admin/users
if (get_method() === 'POST' && $admin_action === 'users') {
require_admin();
$body = get_json_body();
$username = trim($body['username'] ?? '');
$display_name = trim($body['display_name'] ?? '');
$password = $body['password'] ?? '';
$is_admin = !empty($body['is_admin']);
if (!$username || !$password || !$display_name) json_error('Benutzername, Anzeigename und Passwort erforderlich');
if (mb_strlen($password) < 6) json_error('Passwort muss mindestens 6 Zeichen haben');
$hash = password_hash($password, PASSWORD_DEFAULT);
try {
$stmt = $pdo->prepare("INSERT INTO users (username, password_hash, display_name, is_admin) VALUES (:u, :h, :d, :a) RETURNING id");
$stmt->execute([':u' => $username, ':h' => $hash, ':d' => $display_name, ':a' => $is_admin]);
json_ok(['id' => $stmt->fetchColumn(), 'message' => 'Benutzer erstellt']);
} catch (PDOException $e) {
if (strpos($e->getMessage(), 'unique') !== false) json_error('Benutzername existiert bereits');
throw $e;
}
}
// DELETE /api/admin/users/{id}
if (get_method() === 'DELETE' && $admin_action === 'users' && !empty($segments[2])) {
require_admin();
$del_id = (int) $segments[2];
if ($del_id === $_SESSION['user_id']) json_error('Eigenen Account kann man nicht loeschen');
$pdo->prepare("DELETE FROM users WHERE id = :id")->execute([':id' => $del_id]);
json_ok(['ok' => true]);
}
// POST /api/admin/import
if (get_method() === 'POST' && $admin_action === 'import') {
require_admin();
$stats = import_all_content($pdo);
json_ok(['message' => 'Import abgeschlossen', 'stats' => $stats]);
}
json_error('Unbekannter Admin-Endpunkt', 404);

28
edu/api/cheatsheets.php Normale Datei
Datei anzeigen

@ -0,0 +1,28 @@
<?php
require_auth();
// GET /api/cheatsheets
if (get_method() === 'GET' && empty($segments[1])) {
$stmt = $pdo->query("SELECT id, slug, title, category FROM cheatsheets ORDER BY category, title");
json_ok(['cheatsheets' => $stmt->fetchAll()]);
}
// GET /api/cheatsheets/search?q=...
if (get_method() === 'GET' && ($segments[1] ?? '') === 'search') {
$q = get_param('q', '');
if (mb_strlen($q) < 2) json_error('Mindestens 2 Zeichen');
$stmt = $pdo->prepare("SELECT id, slug, title, category FROM cheatsheets WHERE content_md ILIKE :q OR title ILIKE :q ORDER BY title");
$stmt->execute([':q' => '%' . $q . '%']);
json_ok(['cheatsheets' => $stmt->fetchAll(), 'query' => $q]);
}
// GET /api/cheatsheets/{slug}
if (get_method() === 'GET' && !empty($segments[1])) {
$stmt = $pdo->prepare("SELECT * FROM cheatsheets WHERE slug = :slug");
$stmt->execute([':slug' => $segments[1]]);
$sheet = $stmt->fetch();
if (!$sheet) json_error('Cheat Sheet nicht gefunden', 404);
json_ok(['cheatsheet' => $sheet]);
}
json_error('Unbekannter Cheatsheet-Endpunkt', 404);

77
edu/api/quiz.php Normale Datei
Datei anzeigen

@ -0,0 +1,77 @@
<?php
$user_id = require_auth();
// GET /api/quiz/generate?deck={slug}&count=10
if (get_method() === 'GET' && ($segments[1] ?? '') === 'generate') {
$slug = get_param('deck', '');
$count = max(1, min(50, (int) get_param('count', 10)));
$query = "SELECT c.id, c.question, c.answer, c.level, d.slug AS deck_slug, d.name AS deck_name
FROM cards c JOIN decks d ON d.id = c.deck_id";
$params = [];
if ($slug) {
$query .= " WHERE d.slug = :slug";
$params[':slug'] = $slug;
}
$query .= " ORDER BY RANDOM() LIMIT " . $count;
$stmt = $pdo->prepare($query);
$stmt->execute($params);
$cards = $stmt->fetchAll();
$all_answers = $pdo->query("SELECT DISTINCT answer FROM cards ORDER BY RANDOM() LIMIT 200")->fetchAll(PDO::FETCH_COLUMN);
$questions = [];
foreach ($cards as $card) {
$wrong = [];
$shuffled = $all_answers;
shuffle($shuffled);
foreach ($shuffled as $a) {
if ($a !== $card['answer'] && count($wrong) < 3) {
$wrong[] = mb_substr($a, 0, 120) . (mb_strlen($a) > 120 ? '...' : '');
}
}
$correct_short = mb_substr($card['answer'], 0, 120) . (mb_strlen($card['answer']) > 120 ? '...' : '');
$options = $wrong;
$options[] = $correct_short;
shuffle($options);
$questions[] = [
'card_id' => $card['id'],
'question' => $card['question'],
'options' => $options,
'correct_index' => array_search($correct_short, $options),
'full_answer' => $card['answer'],
'deck_name' => $card['deck_name']
];
}
json_ok(['questions' => $questions, 'deck' => $slug ?: 'all']);
}
// POST /api/quiz/submit
if (get_method() === 'POST' && ($segments[1] ?? '') === 'submit') {
$body = get_json_body();
$total = (int) ($body['total'] ?? 0);
$correct = (int) ($body['correct'] ?? 0);
$score = $total > 0 ? round(($correct / $total) * 100) : 0;
$stmt = $pdo->prepare("
INSERT INTO quiz_results (user_id, deck_slug, quiz_type, score_percent, total_questions, correct_answers)
VALUES (:uid, :deck, 'multiple_choice', :score, :total, :correct)
");
$stmt->execute([':uid' => $user_id, ':deck' => $body['deck'] ?? 'all', ':score' => $score, ':total' => $total, ':correct' => $correct]);
json_ok(['score' => $score]);
}
// GET /api/quiz/history
if (get_method() === 'GET' && ($segments[1] ?? '') === 'history') {
$stmt = $pdo->prepare("
SELECT deck_slug, quiz_type, score_percent, total_questions, correct_answers, created_at
FROM quiz_results WHERE user_id = :uid ORDER BY created_at DESC LIMIT 20
");
$stmt->execute([':uid' => $user_id]);
json_ok(['results' => $stmt->fetchAll()]);
}
json_error('Unbekannter Quiz-Endpunkt', 404);

86
edu/js/admin.js Normale Datei
Datei anzeigen

@ -0,0 +1,86 @@
'use strict';
var Admin = {
render: function() {
var el = document.getElementById('content');
if (!App.currentUser || !App.currentUser.is_admin) {
el.textContent = 'Kein Zugriff';
return;
}
el.textContent = 'Lade Admin...';
var self = this;
API.get('/admin/users').then(function(data) { self.renderPanel(el, data.users); })
.catch(function(e) { el.textContent = e.message; });
},
renderPanel: function(el, users) {
var h = '<div class="page-header"><h1>Administration</h1></div>';
h += '<div class="admin-section"><h2>Benutzer</h2>';
h += '<table class="user-table"><thead><tr><th>ID</th><th>Benutzer</th><th>Name</th><th>Admin</th><th>Erstellt</th><th></th></tr></thead><tbody>';
users.forEach(function(u) {
h += '<tr><td>' + u.id + '</td><td>' + Markdown.esc(u.username) + '</td><td>' + Markdown.esc(u.display_name) + '</td>';
h += '<td>' + (u.is_admin ? 'Ja' : 'Nein') + '</td><td>' + new Date(u.created_at).toLocaleDateString('de-DE') + '</td>';
h += '<td>' + (u.id !== App.currentUser.id ? '<button class="btn btn-small" data-del="' + u.id + '" data-name="' + Markdown.esc(u.username) + '">Entfernen</button>' : '') + '</td></tr>';
});
h += '</tbody></table>';
h += '<h3 style="margin-top:24px;">Neuer Benutzer</h3>';
h += '<form id="add-user-form" style="display:flex;gap:8px;margin-top:8px;flex-wrap:wrap;align-items:end;">';
h += '<div><label style="font-size:12px;color:var(--text-secondary)">Benutzername</label><br><input type="text" id="new-username" required style="width:140px"></div>';
h += '<div><label style="font-size:12px;color:var(--text-secondary)">Anzeigename</label><br><input type="text" id="new-displayname" required style="width:140px"></div>';
h += '<div><label style="font-size:12px;color:var(--text-secondary)">Passwort</label><br><input type="password" id="new-password" required style="width:140px"></div>';
h += '<div><label style="font-size:12px;color:var(--text-secondary)"><input type="checkbox" id="new-is-admin"> Admin</label></div>';
h += '<button type="submit" class="btn btn-primary">Erstellen</button></form>';
h += '<div id="add-user-msg" style="margin-top:8px;"></div></div>';
h += '<div class="admin-section"><h2>Content</h2>';
h += '<p style="color:var(--text-secondary);margin-bottom:12px;">Markdown-Dateien neu einlesen und Datenbank aktualisieren.</p>';
h += '<button class="btn btn-primary" id="import-btn">Content aktualisieren</button>';
h += '<div id="import-msg" style="margin-top:8px;"></div></div>';
el.innerHTML = h;
var self = this;
el.querySelectorAll('[data-del]').forEach(function(btn) {
btn.addEventListener('click', function() { self.deleteUser(parseInt(btn.dataset.del), btn.dataset.name); });
});
document.getElementById('add-user-form').addEventListener('submit', function(e) { e.preventDefault(); self.addUser(); });
document.getElementById('import-btn').addEventListener('click', function() { self.importContent(); });
},
addUser: function() {
var msg = document.getElementById('add-user-msg');
var self = this;
API.post('/admin/users', {
username: document.getElementById('new-username').value.trim(),
display_name: document.getElementById('new-displayname').value.trim(),
password: document.getElementById('new-password').value,
is_admin: document.getElementById('new-is-admin').checked
}).then(function() {
msg.textContent = 'Benutzer erstellt!';
msg.style.color = 'var(--green)';
self.render();
}).catch(function(e) {
msg.textContent = e.message;
msg.style.color = 'var(--red)';
});
},
deleteUser: function(id, name) {
if (!confirm('Benutzer "' + name + '" wirklich entfernen? Lernfortschritt geht verloren.')) return;
var self = this;
API.del('/admin/users/' + id).then(function() { self.render(); })
.catch(function(e) { alert('Fehler: ' + e.message); });
},
importContent: function() {
var msg = document.getElementById('import-msg');
msg.textContent = 'Importiere...';
msg.style.color = 'var(--text-muted)';
API.post('/admin/import').then(function(data) {
var s = data.stats;
msg.textContent = 'Fertig! ' + s.decks + ' Decks, ' + s.cards + ' Karten, ' + s.tutorials + ' Tutorials, ' + s.cheatsheets + ' Cheat Sheets importiert.';
msg.style.color = 'var(--green)';
}).catch(function(e) {
msg.textContent = e.message;
msg.style.color = 'var(--red)';
});
}
};

74
edu/js/cheatsheets.js Normale Datei
Datei anzeigen

@ -0,0 +1,74 @@
'use strict';
var CheatSheets = {
render: function(params) {
var el = document.getElementById('content');
if (params[0]) { this.renderSingle(el, params[0]); return; }
el.textContent = 'Lade Cheat Sheets...';
var self = this;
API.get('/cheatsheets').then(function(data) { self.renderList(el, data.cheatsheets); })
.catch(function(e) { el.textContent = e.message; });
},
renderList: function(el, sheets) {
var groups = {};
sheets.forEach(function(s) {
var cat = s.category || 'Sonstige';
if (!groups[cat]) groups[cat] = [];
groups[cat].push(s);
});
var h = '<div class="page-header"><h1>Cheat Sheets</h1><p>' + sheets.length + ' Referenzkarten</p></div>';
h += '<div class="search-bar"><input type="search" id="sheet-search" placeholder="Suchen..."></div>';
h += '<div id="sheet-list">';
Object.keys(groups).forEach(function(cat) {
h += '<h3 style="margin:16px 0 8px;color:var(--text-secondary)">' + Markdown.esc(cat) + '</h3><div class="sheet-grid">';
groups[cat].forEach(function(s) {
h += '<div class="sheet-card" data-slug="' + s.slug + '"><div class="sheet-title">' + Markdown.esc(s.title) + '</div>';
h += '<div class="sheet-cat">' + Markdown.esc(s.category) + '</div></div>';
});
h += '</div>';
});
h += '</div>';
el.innerHTML = h;
el.querySelectorAll('.sheet-card').forEach(function(card) {
card.addEventListener('click', function() { location.hash = '#/cheatsheets/' + card.dataset.slug; });
});
var self = this;
var searchTimer;
document.getElementById('sheet-search').addEventListener('input', function() {
clearTimeout(searchTimer);
var q = this.value;
searchTimer = setTimeout(function() {
if (q.length < 2) {
API.get('/cheatsheets').then(function(data) { self.renderList(el, data.cheatsheets); });
} else {
API.get('/cheatsheets/search?q=' + encodeURIComponent(q)).then(function(data) {
var list = document.getElementById('sheet-list');
if (!list) return;
var lh = '<div class="sheet-grid">';
data.cheatsheets.forEach(function(s) {
lh += '<div class="sheet-card" data-slug="' + s.slug + '"><div class="sheet-title">' + Markdown.esc(s.title) + '</div><div class="sheet-cat">' + Markdown.esc(s.category) + '</div></div>';
});
lh += '</div>';
if (data.cheatsheets.length === 0) lh = '<div class="empty-state">Keine Treffer</div>';
list.innerHTML = lh;
list.querySelectorAll('.sheet-card').forEach(function(card) {
card.addEventListener('click', function() { location.hash = '#/cheatsheets/' + card.dataset.slug; });
});
}).catch(function() {});
}
}, 300);
});
},
renderSingle: function(el, slug) {
el.textContent = 'Lade...';
API.get('/cheatsheets/' + slug).then(function(data) {
var s = data.cheatsheet;
var h = '<a href="#/cheatsheets" class="back-link">&larr; Zurueck zu Cheat Sheets</a>';
h += '<div class="sheet-content">' + Markdown.render(s.content_md) + '</div>';
el.innerHTML = h;
}).catch(function(e) { el.textContent = e.message; });
}
};

109
edu/js/quiz.js Normale Datei
Datei anzeigen

@ -0,0 +1,109 @@
'use strict';
var Quiz = {
state: null,
render: function(params) {
var el = document.getElementById('content');
if (params[0] === 'play') { this.play(el, params[1] || ''); return; }
el.textContent = 'Lade...';
var self = this;
Promise.all([API.get('/decks'), API.get('/quiz/history')]).then(function(results) {
self.renderStart(el, results[0].decks, results[1].results);
}).catch(function(e) { el.textContent = e.message; });
},
renderStart: function(el, decks, history) {
var h = '<div class="page-header"><h1>Quiz</h1><p>Teste dein Wissen mit Multiple-Choice-Fragen</p></div>';
h += '<h3 style="margin-bottom:12px;">Deck waehlen</h3><div class="deck-grid">';
h += '<div class="deck-card" data-slug="all" style="border-color:var(--accent)"><div class="deck-name">Alle Decks</div><div class="deck-info">Querbeet aus allen Themen</div></div>';
decks.forEach(function(d) {
h += '<div class="deck-card" data-slug="' + d.slug + '"><div class="deck-name">' + Markdown.esc(d.name) + '</div><div class="deck-info">' + d.card_count + ' Karten</div></div>';
});
h += '</div>';
if (history.length > 0) {
h += '<h3 style="margin:24px 0 12px;">Letzte Ergebnisse</h3><table class="user-table"><thead><tr><th>Datum</th><th>Deck</th><th>Score</th><th>Richtig</th></tr></thead><tbody>';
history.forEach(function(r) {
h += '<tr><td>' + new Date(r.created_at).toLocaleDateString('de-DE') + '</td><td>' + (r.deck_slug || 'Alle') + '</td><td><strong>' + r.score_percent + '%</strong></td><td>' + r.correct_answers + '/' + r.total_questions + '</td></tr>';
});
h += '</tbody></table>';
}
el.innerHTML = h;
el.querySelectorAll('.deck-card').forEach(function(card) {
card.addEventListener('click', function() { location.hash = '#/quiz/play/' + card.dataset.slug; });
});
},
play: function(el, deck) {
el.textContent = 'Generiere Quiz...';
var self = this;
var slug = deck === 'all' ? '' : deck;
API.get('/quiz/generate?deck=' + slug + '&count=10').then(function(data) {
self.state = { questions: data.questions, index: 0, correct: 0, selected: -1, answered: false, deck: deck };
self.renderQuestion(el);
}).catch(function(e) { el.textContent = e.message; });
},
renderQuestion: function(el) {
var s = this.state;
if (s.index >= s.questions.length) { this.renderResult(el); return; }
var q = s.questions[s.index];
var h = '<a href="#/quiz" class="back-link">&larr; Abbrechen</a>';
h += '<div class="study-header"><span class="study-deck-name">' + Markdown.esc(q.deck_name || 'Quiz') + '</span>';
h += '<span class="study-counter">Frage ' + (s.index + 1) + ' / ' + s.questions.length + '</span></div>';
h += '<div class="progress-bar" style="margin-bottom:24px;"><div class="fill fill-accent" style="width:' + ((s.index / s.questions.length) * 100) + '%"></div></div>';
h += '<div class="flashcard"><div class="card-question">' + Markdown.render(q.question) + '</div><div style="margin-top:16px;">';
q.options.forEach(function(opt, i) {
var cls = 'quiz-option';
if (s.answered) {
if (i === q.correct_index) cls += ' correct';
else if (i === s.selected) cls += ' wrong';
} else if (i === s.selected) cls += ' selected';
h += '<button class="' + cls + '" data-idx="' + i + '"' + (s.answered ? ' disabled' : '') + '>' + Markdown.esc(opt) + '</button>';
});
h += '</div>';
if (s.answered) {
h += '<div class="card-answer" style="margin-top:16px;"><div class="card-answer-label">Erklaerung</div>' + Markdown.render(q.full_answer) + '</div>';
h += '<div style="text-align:center;margin-top:16px;"><button class="btn btn-primary" id="quiz-next">Weiter</button></div>';
}
h += '</div>';
el.innerHTML = h;
var self = this;
if (!s.answered) {
el.querySelectorAll('.quiz-option').forEach(function(btn) {
btn.addEventListener('click', function() { self.selectOption(parseInt(btn.dataset.idx)); });
});
} else {
document.getElementById('quiz-next').addEventListener('click', function() { self.next(); });
}
},
selectOption: function(index) {
var s = this.state;
if (s.answered) return;
s.selected = index;
if (index === s.questions[s.index].correct_index) s.correct++;
s.answered = true;
this.renderQuestion(document.getElementById('content'));
},
next: function() {
this.state.index++;
this.state.selected = -1;
this.state.answered = false;
this.renderQuestion(document.getElementById('content'));
},
renderResult: function(el) {
var s = this.state;
var total = s.questions.length;
var pct = Math.round((s.correct / total) * 100);
API.post('/quiz/submit', { deck: s.deck, total: total, correct: s.correct }).catch(function() {});
var color = pct >= 70 ? 'var(--green)' : pct >= 40 ? 'var(--orange)' : 'var(--red)';
var h = '<div style="text-align:center;padding:48px 0;"><h2>Quiz abgeschlossen!</h2><br>';
h += '<p style="font-size:48px;font-weight:bold;color:' + color + '">' + pct + '%</p>';
h += '<p style="color:var(--text-secondary);font-size:18px;">' + s.correct + ' von ' + total + ' richtig</p><br>';
h += '<a href="#/quiz" class="btn btn-primary">Neues Quiz</a></div>';
el.innerHTML = h;
}
};