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 += '
| ID | Benutzer | Name | Admin | Erstellt | |
';
+ users.forEach(function(u) {
+ h += '| ' + u.id + ' | ' + Markdown.esc(u.username) + ' | ' + Markdown.esc(u.display_name) + ' | ';
+ h += '' + (u.is_admin ? 'Ja' : 'Nein') + ' | ' + new Date(u.created_at).toLocaleDateString('de-DE') + ' | ';
+ h += '' + (u.id !== App.currentUser.id ? '' : '') + ' |
';
+ });
+ h += '
';
+ h += '
Neuer Benutzer
';
+ 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
| Datum | Deck | Score | Richtig |
';
+ history.forEach(function(r) {
+ h += '| ' + new Date(r.created_at).toLocaleDateString('de-DE') + ' | ' + (r.deck_slug || 'Alle') + ' | ' + r.score_percent + '% | ' + r.correct_answers + '/' + r.total_questions + ' |
';
+ });
+ h += '
';
+ }
+ 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 += '';
+ 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;
+ }
+};