Volle Mindflux-App ins Deploy-Repo übernehmen
Ersetzt das Hello-World-Gerüst durch die vollständige Anwendung (com.example.mindflux: 64 Klassen, Thymeleaf-Templates, Static-Assets, Flyway V1–V6). Deploy-Infrastruktur (.gitea, Dockerfile) bleibt erhalten. - pom: volle Abhängigkeiten (Security, Thymeleaf, JPA, Flyway), Spring Boot 3.4.4 / Java 17 - docker-compose: SPRING_DATASOURCE_* unverändert; App lauscht auf 8080 (nginx -> mindflux:8080); Uploads-Volume; Admin-Passwörter via Gitea-Secrets - Workflow: Deploy-Step injiziert MINDFLUX_ADMIN*_PASSWORD aus Secrets - .dockerignore ergänzt; Hello-World (com.mindflux) und Test-Datei 'main' entfernt Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.querySelectorAll('[data-fluxcheck]').forEach((check) => {
|
||||
const button = check.querySelector('button');
|
||||
const result = check.querySelector('.fluxcheck-result');
|
||||
|
||||
if (!button || !result) {
|
||||
return;
|
||||
}
|
||||
|
||||
button.addEventListener('click', () => {
|
||||
const selected = check.querySelector('input[type="radio"]:checked');
|
||||
if (!selected) {
|
||||
result.textContent = 'Wähle zuerst eine Antwort aus.';
|
||||
result.className = 'fluxcheck-result wrong';
|
||||
return;
|
||||
}
|
||||
|
||||
const expected = check.dataset.answer;
|
||||
const explanation = check.dataset.explanation || '';
|
||||
const isCorrect = selected.value === expected;
|
||||
|
||||
result.textContent = isCorrect
|
||||
? 'Richtig. ' + explanation
|
||||
: 'Noch nicht. ' + explanation;
|
||||
result.className = 'fluxcheck-result ' + (isCorrect ? 'correct' : 'wrong');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const script = document.querySelector('script[data-questions-src]');
|
||||
const testRoot = document.querySelector('[data-finalflux-test]');
|
||||
|
||||
if (!script || !testRoot) {
|
||||
return;
|
||||
}
|
||||
|
||||
const elements = {
|
||||
questionCounter: testRoot.querySelector('[data-question-counter]'),
|
||||
scoreCounter: testRoot.querySelector('[data-score-counter]'),
|
||||
categoryTitle: testRoot.querySelector('[data-category-title]'),
|
||||
categoryHint: testRoot.querySelector('[data-category-hint]'),
|
||||
questionText: testRoot.querySelector('[data-question-text]'),
|
||||
answerOptions: testRoot.querySelector('[data-answer-options]'),
|
||||
answerFeedback: testRoot.querySelector('[data-answer-feedback]'),
|
||||
checkAnswer: testRoot.querySelector('[data-check-answer]'),
|
||||
skipQuestion: testRoot.querySelector('[data-skip-question]'),
|
||||
nextQuestion: testRoot.querySelector('[data-next-question]'),
|
||||
testResult: document.querySelector('[data-test-result]'),
|
||||
resultSummary: document.querySelector('[data-result-summary]'),
|
||||
categorySummary: document.querySelector('[data-category-summary]'),
|
||||
restartTest: document.querySelector('[data-restart-test]')
|
||||
};
|
||||
|
||||
let questions = [];
|
||||
let currentIndex = 0;
|
||||
let score = 0;
|
||||
let answered = false;
|
||||
const categoryStats = new Map();
|
||||
|
||||
function setError(message) {
|
||||
elements.questionText.textContent = message;
|
||||
elements.answerOptions.innerHTML = '';
|
||||
elements.checkAnswer.disabled = true;
|
||||
elements.skipQuestion.disabled = true;
|
||||
elements.nextQuestion.disabled = true;
|
||||
}
|
||||
|
||||
function ensureCategory(category) {
|
||||
if (!categoryStats.has(category)) {
|
||||
categoryStats.set(category, { correct: 0, total: 0, skipped: 0 });
|
||||
}
|
||||
|
||||
return categoryStats.get(category);
|
||||
}
|
||||
|
||||
function renderQuestion() {
|
||||
const question = questions[currentIndex];
|
||||
answered = false;
|
||||
|
||||
elements.questionCounter.textContent = `Frage ${currentIndex + 1} von ${questions.length}`;
|
||||
elements.scoreCounter.textContent = `${score} richtig`;
|
||||
elements.categoryTitle.textContent = question.category;
|
||||
elements.categoryHint.textContent = question.hint || '';
|
||||
elements.questionText.textContent = question.text;
|
||||
elements.answerFeedback.textContent = '';
|
||||
elements.answerFeedback.className = 'fluxcheck-result';
|
||||
elements.nextQuestion.disabled = true;
|
||||
elements.checkAnswer.disabled = false;
|
||||
elements.skipQuestion.disabled = false;
|
||||
|
||||
elements.answerOptions.innerHTML = '';
|
||||
question.answers.forEach((answer, index) => {
|
||||
const label = document.createElement('label');
|
||||
const input = document.createElement('input');
|
||||
const text = document.createElement('span');
|
||||
|
||||
input.type = 'radio';
|
||||
input.name = 'answer';
|
||||
input.value = String(index);
|
||||
text.textContent = answer;
|
||||
|
||||
label.append(input, text);
|
||||
elements.answerOptions.append(label);
|
||||
});
|
||||
}
|
||||
|
||||
function markAnswered(isCorrect, skipped) {
|
||||
const question = questions[currentIndex];
|
||||
const stats = ensureCategory(question.category);
|
||||
stats.total += 1;
|
||||
|
||||
if (skipped) {
|
||||
stats.skipped += 1;
|
||||
}
|
||||
|
||||
if (isCorrect) {
|
||||
stats.correct += 1;
|
||||
score += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function finishIfDone() {
|
||||
if (currentIndex < questions.length - 1) {
|
||||
currentIndex += 1;
|
||||
renderQuestion();
|
||||
return;
|
||||
}
|
||||
|
||||
testRoot.hidden = true;
|
||||
elements.testResult.hidden = false;
|
||||
elements.resultSummary.textContent = `Du hast ${score} von ${questions.length} Fragen richtig beantwortet.`;
|
||||
elements.categorySummary.innerHTML = '';
|
||||
Array.from(categoryStats.entries()).forEach(([category, stats]) => {
|
||||
const skipped = stats.skipped ? `, ${stats.skipped} übersprungen` : '';
|
||||
const item = document.createElement('li');
|
||||
const categoryLabel = document.createElement('strong');
|
||||
|
||||
categoryLabel.textContent = `${category}:`;
|
||||
item.append(categoryLabel, ` ${stats.correct} von ${stats.total} richtig${skipped}`);
|
||||
elements.categorySummary.append(item);
|
||||
});
|
||||
}
|
||||
|
||||
elements.checkAnswer.addEventListener('click', () => {
|
||||
if (answered) {
|
||||
return;
|
||||
}
|
||||
|
||||
const question = questions[currentIndex];
|
||||
const selected = elements.answerOptions.querySelector('input[type="radio"]:checked');
|
||||
|
||||
if (!selected) {
|
||||
elements.answerFeedback.textContent = 'Wähle zuerst eine Antwort aus oder überspringe die Frage.';
|
||||
elements.answerFeedback.className = 'fluxcheck-result wrong';
|
||||
return;
|
||||
}
|
||||
|
||||
const isCorrect = Number(selected.value) === question.correct;
|
||||
markAnswered(isCorrect, false);
|
||||
answered = true;
|
||||
elements.answerFeedback.textContent = (isCorrect ? 'Richtig. ' : 'Noch nicht. ') + question.explanation;
|
||||
elements.answerFeedback.className = 'fluxcheck-result ' + (isCorrect ? 'correct' : 'wrong');
|
||||
elements.scoreCounter.textContent = `${score} richtig`;
|
||||
elements.nextQuestion.disabled = false;
|
||||
elements.checkAnswer.disabled = true;
|
||||
elements.skipQuestion.disabled = true;
|
||||
});
|
||||
|
||||
elements.skipQuestion.addEventListener('click', () => {
|
||||
if (!answered) {
|
||||
markAnswered(false, true);
|
||||
}
|
||||
|
||||
finishIfDone();
|
||||
});
|
||||
|
||||
elements.nextQuestion.addEventListener('click', finishIfDone);
|
||||
|
||||
elements.restartTest.addEventListener('click', () => {
|
||||
currentIndex = 0;
|
||||
score = 0;
|
||||
categoryStats.clear();
|
||||
testRoot.hidden = false;
|
||||
elements.testResult.hidden = true;
|
||||
renderQuestion();
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(script.dataset.questionsSrc);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
questions = Array.isArray(payload) ? payload : payload.questions;
|
||||
|
||||
if (!Array.isArray(questions) || questions.length === 0) {
|
||||
setError('Dieser Fragenkatalog enthält noch keine Fragen.');
|
||||
return;
|
||||
}
|
||||
|
||||
renderQuestion();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setError('Der Fragenkatalog konnte nicht geladen werden.');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,306 @@
|
||||
/* MindMatch – Lern-Memory (Begriff <-> Bedeutung).
|
||||
Decks kommen aus der DB-API (/api/mindmatch/decks), Highscore aus /api/games/mindmatch. */
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
const GAME = 'mindmatch';
|
||||
|
||||
const board = document.getElementById('mm-board');
|
||||
const deckSelect = document.getElementById('mm-deck');
|
||||
const newBtn = document.getElementById('mm-new');
|
||||
const cancelBtn = document.getElementById('mm-cancel');
|
||||
const movesEl = document.getElementById('mm-moves');
|
||||
const timeEl = document.getElementById('mm-time');
|
||||
const pairsEl = document.getElementById('mm-pairs');
|
||||
const bestEl = document.getElementById('mm-best');
|
||||
|
||||
const resultEl = document.getElementById('mm-result');
|
||||
const resultStars = document.getElementById('mm-result-stars');
|
||||
const resultScore = document.getElementById('mm-result-score');
|
||||
const resultDetail= document.getElementById('mm-result-detail');
|
||||
const resultHigh = document.getElementById('mm-result-highscore');
|
||||
const againBtn = document.getElementById('mm-again');
|
||||
|
||||
const decksBySlug = new Map(); // slug -> [{term, match}]
|
||||
|
||||
// Laufender Spielzustand
|
||||
let pairs = [];
|
||||
let totalPairs = 0;
|
||||
let firstCard = null; // erste aufgedeckte, noch nicht zugeordnete Karte
|
||||
let lock = false; // Eingabesperre während der Auswertung
|
||||
let moves = 0;
|
||||
let matched = 0;
|
||||
let seconds = 0;
|
||||
let timerId = null;
|
||||
let running = false; // läuft gerade eine Runde? (steuert den Abbrechen-Button)
|
||||
|
||||
/* Punkteformel – identisch zu GameScoreService.calculateScore (Server bleibt autoritativ). */
|
||||
function calculateScore(p, mv, secs) {
|
||||
if (p <= 0) return 0;
|
||||
const safeMoves = Math.max(mv, p);
|
||||
const safeSecs = Math.max(secs, 0);
|
||||
const accuracy = Math.round(1000 * p / safeMoves);
|
||||
const timeBonus = Math.max(0, 500 - safeSecs * 5);
|
||||
return accuracy + timeBonus;
|
||||
}
|
||||
|
||||
function starsFor(p, mv) {
|
||||
if (mv <= Math.ceil(p * 1.4)) return 3;
|
||||
if (mv <= p * 2) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
function formatTime(s) {
|
||||
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function shuffle(arr) {
|
||||
for (let i = arr.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
function startTimer() {
|
||||
if (timerId) return;
|
||||
timerId = setInterval(() => {
|
||||
seconds++;
|
||||
timeEl.textContent = formatTime(seconds);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function stopTimer() {
|
||||
clearInterval(timerId);
|
||||
timerId = null;
|
||||
}
|
||||
|
||||
function resetHud() {
|
||||
seconds = 0; moves = 0; matched = 0;
|
||||
timeEl.textContent = '0:00';
|
||||
movesEl.textContent = '0';
|
||||
pairsEl.textContent = '0 / 0';
|
||||
}
|
||||
|
||||
/* Leeres Brett mit Hinweis; räumt die per-JS gesetzten Grid-Inline-Styles ab. */
|
||||
function setIdle(message) {
|
||||
board.style.gridTemplateColumns = '';
|
||||
board.style.maxWidth = '';
|
||||
board.innerHTML = '';
|
||||
const hint = document.createElement('p');
|
||||
hint.className = 'mm-idle';
|
||||
hint.textContent = message;
|
||||
board.appendChild(hint);
|
||||
}
|
||||
|
||||
function buildCards(pairList) {
|
||||
const cards = [];
|
||||
pairList.forEach((pair, index) => {
|
||||
cards.push({ pairId: index, kind: 'term', text: pair.term });
|
||||
cards.push({ pairId: index, kind: 'def', text: pair.match });
|
||||
});
|
||||
return shuffle(cards);
|
||||
}
|
||||
|
||||
function createCard(data) {
|
||||
const card = document.createElement('button');
|
||||
card.type = 'button';
|
||||
card.className = 'mm-card';
|
||||
card.dataset.pairId = String(data.pairId);
|
||||
card.innerHTML =
|
||||
'<span class="mm-card-inner">' +
|
||||
'<span class="mm-card-face mm-card-front">⚡</span>' +
|
||||
'<span class="mm-card-face mm-card-back mm-' + data.kind + '">' +
|
||||
'<span class="mm-kind">' + (data.kind === 'term' ? 'Begriff' : 'Bedeutung') + '</span>' +
|
||||
'<span class="mm-text"></span>' +
|
||||
'</span>' +
|
||||
'</span>';
|
||||
card.querySelector('.mm-text').textContent = data.text; // sicher gegen HTML im Text
|
||||
card.addEventListener('click', () => onCardClick(card));
|
||||
return card;
|
||||
}
|
||||
|
||||
function renderBoard(cards) {
|
||||
board.innerHTML = '';
|
||||
// Spaltenzahl ~ Wurzel der Kartenzahl; die Reihenhöhe regelt das CSS (grid-auto-rows: 1fr),
|
||||
// sodass das ganze Brett ohne Scrollen in die feste Stage-Höhe passt.
|
||||
const cols = Math.ceil(Math.sqrt(cards.length));
|
||||
board.style.gridTemplateColumns = `repeat(${cols}, minmax(0, 1fr))`;
|
||||
board.style.maxWidth = (cols * 220) + 'px';
|
||||
cards.forEach((data) => board.appendChild(createCard(data)));
|
||||
}
|
||||
|
||||
function onCardClick(card) {
|
||||
if (lock) return;
|
||||
if (card.classList.contains('flipped') || card.classList.contains('matched')) return;
|
||||
|
||||
startTimer();
|
||||
card.classList.add('flipped');
|
||||
|
||||
if (!firstCard) {
|
||||
firstCard = card;
|
||||
return;
|
||||
}
|
||||
|
||||
// zweite Karte -> ein Zug
|
||||
moves++;
|
||||
movesEl.textContent = String(moves);
|
||||
|
||||
const a = firstCard;
|
||||
const b = card;
|
||||
|
||||
if (a.dataset.pairId === b.dataset.pairId) {
|
||||
a.classList.add('matched');
|
||||
b.classList.add('matched');
|
||||
firstCard = null;
|
||||
matched++;
|
||||
pairsEl.textContent = `${matched} / ${totalPairs}`;
|
||||
if (matched === totalPairs) {
|
||||
finish();
|
||||
}
|
||||
} else {
|
||||
lock = true;
|
||||
a.classList.add('wrong');
|
||||
b.classList.add('wrong');
|
||||
setTimeout(() => {
|
||||
a.classList.remove('flipped', 'wrong');
|
||||
b.classList.remove('flipped', 'wrong');
|
||||
firstCard = null;
|
||||
lock = false;
|
||||
}, 900);
|
||||
}
|
||||
}
|
||||
|
||||
async function finish() {
|
||||
stopTimer();
|
||||
running = false;
|
||||
cancelBtn.hidden = true;
|
||||
|
||||
const localScore = calculateScore(totalPairs, moves, seconds);
|
||||
const starCount = starsFor(totalPairs, moves);
|
||||
resultStars.textContent = '★'.repeat(starCount) + '☆'.repeat(3 - starCount);
|
||||
resultScore.textContent = String(localScore);
|
||||
resultDetail.textContent = `${totalPairs} Paare · ${moves} Züge · ${formatTime(seconds)}`;
|
||||
resultHigh.hidden = true;
|
||||
resultEl.hidden = false;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/games/${GAME}/scores`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
deck: deckSelect.value,
|
||||
pairs: totalPairs,
|
||||
moves: moves,
|
||||
durationSeconds: seconds
|
||||
})
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
resultScore.textContent = String(data.score); // Server ist autoritativ
|
||||
resultHigh.hidden = !data.newHighscore;
|
||||
bestEl.textContent = String(data.highscore);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Score konnte nicht gespeichert werden:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/* Laufendes Spiel abbrechen: Timer stoppen, Zustand zurücksetzen, KEIN Score-POST. */
|
||||
function cancelGame() {
|
||||
if (!running) return;
|
||||
|
||||
// Rückfrage nur, wenn schon etwas passiert ist (kein Nerv-Dialog auf frischem Brett).
|
||||
const touched = moves > 0 || matched > 0 || seconds > 0 || firstCard !== null;
|
||||
if (touched && !window.confirm(
|
||||
'Aktuelles Spiel wirklich abbrechen? Der Fortschritt geht verloren und wird nicht gespeichert.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
stopTimer();
|
||||
resetHud();
|
||||
running = false;
|
||||
firstCard = null;
|
||||
lock = false;
|
||||
resultEl.hidden = true;
|
||||
cancelBtn.hidden = true;
|
||||
setIdle('Spiel abgebrochen – wähle ein Deck und starte ein neues Spiel.');
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const res = await fetch(`/api/games/${GAME}/stats`);
|
||||
if (!res.ok) return;
|
||||
const stats = await res.json();
|
||||
bestEl.textContent = stats.highscore > 0 ? String(stats.highscore) : '–';
|
||||
} catch (err) {
|
||||
/* still ohne Highscore weiter */
|
||||
}
|
||||
}
|
||||
|
||||
function newGame() {
|
||||
const slug = deckSelect.value;
|
||||
const deckPairs = decksBySlug.get(slug);
|
||||
|
||||
stopTimer();
|
||||
running = false;
|
||||
cancelBtn.hidden = true;
|
||||
resetHud();
|
||||
resultEl.hidden = true;
|
||||
firstCard = null;
|
||||
lock = false;
|
||||
|
||||
if (!deckPairs || !deckPairs.length) {
|
||||
setIdle('Dieses Deck hat noch keine Paare.');
|
||||
return;
|
||||
}
|
||||
|
||||
pairs = deckPairs;
|
||||
totalPairs = pairs.length;
|
||||
pairsEl.textContent = `0 / ${totalPairs}`;
|
||||
renderBoard(buildCards(pairs));
|
||||
|
||||
running = true;
|
||||
cancelBtn.hidden = false;
|
||||
}
|
||||
|
||||
async function loadDecks() {
|
||||
const res = await fetch('/api/mindmatch/decks');
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function init() {
|
||||
let decks = [];
|
||||
try {
|
||||
decks = await loadDecks();
|
||||
} catch (err) {
|
||||
console.error('Decks konnten nicht geladen werden:', err);
|
||||
}
|
||||
|
||||
deckSelect.innerHTML = '';
|
||||
decksBySlug.clear();
|
||||
decks.forEach((deck) => {
|
||||
decksBySlug.set(deck.slug, deck.pairs || []);
|
||||
const option = document.createElement('option');
|
||||
option.value = deck.slug;
|
||||
option.textContent = deck.title;
|
||||
deckSelect.appendChild(option);
|
||||
});
|
||||
|
||||
newBtn.addEventListener('click', newGame);
|
||||
againBtn.addEventListener('click', newGame);
|
||||
cancelBtn.addEventListener('click', cancelGame);
|
||||
deckSelect.addEventListener('change', newGame);
|
||||
|
||||
await loadStats();
|
||||
|
||||
if (decks.length) {
|
||||
newGame();
|
||||
} else {
|
||||
setIdle('Noch keine Decks vorhanden. Ein Admin kann sie unter „MindMatch-Decks verwalten“ anlegen.');
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
})();
|
||||
@@ -0,0 +1,267 @@
|
||||
/* TechTacular – IT-Quizduell im Jeopardy-Stil.
|
||||
Board kommt aus /api/techtacular/board (ohne Lösung), Prüfung + Score serverseitig. */
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
const GAME = 'techtacular';
|
||||
|
||||
const boardEl = document.getElementById('tt-board');
|
||||
const newBtn = document.getElementById('tt-new');
|
||||
const finishBtn = document.getElementById('tt-finish');
|
||||
const cancelBtn = document.getElementById('tt-cancel');
|
||||
const scoreEl = document.getElementById('tt-score');
|
||||
const answeredEl= document.getElementById('tt-answered');
|
||||
const timeEl = document.getElementById('tt-time');
|
||||
const bestEl = document.getElementById('tt-best');
|
||||
|
||||
const qOverlay = document.getElementById('tt-question');
|
||||
const qCat = document.getElementById('tt-q-cat');
|
||||
const qPoints = document.getElementById('tt-q-points');
|
||||
const qText = document.getElementById('tt-q-text');
|
||||
const qAnswers = document.getElementById('tt-q-answers');
|
||||
const qFeedback = document.getElementById('tt-q-feedback');
|
||||
const qNext = document.getElementById('tt-q-next');
|
||||
|
||||
const resultEl = document.getElementById('tt-result');
|
||||
const resultScore = document.getElementById('tt-result-score');
|
||||
const resultMax = document.getElementById('tt-result-max');
|
||||
const resultDetail= document.getElementById('tt-result-detail');
|
||||
const resultHigh = document.getElementById('tt-result-high');
|
||||
const againBtn = document.getElementById('tt-again');
|
||||
|
||||
let board = null;
|
||||
let previousIds = []; // Frage-IDs der Vorrunde -> werden im nächsten Board vermieden
|
||||
let tiles = []; // Tile-Zustände {data, categoryTitle, answered, correct, el}
|
||||
let answeredCount = 0;
|
||||
let score = 0;
|
||||
let seconds = 0;
|
||||
let timerId = null;
|
||||
let running = false;
|
||||
let current = null; // gerade offene Kachel
|
||||
const submitted = []; // {questionId, answerId} für /finish
|
||||
|
||||
function formatTime(s) {
|
||||
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function startTimer() {
|
||||
if (timerId) return;
|
||||
timerId = setInterval(() => { seconds++; timeEl.textContent = formatTime(seconds); }, 1000);
|
||||
}
|
||||
function stopTimer() { clearInterval(timerId); timerId = null; }
|
||||
|
||||
function setIdle(message) {
|
||||
boardEl.style.gridTemplateColumns = '';
|
||||
boardEl.innerHTML = `<p class="tt-idle">${message}</p>`;
|
||||
}
|
||||
|
||||
function updateHud() {
|
||||
scoreEl.textContent = String(score);
|
||||
answeredEl.textContent = `${answeredCount} / ${tiles.length}`;
|
||||
}
|
||||
|
||||
function renderBoard() {
|
||||
boardEl.innerHTML = '';
|
||||
boardEl.style.gridTemplateColumns = `repeat(${board.categories.length}, 1fr)`;
|
||||
tiles = [];
|
||||
|
||||
board.categories.forEach((category) => {
|
||||
const col = document.createElement('div');
|
||||
col.className = 'tt-col';
|
||||
|
||||
const title = document.createElement('div');
|
||||
title.className = 'tt-cat-title';
|
||||
title.textContent = category.title;
|
||||
col.appendChild(title);
|
||||
|
||||
category.tiles.forEach((tileData) => {
|
||||
const tileEl = document.createElement('button');
|
||||
tileEl.type = 'button';
|
||||
tileEl.className = 'tt-tile';
|
||||
tileEl.textContent = String(tileData.points);
|
||||
|
||||
const state = { data: tileData, categoryTitle: category.title, answered: false, correct: false, el: tileEl };
|
||||
tileEl.addEventListener('click', () => openTile(state));
|
||||
tiles.push(state);
|
||||
col.appendChild(tileEl);
|
||||
});
|
||||
|
||||
boardEl.appendChild(col);
|
||||
});
|
||||
}
|
||||
|
||||
function openTile(state) {
|
||||
if (!running || state.answered || current) return;
|
||||
current = state;
|
||||
|
||||
qCat.textContent = state.categoryTitle;
|
||||
qPoints.textContent = String(state.data.points);
|
||||
qText.textContent = state.data.text;
|
||||
qFeedback.textContent = '';
|
||||
qFeedback.className = 'tt-q-feedback';
|
||||
qNext.hidden = true;
|
||||
|
||||
qAnswers.innerHTML = '';
|
||||
state.data.answers.forEach((answer) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'tt-answer';
|
||||
btn.textContent = `Was ist ${answer.text}?`;
|
||||
btn.addEventListener('click', () => chooseAnswer(state, answer.id, btn));
|
||||
qAnswers.appendChild(btn);
|
||||
});
|
||||
|
||||
qOverlay.hidden = false;
|
||||
}
|
||||
|
||||
async function chooseAnswer(state, answerId, btn) {
|
||||
if (state.answered) return;
|
||||
state.answered = true; // sofort sperren (eine Antwort pro Frage)
|
||||
|
||||
const buttons = Array.from(qAnswers.querySelectorAll('.tt-answer'));
|
||||
buttons.forEach((b) => { b.disabled = true; });
|
||||
|
||||
submitted.push({ questionId: state.data.questionId, answerId });
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/techtacular/answer', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ questionId: state.data.questionId, answerId })
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
state.correct = data.correct;
|
||||
// richtige Antwort grün, eigene falsche rot markieren
|
||||
state.data.answers.forEach((answer, i) => {
|
||||
if (answer.id === data.correctAnswerId) buttons[i].classList.add('correct');
|
||||
});
|
||||
if (!data.correct) btn.classList.add('wrong');
|
||||
|
||||
const correctTerm = (state.data.answers.find((a) => a.id === data.correctAnswerId) || {}).text || '';
|
||||
if (data.correct) {
|
||||
score += data.points;
|
||||
qFeedback.textContent = `Richtig! +${data.points}. ${data.explanation || ''}`.trim();
|
||||
qFeedback.className = 'tt-q-feedback correct';
|
||||
} else {
|
||||
qFeedback.textContent = `Falsch – richtig: „Was ist ${correctTerm}?“. ${data.explanation || ''}`.trim();
|
||||
qFeedback.className = 'tt-q-feedback wrong';
|
||||
}
|
||||
updateHud();
|
||||
} catch (err) {
|
||||
qFeedback.textContent = 'Antwort konnte nicht geprüft werden.';
|
||||
qFeedback.className = 'tt-q-feedback wrong';
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
qNext.hidden = false;
|
||||
}
|
||||
|
||||
function closeQuestion() {
|
||||
if (!current) return;
|
||||
const state = current;
|
||||
current = null;
|
||||
qOverlay.hidden = true;
|
||||
|
||||
state.el.classList.add('answered', state.correct ? 'correct' : 'wrong');
|
||||
state.el.textContent = state.correct ? `✓ ${state.data.points}` : '✗';
|
||||
answeredCount++;
|
||||
updateHud();
|
||||
|
||||
if (answeredCount === tiles.length) {
|
||||
finishGame();
|
||||
}
|
||||
}
|
||||
|
||||
async function finishGame() {
|
||||
if (!running) return;
|
||||
running = false;
|
||||
stopTimer();
|
||||
finishBtn.hidden = true;
|
||||
cancelBtn.hidden = true;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/techtacular/finish', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ durationSeconds: seconds, answers: submitted })
|
||||
});
|
||||
const data = await res.json();
|
||||
resultScore.textContent = String(data.score);
|
||||
resultMax.textContent = String(data.maxScore);
|
||||
resultDetail.textContent =
|
||||
`${data.correctCount} von ${data.totalCount} Fragen richtig · ${formatTime(seconds)}`;
|
||||
resultHigh.hidden = !data.newHighscore;
|
||||
bestEl.textContent = String(data.highscore);
|
||||
resultEl.hidden = false;
|
||||
} catch (err) {
|
||||
console.error('Runde konnte nicht gespeichert werden:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function cancelGame() {
|
||||
if (!running) return;
|
||||
if (answeredCount > 0 && !window.confirm(
|
||||
'Aktuelle Runde wirklich abbrechen? Sie wird nicht gespeichert.')) {
|
||||
return;
|
||||
}
|
||||
running = false;
|
||||
current = null;
|
||||
stopTimer();
|
||||
qOverlay.hidden = true;
|
||||
finishBtn.hidden = true;
|
||||
cancelBtn.hidden = true;
|
||||
setIdle('Runde abgebrochen – klicke „Neues Spiel“ für ein frisches Board.');
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const res = await fetch(`/api/games/${GAME}/stats`);
|
||||
if (!res.ok) return;
|
||||
const stats = await res.json();
|
||||
bestEl.textContent = stats.highscore > 0 ? String(stats.highscore) : '–';
|
||||
} catch (err) { /* egal */ }
|
||||
}
|
||||
|
||||
async function newGame() {
|
||||
stopTimer();
|
||||
seconds = 0; score = 0; answeredCount = 0; current = null;
|
||||
submitted.length = 0;
|
||||
timeEl.textContent = '0:00';
|
||||
resultEl.hidden = true;
|
||||
qOverlay.hidden = true;
|
||||
|
||||
const excludeParam = previousIds.length ? ('?exclude=' + previousIds.join(',')) : '';
|
||||
try {
|
||||
const res = await fetch('/api/techtacular/board' + excludeParam);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
board = await res.json();
|
||||
} catch (err) {
|
||||
console.error('Board konnte nicht geladen werden:', err);
|
||||
setIdle('Board konnte nicht geladen werden.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!board.categories || !board.categories.length) {
|
||||
setIdle('Noch keine Kategorien vorhanden. Ein Admin kann sie unter „TechTacular verwalten“ anlegen.');
|
||||
return;
|
||||
}
|
||||
|
||||
renderBoard();
|
||||
previousIds = board.categories.flatMap((c) => c.tiles.map((t) => t.questionId));
|
||||
updateHud();
|
||||
running = true;
|
||||
finishBtn.hidden = false;
|
||||
cancelBtn.hidden = false;
|
||||
startTimer();
|
||||
}
|
||||
|
||||
newBtn.addEventListener('click', newGame);
|
||||
againBtn.addEventListener('click', newGame);
|
||||
finishBtn.addEventListener('click', finishGame);
|
||||
cancelBtn.addEventListener('click', cancelGame);
|
||||
qNext.addEventListener('click', closeQuestion);
|
||||
|
||||
loadStats();
|
||||
newGame();
|
||||
})();
|
||||
@@ -0,0 +1,34 @@
|
||||
/* Mindflux Theme – Dark/Light-Umschaltung, zentral.
|
||||
Im <head> einbinden (ohne defer), dann setzt es das Theme vor dem Rendern (kein Flash).
|
||||
Jeder Button mit Klasse .mf-theme wird automatisch zum Umschalter. */
|
||||
(function () {
|
||||
var KEY = 'mf-theme';
|
||||
function read() { try { return localStorage.getItem(KEY) || 'dark'; } catch (e) { return 'dark'; } }
|
||||
function apply(mode) { document.documentElement.setAttribute('data-theme', mode); }
|
||||
|
||||
apply(read()); // früh -> kein Aufblitzen
|
||||
|
||||
var SUN = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>';
|
||||
var MOON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>';
|
||||
|
||||
function paint() {
|
||||
var dark = document.documentElement.getAttribute('data-theme') === 'dark';
|
||||
document.querySelectorAll('.mf-theme').forEach(function (b) { b.innerHTML = dark ? SUN : MOON; });
|
||||
}
|
||||
function toggle() {
|
||||
var next = document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
|
||||
apply(next);
|
||||
try { localStorage.setItem(KEY, next); } catch (e) {}
|
||||
paint();
|
||||
}
|
||||
window.MindfluxTheme = { toggle: toggle, apply: apply };
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
document.querySelectorAll('.mf-theme').forEach(function (b) {
|
||||
b.setAttribute('role', 'button');
|
||||
b.setAttribute('aria-label', 'Theme umschalten');
|
||||
b.addEventListener('click', toggle);
|
||||
});
|
||||
paint();
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user