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:
AliceDarkest
2026-06-26 14:51:30 +02:00
parent f3309c1574
commit 9022961762
185 changed files with 22772 additions and 142 deletions
+306
View File
@@ -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();
})();