From 3cd865ccbcc87c164b9cb4b047c3f07bc47d4756 Mon Sep 17 00:00:00 2001 From: hafroese Date: Thu, 2 Apr 2026 23:41:19 +0200 Subject: [PATCH] feat: quiz, cheat sheets, admin modules - MC quiz, search, user mgmt, content import --- edu/api/admin.php | 51 +++++++++++++++++++ edu/api/cheatsheets.php | 28 +++++++++++ edu/api/quiz.php | 77 ++++++++++++++++++++++++++++ edu/js/admin.js | 86 +++++++++++++++++++++++++++++++ edu/js/cheatsheets.js | 74 +++++++++++++++++++++++++++ edu/js/quiz.js | 109 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 425 insertions(+) create mode 100644 edu/api/admin.php create mode 100644 edu/api/cheatsheets.php create mode 100644 edu/api/quiz.php create mode 100644 edu/js/admin.js create mode 100644 edu/js/cheatsheets.js create mode 100644 edu/js/quiz.js diff --git a/edu/api/admin.php b/edu/api/admin.php new file mode 100644 index 0000000..f8cc793 --- /dev/null +++ b/edu/api/admin.php @@ -0,0 +1,51 @@ +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); diff --git a/edu/api/cheatsheets.php b/edu/api/cheatsheets.php new file mode 100644 index 0000000..d2c1147 --- /dev/null +++ b/edu/api/cheatsheets.php @@ -0,0 +1,28 @@ +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); diff --git a/edu/api/quiz.php b/edu/api/quiz.php new file mode 100644 index 0000000..6e7f1f0 --- /dev/null +++ b/edu/api/quiz.php @@ -0,0 +1,77 @@ +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); diff --git a/edu/js/admin.js b/edu/js/admin.js new file mode 100644 index 0000000..699c578 --- /dev/null +++ b/edu/js/admin.js @@ -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 = ''; + h += '

Benutzer

'; + h += ''; + users.forEach(function(u) { + h += ''; + h += ''; + h += ''; + }); + h += '
IDBenutzerNameAdminErstellt
' + u.id + '' + Markdown.esc(u.username) + '' + Markdown.esc(u.display_name) + '' + (u.is_admin ? 'Ja' : 'Nein') + '' + new Date(u.created_at).toLocaleDateString('de-DE') + '' + (u.id !== App.currentUser.id ? '' : '') + '
'; + h += '

Neuer Benutzer

'; + h += '
'; + h += '

'; + h += '

'; + h += '

'; + h += '
'; + h += '
'; + h += '
'; + h += '

Content

'; + h += '

Markdown-Dateien neu einlesen und Datenbank aktualisieren.

'; + h += ''; + h += '
'; + 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)'; + }); + } +}; diff --git a/edu/js/cheatsheets.js b/edu/js/cheatsheets.js new file mode 100644 index 0000000..1844ce7 --- /dev/null +++ b/edu/js/cheatsheets.js @@ -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 = ''; + h += ''; + h += '
'; + Object.keys(groups).forEach(function(cat) { + h += '

' + Markdown.esc(cat) + '

'; + groups[cat].forEach(function(s) { + h += '
' + Markdown.esc(s.title) + '
'; + h += '
' + Markdown.esc(s.category) + '
'; + }); + h += '
'; + }); + h += '
'; + 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 = '
'; + data.cheatsheets.forEach(function(s) { + lh += '
' + Markdown.esc(s.title) + '
' + Markdown.esc(s.category) + '
'; + }); + lh += '
'; + if (data.cheatsheets.length === 0) lh = '
Keine Treffer
'; + 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 = '← Zurueck zu Cheat Sheets'; + h += '
' + Markdown.render(s.content_md) + '
'; + el.innerHTML = h; + }).catch(function(e) { el.textContent = e.message; }); + } +}; diff --git a/edu/js/quiz.js b/edu/js/quiz.js new file mode 100644 index 0000000..cf35747 --- /dev/null +++ b/edu/js/quiz.js @@ -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 = ''; + h += '

Deck waehlen

'; + h += '
Alle Decks
Querbeet aus allen Themen
'; + decks.forEach(function(d) { + h += '
' + Markdown.esc(d.name) + '
' + d.card_count + ' Karten
'; + }); + h += '
'; + if (history.length > 0) { + h += '

Letzte Ergebnisse

'; + history.forEach(function(r) { + h += ''; + }); + h += '
DatumDeckScoreRichtig
' + new Date(r.created_at).toLocaleDateString('de-DE') + '' + (r.deck_slug || 'Alle') + '' + r.score_percent + '%' + r.correct_answers + '/' + r.total_questions + '
'; + } + 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 = '← Abbrechen'; + h += '
' + Markdown.esc(q.deck_name || 'Quiz') + ''; + h += 'Frage ' + (s.index + 1) + ' / ' + s.questions.length + '
'; + h += '
'; + h += '
' + Markdown.render(q.question) + '
'; + 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 += ''; + }); + h += '
'; + if (s.answered) { + h += '
Erklaerung
' + Markdown.render(q.full_answer) + '
'; + h += '
'; + } + h += '
'; + 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 = '

Quiz abgeschlossen!


'; + h += '

' + pct + '%

'; + h += '

' + s.correct + ' von ' + total + ' richtig


'; + h += 'Neues Quiz
'; + el.innerHTML = h; + } +};