feat: dashboard + tutorials modules - stats tiles, tutorial list, reader, completion
Dieser Commit ist enthalten in:
Ursprung
2c34aea901
Commit
03778e4bed
4 geänderte Dateien mit 197 neuen und 0 gelöschten Zeilen
40
edu/api/dashboard.php
Normale Datei
40
edu/api/dashboard.php
Normale Datei
|
|
@ -0,0 +1,40 @@
|
||||||
|
<?php
|
||||||
|
$user_id = require_auth();
|
||||||
|
if (get_method() !== 'GET') json_error('Methode nicht erlaubt', 405);
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("SELECT COUNT(*) FROM card_progress WHERE user_id = :uid AND last_reviewed_at IS NOT NULL");
|
||||||
|
$stmt->execute([':uid' => $user_id]);
|
||||||
|
$cards_learned = (int) $stmt->fetchColumn();
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("SELECT COUNT(*) FROM card_progress WHERE user_id = :uid AND next_review <= CURRENT_DATE");
|
||||||
|
$stmt->execute([':uid' => $user_id]);
|
||||||
|
$cards_due = (int) $stmt->fetchColumn();
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
SELECT COUNT(*) FILTER (WHERE last_rating IN ('easy','medium')) AS correct, COUNT(*) AS total
|
||||||
|
FROM card_progress WHERE user_id = :uid AND last_reviewed_at IS NOT NULL
|
||||||
|
");
|
||||||
|
$stmt->execute([':uid' => $user_id]);
|
||||||
|
$acc = $stmt->fetch();
|
||||||
|
$correct_pct = ($acc['total'] > 0) ? round(($acc['correct'] / $acc['total']) * 100) : 0;
|
||||||
|
|
||||||
|
$total_cards = (int) $pdo->query("SELECT COUNT(*) FROM cards")->fetchColumn();
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("SELECT COUNT(*) FROM tutorial_progress WHERE user_id = :uid AND completed = true");
|
||||||
|
$stmt->execute([':uid' => $user_id]);
|
||||||
|
$tutorials_done = (int) $stmt->fetchColumn();
|
||||||
|
$tutorials_total = (int) $pdo->query("SELECT COUNT(*) FROM tutorials")->fetchColumn();
|
||||||
|
|
||||||
|
$deck_count = (int) $pdo->query("SELECT COUNT(*) FROM decks")->fetchColumn();
|
||||||
|
$cheatsheet_count = (int) $pdo->query("SELECT COUNT(*) FROM cheatsheets")->fetchColumn();
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("SELECT score_percent FROM quiz_results WHERE user_id = :uid ORDER BY created_at DESC LIMIT 1");
|
||||||
|
$stmt->execute([':uid' => $user_id]);
|
||||||
|
$last_quiz = $stmt->fetchColumn();
|
||||||
|
|
||||||
|
json_ok([
|
||||||
|
'cards_learned' => $cards_learned, 'cards_due' => $cards_due, 'cards_total' => $total_cards,
|
||||||
|
'correct_pct' => $correct_pct, 'tutorials_done' => $tutorials_done, 'tutorials_total' => $tutorials_total,
|
||||||
|
'deck_count' => $deck_count, 'cheatsheet_count' => $cheatsheet_count,
|
||||||
|
'last_quiz_score' => $last_quiz !== false ? (int) $last_quiz : null
|
||||||
|
]);
|
||||||
47
edu/api/tutorials.php
Normale Datei
47
edu/api/tutorials.php
Normale Datei
|
|
@ -0,0 +1,47 @@
|
||||||
|
<?php
|
||||||
|
$user_id = require_auth();
|
||||||
|
|
||||||
|
// GET /api/tutorials
|
||||||
|
if (get_method() === 'GET' && empty($segments[1])) {
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
SELECT t.id, t.slug, t.title, t.description, t.duration_min, t.sort_order,
|
||||||
|
COALESCE(tp.completed, false) AS completed, tp.completed_at
|
||||||
|
FROM tutorials t
|
||||||
|
LEFT JOIN tutorial_progress tp ON tp.tutorial_id = t.id AND tp.user_id = :uid
|
||||||
|
ORDER BY t.sort_order
|
||||||
|
");
|
||||||
|
$stmt->execute([':uid' => $user_id]);
|
||||||
|
json_ok(['tutorials' => $stmt->fetchAll()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/tutorials/{slug}
|
||||||
|
if (get_method() === 'GET' && !empty($segments[1]) && ($segments[2] ?? '') !== 'complete') {
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
SELECT t.*, COALESCE(tp.completed, false) AS completed
|
||||||
|
FROM tutorials t
|
||||||
|
LEFT JOIN tutorial_progress tp ON tp.tutorial_id = t.id AND tp.user_id = :uid
|
||||||
|
WHERE t.slug = :slug
|
||||||
|
");
|
||||||
|
$stmt->execute([':slug' => $segments[1], ':uid' => $user_id]);
|
||||||
|
$tutorial = $stmt->fetch();
|
||||||
|
if (!$tutorial) json_error('Tutorial nicht gefunden', 404);
|
||||||
|
json_ok(['tutorial' => $tutorial]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/tutorials/{slug}/complete
|
||||||
|
if (get_method() === 'POST' && !empty($segments[1]) && ($segments[2] ?? '') === 'complete') {
|
||||||
|
$stmt = $pdo->prepare("SELECT id FROM tutorials WHERE slug = :slug");
|
||||||
|
$stmt->execute([':slug' => $segments[1]]);
|
||||||
|
$tutorial = $stmt->fetch();
|
||||||
|
if (!$tutorial) json_error('Tutorial nicht gefunden', 404);
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
INSERT INTO tutorial_progress (user_id, tutorial_id, completed, completed_at)
|
||||||
|
VALUES (:uid, :tid, true, NOW())
|
||||||
|
ON CONFLICT (user_id, tutorial_id) DO UPDATE SET completed = true, completed_at = NOW()
|
||||||
|
");
|
||||||
|
$stmt->execute([':uid' => $user_id, ':tid' => $tutorial['id']]);
|
||||||
|
json_ok(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
json_error('Unbekannter Tutorials-Endpunkt', 404);
|
||||||
50
edu/js/dashboard.js
Normale Datei
50
edu/js/dashboard.js
Normale Datei
|
|
@ -0,0 +1,50 @@
|
||||||
|
'use strict';
|
||||||
|
// NOTE: innerHTML safe - only own API data (numbers, names)
|
||||||
|
|
||||||
|
var Dashboard = {
|
||||||
|
render: function() {
|
||||||
|
var el = document.getElementById('content');
|
||||||
|
el.textContent = 'Lade Dashboard...';
|
||||||
|
|
||||||
|
API.get('/dashboard').then(function(d) {
|
||||||
|
var cardPct = d.cards_total > 0 ? Math.round((d.cards_learned / d.cards_total) * 100) : 0;
|
||||||
|
var tutPct = d.tutorials_total > 0 ? Math.round((d.tutorials_done / d.tutorials_total) * 100) : 0;
|
||||||
|
var dueText = d.cards_due > 0
|
||||||
|
? '<strong style="color:var(--orange)">' + d.cards_due + ' Karten faellig heute</strong>'
|
||||||
|
: 'Keine faelligen Karten — gut gemacht!';
|
||||||
|
|
||||||
|
var h = '<div class="page-header"><h1>Willkommen, ' + Markdown.esc(App.currentUser.display_name) + '!</h1>';
|
||||||
|
h += '<p>' + dueText + '</p></div>';
|
||||||
|
h += '<div class="module-grid">';
|
||||||
|
h += '<div class="module-tile" data-href="#/flashcards"><div class="tile-icon">📚</div>';
|
||||||
|
h += '<div class="tile-title">Flashcards</div>';
|
||||||
|
h += '<div class="tile-info">' + d.cards_total + ' Karten in ' + d.deck_count + ' Decks</div>';
|
||||||
|
h += '<div class="progress-bar"><div class="fill fill-green" style="width:' + cardPct + '%"></div></div>';
|
||||||
|
h += '<div class="tile-info" style="margin-top:4px">' + cardPct + '% gelernt';
|
||||||
|
if (d.cards_due > 0) h += ' — ' + d.cards_due + ' faellig';
|
||||||
|
h += '</div></div>';
|
||||||
|
h += '<div class="module-tile" data-href="#/tutorials"><div class="tile-icon">📖</div>';
|
||||||
|
h += '<div class="tile-title">Tutorials</div>';
|
||||||
|
h += '<div class="tile-info">' + d.tutorials_total + ' Lektionen</div>';
|
||||||
|
h += '<div class="progress-bar"><div class="fill fill-green" style="width:' + tutPct + '%"></div></div>';
|
||||||
|
h += '<div class="tile-info" style="margin-top:4px">' + d.tutorials_done + '/' + d.tutorials_total + ' abgeschlossen</div></div>';
|
||||||
|
h += '<div class="module-tile" data-href="#/quiz"><div class="tile-icon">❓</div>';
|
||||||
|
h += '<div class="tile-title">Quiz</div><div class="tile-info">Wissen testen</div>';
|
||||||
|
if (d.last_quiz_score !== null) {
|
||||||
|
h += '<div class="tile-info" style="margin-top:12px">Letzte Note: <strong>' + d.last_quiz_score + '%</strong></div>';
|
||||||
|
} else {
|
||||||
|
h += '<div class="tile-info" style="margin-top:12px;color:var(--text-muted)">Noch kein Quiz</div>';
|
||||||
|
}
|
||||||
|
h += '</div>';
|
||||||
|
h += '<div class="module-tile" data-href="#/cheatsheets"><div class="tile-icon">📄</div>';
|
||||||
|
h += '<div class="tile-title">Cheat Sheets</div>';
|
||||||
|
h += '<div class="tile-info">' + d.cheatsheet_count + ' Referenzkarten</div>';
|
||||||
|
h += '<div class="tile-info" style="margin-top:12px;color:var(--text-muted)">Schnellreferenz</div></div>';
|
||||||
|
h += '</div>';
|
||||||
|
el.innerHTML = h;
|
||||||
|
el.querySelectorAll('.module-tile').forEach(function(tile) {
|
||||||
|
tile.addEventListener('click', function() { location.hash = tile.dataset.href; });
|
||||||
|
});
|
||||||
|
}).catch(function(e) { el.textContent = e.message; });
|
||||||
|
}
|
||||||
|
};
|
||||||
60
edu/js/tutorials.js
Normale Datei
60
edu/js/tutorials.js
Normale Datei
|
|
@ -0,0 +1,60 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var Tutorials = {
|
||||||
|
render: function(params) {
|
||||||
|
var el = document.getElementById('content');
|
||||||
|
if (params[0]) { this.renderSingle(el, params[0]); return; }
|
||||||
|
el.textContent = 'Lade Tutorials...';
|
||||||
|
var self = this;
|
||||||
|
API.get('/tutorials').then(function(data) { self.renderList(el, data.tutorials); })
|
||||||
|
.catch(function(e) { el.textContent = e.message; });
|
||||||
|
},
|
||||||
|
|
||||||
|
renderList: function(el, tutorials) {
|
||||||
|
var done = tutorials.filter(function(t) { return t.completed; }).length;
|
||||||
|
var h = '<div class="page-header"><h1>Tutorials</h1><p>' + done + '/' + tutorials.length + ' abgeschlossen</p></div>';
|
||||||
|
h += '<div class="tutorial-list">';
|
||||||
|
tutorials.forEach(function(t, i) {
|
||||||
|
h += '<div class="tutorial-item" data-slug="' + t.slug + '">';
|
||||||
|
h += '<div class="tut-number">' + String(i + 1).padStart(2, '0') + '</div><div>';
|
||||||
|
h += '<div class="tut-title">' + Markdown.esc(t.title) + '</div>';
|
||||||
|
h += '<div class="tut-info">' + (t.duration_min ? 'ca. ' + t.duration_min + ' Min' : '');
|
||||||
|
if (t.description) h += ' — ' + Markdown.esc(t.description);
|
||||||
|
h += '</div></div>';
|
||||||
|
if (t.completed) h += '<div class="tut-check">✓</div>';
|
||||||
|
h += '</div>';
|
||||||
|
});
|
||||||
|
h += '</div>';
|
||||||
|
el.innerHTML = h;
|
||||||
|
el.querySelectorAll('.tutorial-item').forEach(function(item) {
|
||||||
|
item.addEventListener('click', function() { location.hash = '#/tutorials/' + item.dataset.slug; });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
renderSingle: function(el, slug) {
|
||||||
|
el.textContent = 'Lade Tutorial...';
|
||||||
|
var self = this;
|
||||||
|
API.get('/tutorials/' + slug).then(function(data) {
|
||||||
|
var t = data.tutorial;
|
||||||
|
var h = '<a href="#/tutorials" class="back-link">← Zurueck zu Tutorials</a>';
|
||||||
|
h += '<div class="tutorial-content">' + Markdown.render(t.content_md) + '</div><br>';
|
||||||
|
h += '<div style="text-align:center;">';
|
||||||
|
if (t.completed) {
|
||||||
|
h += '<p style="color:var(--green)">✓ Abgeschlossen</p>';
|
||||||
|
} else {
|
||||||
|
h += '<button class="btn btn-green" id="complete-btn">Als abgeschlossen markieren</button>';
|
||||||
|
}
|
||||||
|
h += '</div>';
|
||||||
|
el.innerHTML = h;
|
||||||
|
var btn = document.getElementById('complete-btn');
|
||||||
|
if (btn) btn.addEventListener('click', function() { self.complete(slug); });
|
||||||
|
}).catch(function(e) { el.textContent = e.message; });
|
||||||
|
},
|
||||||
|
|
||||||
|
complete: function(slug) {
|
||||||
|
var self = this;
|
||||||
|
API.post('/tutorials/' + slug + '/complete').then(function() {
|
||||||
|
self.renderSingle(document.getElementById('content'), slug);
|
||||||
|
}).catch(function(e) { alert('Fehler: ' + e.message); });
|
||||||
|
}
|
||||||
|
};
|
||||||
Laden …
Tabelle hinzufügen
Einen Link hinzufügen
In neuem Issue referenzieren