feat: flashcards module - deck list, study mode, SM-2 review, session summary
Dieser Commit ist enthalten in:
Ursprung
6a504254b0
Commit
2c34aea901
3 geänderte Dateien mit 279 neuen und 0 gelöschten Zeilen
92
edu/api/cards.php
Normale Datei
92
edu/api/cards.php
Normale Datei
|
|
@ -0,0 +1,92 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/sm2.php';
|
||||
$user_id = require_auth();
|
||||
|
||||
// GET /api/cards/deck/{slug}
|
||||
if (get_method() === 'GET' && ($segments[1] ?? '') === 'deck' && !empty($segments[2])) {
|
||||
$slug = $segments[2];
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT c.id, c.question, c.answer, c.level, c.sort_order,
|
||||
cp.next_review, cp.last_rating, cp.last_reviewed_at,
|
||||
cp.ease_factor, cp.interval_days, cp.repetitions,
|
||||
d.name AS deck_name, d.slug AS deck_slug
|
||||
FROM cards c
|
||||
JOIN decks d ON d.id = c.deck_id AND d.slug = :slug
|
||||
LEFT JOIN card_progress cp ON cp.card_id = c.id AND cp.user_id = :uid
|
||||
ORDER BY c.sort_order
|
||||
");
|
||||
$stmt->execute([':slug' => $slug, ':uid' => $user_id]);
|
||||
json_ok(['cards' => $stmt->fetchAll()]);
|
||||
}
|
||||
|
||||
// GET /api/cards/due?deck={slug}&limit=N
|
||||
if (get_method() === 'GET' && ($segments[1] ?? '') === 'due') {
|
||||
$slug = get_param('deck', '');
|
||||
$limit = max(1, min(50, (int) get_param('limit', 20)));
|
||||
|
||||
$query = "
|
||||
SELECT c.id, c.question, c.answer, c.level, c.sort_order, c.deck_id,
|
||||
d.slug AS deck_slug, d.name AS deck_name,
|
||||
cp.next_review, cp.last_rating, cp.last_reviewed_at,
|
||||
cp.ease_factor, cp.interval_days, cp.repetitions
|
||||
FROM cards c
|
||||
JOIN decks d ON d.id = c.deck_id
|
||||
LEFT JOIN card_progress cp ON cp.card_id = c.id AND cp.user_id = :uid
|
||||
WHERE (cp.next_review IS NULL OR cp.next_review <= CURRENT_DATE)
|
||||
";
|
||||
$params = [':uid' => $user_id];
|
||||
|
||||
if ($slug) {
|
||||
$query .= " AND d.slug = :slug";
|
||||
$params[':slug'] = $slug;
|
||||
}
|
||||
|
||||
$query .= " ORDER BY cp.next_review ASC NULLS FIRST, c.sort_order LIMIT " . $limit;
|
||||
|
||||
$stmt = $pdo->prepare($query);
|
||||
$stmt->execute($params);
|
||||
json_ok(['cards' => $stmt->fetchAll()]);
|
||||
}
|
||||
|
||||
// POST /api/cards/{id}/review
|
||||
if (get_method() === 'POST' && is_numeric($segments[1] ?? '') && ($segments[2] ?? '') === 'review') {
|
||||
$card_id = (int) $segments[1];
|
||||
$body = get_json_body();
|
||||
$rating = $body['rating'] ?? '';
|
||||
|
||||
if (!in_array($rating, ['easy', 'medium', 'hard', 'wrong'])) {
|
||||
json_error('Ungueltige Bewertung. Erlaubt: easy, medium, hard, wrong');
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("SELECT id FROM cards WHERE id = :id");
|
||||
$stmt->execute([':id' => $card_id]);
|
||||
if (!$stmt->fetch()) json_error('Karte nicht gefunden', 404);
|
||||
|
||||
$stmt = $pdo->prepare("SELECT * FROM card_progress WHERE user_id = :uid AND card_id = :cid");
|
||||
$stmt->execute([':uid' => $user_id, ':cid' => $card_id]);
|
||||
$progress = $stmt->fetch();
|
||||
|
||||
$ease = $progress['ease_factor'] ?? 2.5;
|
||||
$interval = $progress['interval_days'] ?? 0;
|
||||
$reps = $progress['repetitions'] ?? 0;
|
||||
|
||||
$result = sm2_calculate($rating, $ease, $interval, $reps);
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
INSERT INTO card_progress (user_id, card_id, ease_factor, interval_days, repetitions, next_review, last_rating, last_reviewed_at)
|
||||
VALUES (:uid, :cid, :ease, :intv, :reps, :next, :rating, NOW())
|
||||
ON CONFLICT (user_id, card_id) DO UPDATE SET
|
||||
ease_factor = :ease, interval_days = :intv, repetitions = :reps,
|
||||
next_review = :next, last_rating = :rating, last_reviewed_at = NOW()
|
||||
");
|
||||
$stmt->execute([
|
||||
':uid' => $user_id, ':cid' => $card_id,
|
||||
':ease' => $result['ease_factor'], ':intv' => $result['interval_days'],
|
||||
':reps' => $result['repetitions'], ':next' => $result['next_review'],
|
||||
':rating' => $rating
|
||||
]);
|
||||
|
||||
json_ok(['progress' => $result]);
|
||||
}
|
||||
|
||||
json_error('Unbekannter Cards-Endpunkt', 404);
|
||||
30
edu/api/decks.php
Normale Datei
30
edu/api/decks.php
Normale Datei
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
$user_id = require_auth();
|
||||
|
||||
// GET /api/decks
|
||||
if (get_method() === 'GET' && empty($segments[1])) {
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT d.id, d.slug, d.name, d.description, d.card_count,
|
||||
COUNT(cp.id) AS reviewed,
|
||||
COUNT(cp.id) FILTER (WHERE cp.last_rating IN ('easy','medium')) AS correct,
|
||||
COUNT(cp.id) FILTER (WHERE cp.next_review <= CURRENT_DATE) AS due
|
||||
FROM decks d
|
||||
LEFT JOIN cards c ON c.deck_id = d.id
|
||||
LEFT JOIN card_progress cp ON cp.card_id = c.id AND cp.user_id = :uid
|
||||
GROUP BY d.id
|
||||
ORDER BY d.sort_order, d.name
|
||||
");
|
||||
$stmt->execute([':uid' => $user_id]);
|
||||
json_ok(['decks' => $stmt->fetchAll()]);
|
||||
}
|
||||
|
||||
// GET /api/decks/{slug}
|
||||
if (get_method() === 'GET' && !empty($segments[1])) {
|
||||
$stmt = $pdo->prepare("SELECT * FROM decks WHERE slug = :s");
|
||||
$stmt->execute([':s' => $segments[1]]);
|
||||
$deck = $stmt->fetch();
|
||||
if (!$deck) json_error('Deck nicht gefunden', 404);
|
||||
json_ok(['deck' => $deck]);
|
||||
}
|
||||
|
||||
json_error('Methode nicht erlaubt', 405);
|
||||
157
edu/js/flashcards.js
Normale Datei
157
edu/js/flashcards.js
Normale Datei
|
|
@ -0,0 +1,157 @@
|
|||
'use strict';
|
||||
// NOTE: innerHTML usage is intentional - content comes from our own API/DB only (admin-imported Markdown).
|
||||
// This is an internal training tool for 2-5 known users. No user-generated content.
|
||||
|
||||
var Flashcards = {
|
||||
session: null,
|
||||
|
||||
render: function(params) {
|
||||
var el = document.getElementById('content');
|
||||
|
||||
if (params[0] === 'study' && params[1]) {
|
||||
this.renderStudy(el, params[1]);
|
||||
return;
|
||||
}
|
||||
|
||||
el.textContent = 'Lade Decks...';
|
||||
var self = this;
|
||||
API.get('/decks').then(function(data) {
|
||||
self.renderDeckList(el, data.decks);
|
||||
}).catch(function(e) {
|
||||
el.textContent = e.message;
|
||||
});
|
||||
},
|
||||
|
||||
renderDeckList: function(el, decks) {
|
||||
var total = decks.reduce(function(s, d) { return s + (d.card_count || 0); }, 0);
|
||||
var totalDue = decks.reduce(function(s, d) { return s + (parseInt(d.due) || 0); }, 0);
|
||||
|
||||
var h = '<div class="page-header"><h1>Flashcards</h1>';
|
||||
h += '<p>' + total + ' Karten in ' + decks.length + ' Decks';
|
||||
if (totalDue > 0) h += ' — <strong style="color:var(--orange)">' + totalDue + ' faellig</strong>';
|
||||
h += '</p></div><div class="deck-grid">';
|
||||
|
||||
decks.forEach(function(d) {
|
||||
var reviewed = parseInt(d.reviewed) || 0;
|
||||
var ct = d.card_count || 0;
|
||||
var due = parseInt(d.due) || 0;
|
||||
var pct = ct > 0 ? Math.round((reviewed / ct) * 100) : 0;
|
||||
|
||||
h += '<div class="deck-card" data-slug="' + d.slug + '">';
|
||||
h += '<div class="deck-name">' + Markdown.esc(d.name) + '</div>';
|
||||
h += '<div class="deck-info">' + ct + ' Karten — ' + pct + '% gelernt</div>';
|
||||
if (due > 0) h += '<div class="deck-due">' + due + ' Karten faellig</div>';
|
||||
h += '<div class="progress-bar"><div class="fill fill-green" style="width:' + pct + '%"></div></div>';
|
||||
h += '</div>';
|
||||
});
|
||||
|
||||
h += '</div>';
|
||||
el.innerHTML = h; // safe: data from own API only
|
||||
el.querySelectorAll('.deck-card').forEach(function(card) {
|
||||
card.addEventListener('click', function() {
|
||||
location.hash = '#/flashcards/study/' + card.dataset.slug;
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
renderStudy: function(el, slug) {
|
||||
el.textContent = 'Lade Karten...';
|
||||
var self = this;
|
||||
API.get('/cards/due?deck=' + slug + '&limit=50').then(function(data) {
|
||||
if (data.cards.length === 0) {
|
||||
return API.get('/cards/deck/' + slug).then(function(allData) {
|
||||
var h = '<a href="#/flashcards" class="back-link">← Zurueck zu Decks</a>';
|
||||
h += '<div class="empty-state"><h2>Keine faelligen Karten</h2>';
|
||||
h += '<p>Alle Karten sind gelernt. Komm spaeter wieder!</p><br>';
|
||||
h += '<button class="btn btn-primary" id="start-all-btn">Alle ' + allData.cards.length + ' Karten ueben</button></div>';
|
||||
el.innerHTML = h; // safe: static text + number
|
||||
document.getElementById('start-all-btn').addEventListener('click', function() {
|
||||
self.startSession(el, slug, allData.cards);
|
||||
});
|
||||
});
|
||||
}
|
||||
self.startSession(el, slug, data.cards);
|
||||
}).catch(function(e) { el.textContent = e.message; });
|
||||
},
|
||||
|
||||
startSession: function(el, slug, cards) {
|
||||
this.session = { slug: slug, cards: cards, index: 0, correct: 0, wrong: 0, showAnswer: false };
|
||||
this.renderCard(el);
|
||||
},
|
||||
|
||||
renderCard: function(el) {
|
||||
var s = this.session;
|
||||
if (s.index >= s.cards.length) { this.renderSummary(el); return; }
|
||||
|
||||
var card = s.cards[s.index];
|
||||
var open = s.cards.length - s.index - 1;
|
||||
var deckName = card.deck_name || s.slug;
|
||||
var lastReview = card.last_reviewed_at
|
||||
? 'Zuletzt: ' + new Date(card.last_reviewed_at).toLocaleDateString('de-DE')
|
||||
: 'Noch nie gelernt';
|
||||
|
||||
var h = '<a href="#/flashcards" class="back-link">← Zurueck zu Decks</a>';
|
||||
h += '<div class="study-header"><span class="study-deck-name">' + Markdown.esc(deckName) + '</span>';
|
||||
h += '<div class="study-stats"><span class="stat-correct">' + s.correct + ' richtig</span>';
|
||||
h += '<span class="stat-wrong">' + s.wrong + ' falsch</span>';
|
||||
h += '<span class="stat-open">' + open + ' offen</span></div>';
|
||||
h += '<span class="study-counter">Karte ' + (s.index + 1) + ' / ' + s.cards.length + '</span></div>';
|
||||
h += '<div class="progress-segmented"><div class="seg-correct" style="flex:' + s.correct + '"></div>';
|
||||
h += '<div class="seg-wrong" style="flex:' + s.wrong + '"></div>';
|
||||
h += '<div class="seg-open" style="flex:' + (open + 1) + '"></div></div><br>';
|
||||
h += '<div class="flashcard"><div class="card-meta"><span class="card-level">' + (card.level || 'basis') + '</span>';
|
||||
h += '<span class="card-last-review">' + lastReview + '</span></div>';
|
||||
h += '<div class="card-question">' + Markdown.render(card.question) + '</div>';
|
||||
|
||||
if (s.showAnswer) {
|
||||
h += '<div class="card-answer"><div class="card-answer-label">Antwort</div>' + Markdown.render(card.answer) + '</div>';
|
||||
h += '<div class="rating-buttons">';
|
||||
h += '<button class="btn btn-green" id="rate-easy">Leicht</button>';
|
||||
h += '<button class="btn btn-orange" id="rate-medium">Mittel</button>';
|
||||
h += '<button class="btn btn-red" id="rate-hard">Schwer</button>';
|
||||
h += '<button class="btn btn-muted" id="rate-wrong">Falsch</button></div>';
|
||||
} else {
|
||||
h += '<div class="card-answer-hidden"><button class="btn btn-primary" id="show-answer-btn">Antwort zeigen</button></div>';
|
||||
}
|
||||
h += '</div>';
|
||||
el.innerHTML = h; // safe: Markdown content from own DB
|
||||
|
||||
var self = this;
|
||||
if (s.showAnswer) {
|
||||
document.getElementById('rate-easy').addEventListener('click', function() { self.rate('easy'); });
|
||||
document.getElementById('rate-medium').addEventListener('click', function() { self.rate('medium'); });
|
||||
document.getElementById('rate-hard').addEventListener('click', function() { self.rate('hard'); });
|
||||
document.getElementById('rate-wrong').addEventListener('click', function() { self.rate('wrong'); });
|
||||
} else {
|
||||
document.getElementById('show-answer-btn').addEventListener('click', function() {
|
||||
s.showAnswer = true;
|
||||
self.renderCard(el);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
rate: function(rating) {
|
||||
var s = this.session;
|
||||
var card = s.cards[s.index];
|
||||
if (rating === 'easy' || rating === 'medium') s.correct++;
|
||||
else s.wrong++;
|
||||
API.post('/cards/' + card.id + '/review', { rating: rating }).catch(function(e) { console.error('Review error:', e); });
|
||||
s.index++;
|
||||
s.showAnswer = false;
|
||||
this.renderCard(document.getElementById('content'));
|
||||
},
|
||||
|
||||
renderSummary: function(el) {
|
||||
var s = this.session;
|
||||
var total = s.correct + s.wrong;
|
||||
var pct = total > 0 ? Math.round((s.correct / total) * 100) : 0;
|
||||
var h = '<a href="#/flashcards" class="back-link">← Zurueck zu Decks</a>';
|
||||
h += '<div style="text-align:center;padding:48px 0;"><h2>Session abgeschlossen!</h2><br>';
|
||||
h += '<div class="study-stats" style="justify-content:center;font-size:18px;">';
|
||||
h += '<span class="stat-correct">' + s.correct + ' richtig</span>';
|
||||
h += '<span class="stat-wrong">' + s.wrong + ' falsch</span></div><br>';
|
||||
h += '<p style="color:var(--text-secondary);font-size:18px;">' + pct + '% korrekt</p><br>';
|
||||
h += '<a href="#/flashcards" class="btn btn-primary">Zurueck zu Decks</a></div>';
|
||||
el.innerHTML = h; // safe: only session stats
|
||||
}
|
||||
};
|
||||
Laden …
Tabelle hinzufügen
Einen Link hinzufügen
In neuem Issue referenzieren