add-frontend-code-comments #11

Merged
gartmaflorin merged 3 commits from add-frontend-code-comments into main 2026-06-09 18:37:22 +02:00
20 changed files with 1253 additions and 419 deletions

View File

@ -401,11 +401,11 @@ const normalized = str.normalize('NFD');
## Team
| Name |
|------|
| **Florin Gartmann** |
| **Daniela Studer-Müller** |
| **Adrian Joost** |
| Name | Rolle & Verantwortungsbereich |
|------|-------------------------------|
| *Adrian Joost* | *Projektleitung & Architektur*: Gesamtüberblick, Projektinitialisierung, Definition der Schnittstellen sowie Analyse von Sicherheitslücken und Edge-Cases (inkl. „Cheating“-Szenarien). |
| *Daniela Studer-Müller* | *Core-Game-Logic*: Implementierung der Spielmechanik, Text-Generierung sowie Entwicklung der Message- und Challenge-Logik. |
| *Florin Gartmann* | *Score-System & Ranking*: Entwicklung der Bewertungslogik (Scoring-Algorithmus) und Implementierung des globalen Leaderboards. |
---

View File

@ -1,9 +1,25 @@
/**
* Service-Klasse für Duelle/Herausforderungen (Challenge Service).
* Ermöglicht das Erstellen und Abschließen von direkt verlinkten Challenges im Backend.
*/
class ChallengeService {
/**
* Erstellt eine Instanz des ChallengeService.
* @param {Object} config - Das globale Konfigurationsobjekt.
*/
constructor(config) {
this.baseUrl = config.API_BASE_URL;
this.urlTail = "challenges";
}
/**
* Erstellt eine neue Herausforderung für einen gegnerischen Spieler.
* @param {string} username - Name des herausfordernden Spielers.
* @param {string} password - Passwort des herausfordernden Spielers.
* @param {string} opponent - Name des herausgeforderten Spielers.
* @param {string} text - Der Rundentext und optionale Zusatznachrichten.
* @returns {Promise<Object>} Ein Promise mit den erstellten Challenge-Details im Body.
*/
async postChallenge(username, password, opponent, text) {
const response = await fetch(`${this.baseUrl}${this.urlTail}`, {
method: "POST",
@ -32,6 +48,17 @@ class ChallengeService {
};
}
/**
* Schließt eine bestehende Herausforderung ab, indem das Spielergebnis übermittelt wird.
* @param {string} username - Name des Spielers, der die Challenge abschließt.
* @param {string} password - Passwort des Spielers.
* @param {string|number} challengeId - Eindeutige ID der Challenge im Backend.
* @param {number} score - Die erreichte Punktzahl.
* @param {number} time - Die benötigte Zeit in Sekunden.
* @param {string} text - Der Originaltext der Challenge-Runde.
* @param {string} userWrittenText - Der vom Spieler eingegebene Text.
* @returns {Promise<Object>} Ein Promise mit der Bestätigung und dem berechneten Ergebnis.
*/
async completeChallenge(username, password, challengeId, score, time, text, userWrittenText) {
const response = await fetch(`${this.baseUrl}${this.urlTail}/${challengeId}/complete`, {
method: "POST",
@ -63,4 +90,6 @@ class ChallengeService {
}
}
// Global verfügbar machen
window.ChallengeService = ChallengeService;

View File

@ -1,5 +1,12 @@
const config = {
API_BASE_URL: "https://webdev.iten-web.ch/10001/api/"
}
window.config = config;
/**
* Konfiguration für die Backend-Verbindung.
* Enthält die Basis-URL für alle API-Anfragen der Anwendung.
*/
const config = {
// Die Basis-URL des Backends, an die alle REST-Anfragen gesendet werden.
API_BASE_URL: "https://webdev.iten-web.ch/10001/api/"
}
// Globales Konfigurationsobjekt im window-Scope registrieren,
// damit alle anderen Service-Klassen darauf zugreifen können.
window.config = config;

View File

@ -1,9 +1,23 @@
/**
* Service-Klasse für die Bestenliste (Leaderboard Service).
* Kapselt den REST-API-Aufruf zur Abfrage der globalen Highscore-Liste.
*/
class LeaderboardService {
/**
* Erstellt eine Instanz des LeaderboardService.
* @param {Object} config - Das globale Konfigurationsobjekt.
*/
constructor(config) {
this.baseUrl = config.API_BASE_URL;
this.urlTail = "leaderboard";
}
/**
* Ruft einen Ausschnitt der globalen Bestenliste ab (Paging).
* @param {number} offset - Der Startindex der abzufragenden Einträge (0-basiert).
* @param {number} limit - Die maximale Anzahl der zurückzugebenden Einträge (z.B. 10 für Top 10).
* @returns {Promise<Object>} Ein Promise mit der Liste der Top-Spieler im Body.
*/
async getLeaderboard(offset, limit) {
const response = await fetch(
`${this.baseUrl}${this.urlTail}?offset=${offset}&limit=${limit}`,
@ -26,4 +40,6 @@ class LeaderboardService {
}
}
// Global verfügbar machen
window.LeaderboardService = LeaderboardService;

View File

@ -1,9 +1,24 @@
/**
* Service-Klasse für das Nachrichtensystem (Message Service).
* Ermöglicht den Abruf und Versand von Systemnachrichten und Herausforderungen (Challenges)
* sowie das Ändern des Gelesen-Status von Nachrichten.
*/
class MessageService {
/**
* Erstellt eine Instanz des MessageService.
* @param {Object} config - Das globale Konfigurationsobjekt.
*/
constructor(config) {
this.baseUrl = config.API_BASE_URL;
this.urlTail = "messages";
}
/**
* Lädt alle Nachrichten (eingehend und ausgehend) für den authentifizierten Benutzer.
* @param {string} username - Der Benutzername.
* @param {string} password - Das Passwort des Benutzers.
* @returns {Promise<Object>} Ein Promise mit der Liste aller Nachrichten im Body.
*/
async getMessages(username, password) {
const response = await fetch(`${this.baseUrl}${this.urlTail}`, {
method: "GET",
@ -27,6 +42,15 @@ class MessageService {
};
}
/**
* Sendet eine Nachricht (oder eine eingebettete Challenge) an einen anderen Benutzer.
* @param {string} username - Der Benutzername des Senders.
* @param {string} password - Das Passwort des Senders.
* @param {string} recipient - Der Empfänger der Nachricht.
* @param {string} type - Der Typ der Nachricht (z. B. "standard" oder "challenge").
* @param {string} text - Der eigentliche Textinhalt (kann eingebettete Challenge-JSON-Daten enthalten).
* @returns {Promise<Object>} Ein Promise mit der Antwort des Servers im Body.
*/
async postMessage(username, password, recipient, type, text) {
const response = await fetch(`${this.baseUrl}${this.urlTail}`, {
method: "POST",
@ -56,6 +80,13 @@ class MessageService {
};
}
/**
* Markiert eine spezifische Nachricht als gelesen.
* @param {string} username - Der Benutzername des Empfängers.
* @param {string} password - Das Passwort des Empfängers.
* @param {string|number} messageId - Eindeutige ID der Nachricht.
* @returns {Promise<Object>} Ein Promise mit dem Resultat der PATCH-Anfrage.
*/
async markMessageAsRead(username, password, messageId) {
const response = await fetch(`${this.baseUrl}${this.urlTail}/${messageId}/read`, {
method: "PATCH",
@ -79,6 +110,12 @@ class MessageService {
};
}
/**
* Markiert alle ungelesenen Nachrichten des Benutzers auf einmal als gelesen.
* @param {string} username - Der Benutzername.
* @param {string} password - Das Passwort des Benutzers.
* @returns {Promise<Object>} Ein Promise mit dem Status der Bulk-Operation.
*/
async markAllMessagesAsRead(username, password) {
const response = await fetch(`${this.baseUrl}${this.urlTail}/read`, {
method: "PATCH",
@ -103,4 +140,6 @@ class MessageService {
}
}
// Global verfügbar machen
window.MessageService = MessageService;

View File

@ -1,11 +1,25 @@
/**
* Service-Klasse für die Spielstände (Score Service).
* Kapselt alle REST-Anfragen an das Backend zur Speicherung und zum Laden von Scores.
*/
class ScoreService {
/**
* Erstellt eine Instanz des ScoreService.
* @param {Object} config - Das globale Konfigurationsobjekt.
*/
constructor(config) {
this.baseUrl = config.API_BASE_URL;
this.urlTail = "score";
}
/**
* Lädt alle Scores eines bestimmten Benutzers.
* @param {string} username - Der Name des Benutzers, dessen Scores geladen werden sollen.
* @returns {Promise<Object>} Ein Promise mit der Liste aller Benutzer-Scores im Body.
*/
async getScoreByName(username) {
// Note: When user does not exist, we get a 200 with empty array, not a 404
// Hinweis: Wenn der Benutzer nicht existiert, liefert das Backend einen Status 200
// mit einem leeren Array statt eines 404-Fehlers.
const response = await fetch(`${this.baseUrl}${this.urlTail}/${username}`, {
method: "GET",
});
@ -23,6 +37,16 @@ class ScoreService {
};
}
/**
* Speichert einen neu erzielten Score im Backend.
* @param {string} username - Benutzername des Spielers.
* @param {string} password - Passwort des Spielers zur Autorisierung.
* @param {number} score - Die erreichte Punktzahl (Anzahl korrekter Wörter).
* @param {number} time - Die für die Eingabe benötigte Zeit in Sekunden.
* @param {string} text - Der vorgegebene Originaltext der Spielrunde.
* @param {string} userWrittenText - Der vom Benutzer eingegebene Text.
* @returns {Promise<Object>} Ein Promise mit der Antwort des Servers (inkl. vergebenem Rang im Body).
*/
async postScore(username, password, score, time, text, userWrittenText) {
const response = await fetch(`${this.baseUrl}${this.urlTail}`, {
method: "POST",
@ -53,6 +77,13 @@ class ScoreService {
};
}
/**
* Löscht ein bestimmtes Score-Ergebnis anhand seiner ID (falls vom Backend unterstützt).
* @param {string} username - Benutzername des Spielers.
* @param {string} password - Passwort des Spielers zur Autorisierung.
* @param {string|number} scoreId - Die eindeutige ID des zu löschenden Scores.
* @returns {Promise<Object>} Ein Promise mit dem Resultat der Löschung.
*/
async deleteScore(username, password, scoreId) {
const response = await fetch(`${this.baseUrl}${this.urlTail}/${scoreId}`, {
method: "DELETE",
@ -76,4 +107,6 @@ class ScoreService {
}
}
// Global verfügbar machen
window.ScoreService = ScoreService;

View File

@ -1,13 +1,29 @@
/**
* Service-Klasse für die Benutzerverwaltung (User Service).
* Kapselt alle REST-Anfragen an das Backend bezüglich Login, Registrierung,
* Abfrage aller Benutzer und Löschung von Konten.
*/
class UserService {
/**
* Erstellt eine Instanz des UserService.
* @param {Object} config - Das globale Konfigurationsobjekt (enthält API_BASE_URL).
*/
constructor(config) {
this.baseUrl = config.API_BASE_URL;
this.urlTail = "user";
}
/**
* Authentifiziert einen Benutzer und ruft dessen Kontoinformationen ab.
* @param {string} username - Der Benutzername.
* @param {string} password - Das Passwort des Benutzers.
* @returns {Promise<Object>} Ein Promise mit dem HTTP-Status, dem OK-Flag und den Benutzerdaten (Body).
*/
async getUser(username, password) {
const response = await fetch(`${this.baseUrl}${this.urlTail}`, {
method: "GET",
headers: {
// Authentifizierung erfolgt über benutzerdefinierte HTTP-Header
"X-Username": username,
"X-Password": password,
},
@ -26,6 +42,12 @@ class UserService {
};
}
/**
* Ruft eine Liste aller im System registrierten Benutzer ab (z. B. für die Challenge-Auswahl).
* @param {string} username - Der Benutzername des anfragenden Benutzers.
* @param {string} password - Das Passwort des anfragenden Benutzers.
* @returns {Promise<Object>} Ein Promise mit der Benutzerliste im Response-Body.
*/
async getUsers(username, password) {
const response = await fetch(`${this.baseUrl}users`, {
method: "GET",
@ -48,6 +70,12 @@ class UserService {
};
}
/**
* Löscht das eigene Benutzerkonto unwiderruflich aus dem System.
* @param {string} username - Der Benutzername des zu löschenden Kontos.
* @param {string} password - Das Passwort des Kontos.
* @returns {Promise<Object>} Ein Promise mit dem Status der Löschoperation.
*/
async deleteUser(username, password) {
const response = await fetch(`${this.baseUrl}${this.urlTail}`, {
method: "DELETE",
@ -70,6 +98,11 @@ class UserService {
};
}
/**
* Registriert einen neuen Benutzer im System. Das Passwort wird vom Backend generiert und zurückgegeben.
* @param {string} username - Der gewünschte neue Benutzername.
* @returns {Promise<Object>} Ein Promise mit den erstellten Account-Daten (inkl. generiertem Passwort) im Body.
*/
async postUser(username) {
const response = await fetch(`${this.baseUrl}${this.urlTail}`, {
method: "POST",
@ -96,4 +129,6 @@ class UserService {
}
}
// Global verfügbar machen
window.UserService = UserService;

View File

@ -4,12 +4,15 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Lorem Ipsum - Das Spiel</title>
<link rel="icon" type="image/png" href="image/icon_l.png">
<!-- Bootstrap CSS -->
<!-- Favicon -->
<link class="icon" type="image/png" href="image/icon_l.png">
<!-- Bootstrap CSS (Lokale Distribution) -->
<link rel="stylesheet" href="assets/bootstrap-5.3.8-dist/css/bootstrap.min.css">
<!-- Eigene Styles für Design-Anpassungen -->
<link rel="stylesheet" href="assets/css/custom.css?v=challenge-flow-20260528e">
<!-- Kleine Korrektur direkt hier, falls Sie custom.css nicht sofort ändern wollen -->
<!-- Inline-CSS-Anpassungen zur Behebung von Flexbox-Layout-Verschiebungen -->
<style>
/* Sicherstellen, dass der Main-Area den ganzen Platz einnimmt */
#main-area {
@ -36,9 +39,8 @@
</style>
</head>
<!-- class="p-4" entfernt -->
<body>
<!-- Navigation -->
<!-- Navigation: Navbar am oberen Bildschirmrand -->
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container-fluid">
<a class="navbar-brand" href="#">Lorem Ipsum - Das Spiel</a>
@ -47,6 +49,7 @@
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto">
<!-- Navigationslinks für Nachrichten (Inbox) und den Login/Registrierungsstatus -->
<li class ="nav-item"><a class="nav-link" href="#" id="navbar-messages">Nachrichten</a></li>
<li class="nav-item"><a class="nav-link" href="#" id="navbar-login">Login / Registrieren</a></li>
</ul>
@ -54,8 +57,9 @@
</div>
</nav>
<!-- Hauptseiten-Wrapper für das Flexbox-Layout -->
<div id="page-wrapper">
<!-- Sidebar -->
<!-- Sidebar Navigation auf der linken Seite (oder einklappbar auf Mobilgeräten) -->
<nav id="sidebar" class="bg-dark text-white p-3">
<ul class="nav flex-column">
<li class="nav-item"><a class="nav-link" href="#" id="nav-home">Home</a></li>
@ -65,22 +69,22 @@
</ul>
</nav>
<!-- Main Area: Enthält jetzt Topbar, Content UND Footer -->
<!-- Hauptbereich: Enthält Topbar, Content und Footer -->
<div id="main-area">
<!-- Topbar -->
<!-- Topbar (Seiten-Überschrift) -->
<header id="topbar">
<h1 class="text-center mb-4">Dashboard</h1>
</header>
<!-- Content -->
<!-- Dynamischer Inhalts-Container: Hier werden die HTML-Dateien aus pages/ hineingeladen -->
<main class="container mt-4" id="main-content">
<!-- Hier werden die pages.html geladen -->
</main>
<!-- Footer: Jetzt INSIDE main-area, damit Flexbox funktioniert -->
<!-- Footer-Bereich mit Impressums- und Urheberrechtsangaben -->
<footer class="bg-dark text-black py-4 border-top border-secondary">
<div class="container">
<!-- Oberer Bereich -->
<!-- Modul-Informationen -->
<div class="row mb-3">
<div class="col-12">Made with <span class="text-danger"></span> für das Modul Frontend
<h5 class="text-warning">Made with Bootstrap 5.3.8.</h5>
@ -90,7 +94,7 @@
</div>
</div>
<!-- Impressum -->
<!-- Impressumsdetails -->
<div class="row mt-2">
<div class="col-12">
<span class="text-warning fw-bold d-block mb-2" style="font-size: 0.7rem;">Impressum</span>
@ -113,6 +117,7 @@
</div>
</div>
</div>
<!-- Copyright-Angabe -->
<div class="text-center mt-2">
<p class="mb-0 text-black-50" style="font-size: 0.65rem;">
&copy; 2026 Modul Frontend Projekt. Alle Rechte vorbehalten.
@ -125,20 +130,26 @@
</div>
</div>
<!-- Bootstrap JS Bundle -->
<!-- JavaScript-Einbindungen -->
<!-- Bootstrap JS Bundle (Lokale Distribution) -->
<script src="assets/bootstrap-5.3.8-dist/js/bootstrap.bundle.min.js"></script>
<!-- Backend API Services -->
<script src="assets/src/service/config-service.js"></script>
<script src="assets/src/service/user-service.js"></script>
<script src="assets/src/service/score-service.js"></script>
<script src="assets/src/service/leaderboard-service.js"></script>
<script src="assets/src/service/message-service.js"></script>
<script src="assets/src/service/challenge-service.js"></script>
<!-- Frontend Logik-Skripte -->
<script src="js/login.js"></script>
<script src="js/leaderboard.js"></script>
<script src="js/scores.js"></script>
<script src="js/messages.js?v=challenge-flow-20260531b"></script>
<!--Navigation Script -->
<script src="js/play.js?v=challenge-flow-20260531b"></script>
<!-- Hauptnavigations-Steuerung -->
<script src="js/navigation.js?v=challenge-flow-20260528"></script>
</body>
</html>

View File

@ -1,4 +1,16 @@
// Formatiert Sekunden als m:ss. Bei ungültigem Wert wird ein Platzhalter angezeigt.
/**
* Bestenliste (Leaderboard) verwalten und anzeigen.
* Dieses Modul lädt die Top-10-Spieler vom Server. Falls der aktuell angemeldete
* Benutzer nicht in diesen Top-10 vertreten ist, lädt es zusätzlich dessen
* persönliches Bestergebnis und zeigt dieses optisch getrennt darunter an.
*/
/**
* Formatiert Sekunden in ein lesbares MM:SS Format (z.B. 75 Sekunden -> "1:15").
* Bei ungültigen Werten wird ein Strich ausgegeben.
* @param {number} seconds - Anzahl Sekunden.
* @returns {string} Formatiertes Zeit-String.
*/
function formatTime(seconds) {
if (typeof seconds !== "number" || Number.isNaN(seconds)) {
return "-";
@ -9,7 +21,10 @@ function formatTime(seconds) {
return `${minutes}:${String(remainingSeconds).padStart(2, "0")}`;
}
// Liefert den aktuellen Login-Kontext, falls Auth global verfügbar ist.
/**
* Holt die aktuellen Authentifizierungsdaten aus dem globalen Auth-Modul.
* @returns {Object|null} Auth-Objekt (mit username) oder null.
*/
function getLoggedInAuth() {
if (!window.AppAuth || typeof window.AppAuth.getAuth !== "function") {
return null;
@ -23,11 +38,19 @@ function getLoggedInAuth() {
return auth;
}
// Vereinheitlicht Benutzernamen für robuste Vergleiche (z. B. Groß-/Kleinschreibung).
/**
* Normalisiert einen Benutzernamen (Trimming und Kleinschreibung) für robuste Vergleiche.
* @param {string} username - Der Benutzername.
* @returns {string} Der bereinigte Benutzername.
*/
function normalizeUsername(username) {
return String(username ?? "").trim().toLowerCase();
}
/**
* Ermittelt den normalisierten Benutzernamen des aktuell angemeldeten Benutzers.
* @returns {string|null} Der bereinigte Name oder null.
*/
function getLoggedInUsername() {
const auth = getLoggedInAuth();
if (!auth) {
@ -37,7 +60,13 @@ function getLoggedInUsername() {
return normalizeUsername(auth.username);
}
// Nutzt den vom Backend gelieferten Rang, fallback auf die aktuelle Listenposition.
/**
* Ermittelt den anzuzeigenden Rang eines Bestenlisten-Eintrags.
* Nutzt vorrangig den vom Backend gelieferten Platz ("place"), andernfalls den Listenindex.
* @param {Object} entry - Der Listeneintrag.
* @param {number} index - Der Listenindex.
* @returns {number} Der anzuzeigende Rang.
*/
function getDisplayedRank(entry, index) {
const place = Number(entry?.place);
if (!Number.isNaN(place) && place > 0) {
@ -47,7 +76,12 @@ function getDisplayedRank(entry, index) {
return index + 1;
}
// Bestes Ergebnis: höchste Punktzahl, bei Gleichstand die geringere Zeit.
/**
* Ermittelt das beste Ergebnis aus einer Liste von Scores.
* Sortiert nach Score (absteigend) und bei Gleichstand nach Zeit (aufsteigend).
* @param {Array<Object>} entries - Eine Liste von Score-Einträgen.
* @returns {Object|null} Der beste Score-Eintrag.
*/
function getBestScoreEntry(entries) {
return entries
.slice()
@ -64,6 +98,11 @@ function getBestScoreEntry(entries) {
})[0] ?? null;
}
/**
* Holt das beste Ergebnis des aktuell angemeldeten Benutzers aus dem Backend.
* @param {string} username - Der Benutzername des eingeloggten Spielers.
* @returns {Promise<Object|null>} Das beste Spielergebnis oder null.
*/
async function getCurrentUserLeaderboardEntry(username) {
if (!window.ScoreService || !username) {
return null;
@ -79,7 +118,14 @@ async function getCurrentUserLeaderboardEntry(username) {
return getBestScoreEntry(result.body);
}
// Rendert die Top-Liste und markiert den eingeloggten Nutzer visuell.
/**
* Rendert die Bestenliste im DOM.
* Hebt die Zeile des aktuell angemeldeten Benutzers farblich hervor.
* Falls extraUserEntry übergeben wird (wenn der User nicht in den Top-10 ist),
* wird am Ende eine Trennzeile und der Eintrag des Benutzers angehängt.
* @param {Array<Object>} entries - Die Top-10 Leaderboard-Einträge.
* @param {Object|null} extraUserEntry - Der separate Bestenlisten-Eintrag des angemeldeten Benutzers.
*/
function renderLeaderboard(entries, extraUserEntry = null) {
const tableBody = document.getElementById("leaderboard-body");
if (!tableBody) {
@ -90,10 +136,12 @@ function renderLeaderboard(entries, extraUserEntry = null) {
tableBody.innerHTML = "";
// Rendern der Top-10
entries.forEach((entry, index) => {
const row = document.createElement("tr");
const rowUsername = normalizeUsername(entry.username);
// Zeile grün hervorheben, wenn es sich um den eigenen Account handelt
if (loggedInUsername && rowUsername === loggedInUsername) {
row.classList.add("leaderboard-row-current-user");
}
@ -108,13 +156,15 @@ function renderLeaderboard(entries, extraUserEntry = null) {
tableBody.appendChild(row);
});
// Wenn der User eingeloggt ist, aber nicht in den Top-10 vertreten war, hängen wir ihn unten an
if (extraUserEntry) {
// Trennt Top-10 und eigenen Eintrag optisch, wenn der Nutzer nicht in den Top-10 ist.
// Optische Lücke (Leerzeile) einfügen
const spacerRow = document.createElement("tr");
spacerRow.classList.add("leaderboard-row-gap");
spacerRow.innerHTML = '<td colspan="4"></td>';
tableBody.appendChild(spacerRow);
// Zeile des angemeldeten Benutzers anhängen
const userRow = document.createElement("tr");
userRow.classList.add("leaderboard-row-current-user");
userRow.classList.add("leaderboard-row-current-user-extra");
@ -129,8 +179,13 @@ function renderLeaderboard(entries, extraUserEntry = null) {
}
}
/**
* Lädt die Top-10-Bestenliste vom Backend und prüft,
* ob der angemeldete Benutzer separat geladen werden muss.
*/
async function loadTopTenLeaderboard() {
const leaderboardService = new window.LeaderboardService(window.config);
// Holt die Bestenliste beginnend bei Rang 1 (Offset 0) mit maximal 10 Einträgen
const result = await leaderboardService.getLeaderboard(0, 10);
if (!result.ok || !Array.isArray(result.body)) {
@ -143,11 +198,12 @@ async function loadTopTenLeaderboard() {
if (auth && auth.username) {
const loggedInUsername = normalizeUsername(auth.username);
// Falls der Nutzer nicht in den Top-10 erscheint, wird sein bestes Ergebnis separat gezeigt.
// Prüfen, ob der angemeldete Benutzer bereits in den Top-10 vorhanden ist
const isInTopTen = result.body.some(
(entry) => normalizeUsername(entry.username) === loggedInUsername,
);
// Wenn er nicht in den Top-10 ist, rufen wir sein bestes Ergebnis separat ab
if (!isInTopTen) {
extraUserEntry = await getCurrentUserLeaderboardEntry(auth.username);
}
@ -156,9 +212,14 @@ async function loadTopTenLeaderboard() {
renderLeaderboard(result.body, extraUserEntry);
}
/**
* Globale Initialisierungsfunktion, die von navigation.js aufgerufen wird,
* sobald die leaderboard.html-Teilseite geladen wurde.
*/
window.initLeaderboardPage = function initLeaderboardPage() {
loadTopTenLeaderboard().catch((error) => {
console.error("Fehler beim Laden des Leaderboards:", error);
renderLeaderboard([]);
});
};

View File

@ -1,6 +1,16 @@
/**
* Benutzer-Authentifizierung und Account-Verwaltung.
* Verwaltet den Login-Status über den LocalStorage, steuert die Anzeige im Header,
* verarbeitet Login- und Registrierungsformulare und ermöglicht das Löschen des Kontos.
*/
(function () {
// Der Schlüssel, unter dem die Benutzerdaten im LocalStorage abgelegt werden.
const AUTH_STORAGE_KEY = "loremIpsumAuth";
/**
* Liest die aktuellen Anmeldedaten aus dem LocalStorage des Browsers.
* @returns {Object|null} Ein Objekt mit {username, password} oder null, falls nicht angemeldet oder ungültig.
*/
function readAuth() {
const raw = localStorage.getItem(AUTH_STORAGE_KEY);
if (!raw) {
@ -9,15 +19,22 @@
try {
const parsed = JSON.parse(raw);
// Validieren, dass die Pflichtfelder vorhanden sind
if (!parsed.username || !parsed.password) {
return null;
}
return parsed;
} catch {
// Fehler beim JSON-Parsen (z.B. manipulierte Daten) -> als nicht angemeldet behandeln
return null;
}
}
/**
* Speichert die Benutzerdaten verschlüsselt als JSON-String im LocalStorage.
* @param {string} username - Der Benutzername.
* @param {string} password - Das generierte/eingegebene Passwort.
*/
function saveAuth(username, password) {
localStorage.setItem(
AUTH_STORAGE_KEY,
@ -25,24 +42,37 @@
);
}
/**
* Entfernt die Anmeldedaten aus dem LocalStorage (Logout).
*/
function clearAuth() {
localStorage.removeItem(AUTH_STORAGE_KEY);
}
/**
* Aktualisiert die Anzeige des Benutzernamens im Header und den Login-Button.
*/
function updateHeaderUsername() {
const usernameDisplay = document.getElementById("username-display");
const navbarLogin = document.getElementById("navbar-login");
const auth = readAuth();
// Text im Header anpassen ("User: Guest" oder "User: <Name>")
if (usernameDisplay) {
usernameDisplay.textContent = auth ? "User: " + auth.username : "User: Guest";
}
// Text des Login-Links in der Navbar anpassen
if (navbarLogin) {
navbarLogin.textContent = auth ? auth.username : "Login / Registrieren";
}
}
/**
* Zeigt dem Benutzer Feedback (z. B. Fehlermeldungen) auf der Login-Seite an.
* @param {string} message - Die anzuzeigende Nachricht.
* @param {string} type - Der Bootstrap-Alert-Typ (danger, warning, success, info).
*/
function setFeedback(message, type) {
const feedback = document.getElementById("auth-feedback");
if (!feedback) {
@ -51,9 +81,13 @@
feedback.className = "alert alert-" + type;
feedback.textContent = message;
feedback.classList.remove("d-none");
feedback.classList.remove("d-none"); // Alert einblenden
}
/**
* Aktualisiert den Zustand der Session-Box (Anzeige der aktuellen Anmeldung).
* Blendet die Forms aus bzw. ein und aktiviert/deaktiviert die Buttons.
*/
function updateSessionBox() {
const sessionText = document.getElementById("current-session-text");
const logoutButton = document.getElementById("logout-button");
@ -67,12 +101,14 @@
const auth = readAuth();
if (auth) {
// Wenn eingeloggt: Session-Info anzeigen und Buttons aktivieren
sessionText.textContent = "Eingeloggt als " + auth.username + ".";
logoutButton.disabled = false;
deleteAccountButton.disabled = false;
currentSessionBox.classList.remove("d-none");
authFormsRow.classList.remove("d-none");
} else {
// Wenn nicht eingeloggt: Info setzen und Session-Box verbergen
sessionText.textContent = "Nicht eingeloggt.";
logoutButton.disabled = true;
deleteAccountButton.disabled = true;
@ -81,6 +117,10 @@
}
}
/**
* Hilfsfunktion zum Instanziieren des UserService.
* @returns {UserService|null} Eine UserService-Instanz oder null.
*/
function getUserService() {
if (!window.config || !window.UserService) {
return null;
@ -89,6 +129,11 @@
return new window.UserService(window.config);
}
/**
* Behandelt das Absenden des Login-Formulars.
* Authentifiziert den Benutzer beim Backend und speichert die Session bei Erfolg.
* @param {Event} event - Das Submit-Event des Formulars.
*/
async function handleLoginSubmit(event) {
event.preventDefault();
@ -100,7 +145,7 @@
const usernameInput = document.getElementById("login-username");
const passwordInput = document.getElementById("login-password");
const submitButton = event.submitter;
const submitButton = event.submitter; // Der geklickte Button
const username = usernameInput.value.trim();
const password = passwordInput.value.trim();
@ -109,6 +154,7 @@
return;
}
// Button während der Anfrage sperren und Ladeindikator zeigen
if (submitButton) {
submitButton.disabled = true;
submitButton.textContent = "Einloggen...";
@ -127,19 +173,25 @@
return;
}
// Button wieder freigeben
if (submitButton) {
submitButton.disabled = false;
submitButton.textContent = "Einloggen";
}
if (result.ok) {
// Login erfolgreich: Daten speichern, Ansicht aktualisieren und zur Startseite leiten
saveAuth(username, password);
setFeedback("Login erfolgreich.", "success");
updateSessionBox();
updateHeaderUsername();
// Falls das Navigationsmenü geöffnet ist, dessen Status aktualisieren
if (typeof window.updateMessagesNavState === "function") {
window.updateMessagesNavState();
}
// Kurze Verzögerung für visuelles Feedback vor dem Seitenwechsel
setTimeout(function() {
if (typeof window.loadPage === "function") {
window.loadPage("home", "nav-home");
@ -156,6 +208,12 @@
setFeedback("Login fehlgeschlagen (Status " + result.status + ").", "danger");
}
/**
* Behandelt das Absenden des Registrierungsformulars.
* Sendet den gewünschten Benutzernamen ans Backend, welches das Passwort generiert.
* Zeigt das Passwort anschließend in einem Bootstrap-Modal an, da es nur einmalig sichtbar ist.
* @param {Event} event - Das Submit-Event des Formulars.
*/
async function handleRegisterSubmit(event) {
event.preventDefault();
@ -175,9 +233,11 @@
const result = await userService.postUser(username);
if (result.ok && result.body) {
// Registrierung erfolgreich: Antwortdaten auslesen
const createdName = result.body.name || username;
const createdPassword = result.body.password || "";
// Direkt einloggen mit den erhaltenen Daten
saveAuth(createdName, createdPassword);
updateSessionBox();
updateHeaderUsername();
@ -185,17 +245,18 @@
window.updateMessagesNavState();
}
// Modal mit Daten füllen und anzeigen
// Modal mit den generierten Zugangsdaten befüllen
const modalUsername = document.getElementById("modal-username");
const modalPassword = document.getElementById("modal-password");
if (modalUsername) modalUsername.textContent = createdName;
if (modalPassword) modalPassword.textContent = createdPassword;
// Das Passwort-Modal anzeigen
const passwordModalElement = document.getElementById("password-modal");
if (passwordModalElement) {
const passwordModal = new window.bootstrap.Modal(passwordModalElement);
// Listener für Modals-Schließen: zur Startseite navigieren
// Nach Schließen des Modals automatisch auf die Startseite weiterleiten
passwordModalElement.addEventListener("hidden.bs.modal", function handleClose() {
if (typeof window.loadPage === "function") {
window.loadPage("home", "nav-home");
@ -205,7 +266,7 @@
passwordModal.show();
} else {
// Fallback wenn Modal nicht gefunden
// Fallback, falls das Bootstrap-Modal im DOM nicht gefunden wurde
setFeedback(
"Account erstellt. Username: " + createdName + ", Passwort: " + createdPassword,
"success",
@ -217,6 +278,7 @@
}, 1500);
}
// Formularfelder für eventuelle spätere Logins vorausfüllen
const loginUsernameInput = document.getElementById("login-username");
const loginPasswordInput = document.getElementById("login-password");
if (loginUsernameInput && loginPasswordInput) {
@ -227,6 +289,7 @@
return;
}
// Fehlerbehandlung
if (result.status === 400) {
const errorMessage = result.body && result.body.message
? result.body.message
@ -241,6 +304,9 @@
);
}
/**
* Loggt den aktuellen Benutzer aus (löscht Auth-Daten aus LocalStorage und aktualisiert UI).
*/
function handleLogout() {
clearAuth();
setFeedback("Du wurdest ausgeloggt.", "info");
@ -251,6 +317,11 @@
}
}
/**
* Behandelt das Löschen des eigenen Accounts.
* Fordert eine Sicherheitsbestätigung an, sendet die Löschanfrage ans Backend
* und führt bei Erfolg einen automatischen Logout aus.
*/
async function handleDeleteAccount() {
const auth = readAuth();
if (!auth) {
@ -258,6 +329,7 @@
return;
}
// Sicherheitsabfrage im Browser
const wantsDelete = window.confirm(
"Möchtest du den Account \"" + auth.username + "\" wirklich löschen?",
);
@ -273,6 +345,7 @@
const result = await userService.deleteUser(auth.username, auth.password);
if (result.ok) {
// Account erfolgreich gelöscht: Lokal ausloggen und Feedback geben
clearAuth();
updateSessionBox();
updateHeaderUsername();
@ -294,6 +367,10 @@
);
}
/**
* Initialisiert die Login-Seite.
* Bindet alle Event-Listener an die Formulare und Buttons nach dem Laden des HTML-Inhalts.
*/
function initLoginPage() {
const loginForm = document.getElementById("login-form");
const registerForm = document.getElementById("register-form");
@ -309,15 +386,22 @@
logoutButton.addEventListener("click", handleLogout);
deleteAccountButton.addEventListener("click", handleDeleteAccount);
// Initialen UI-Zustand herstellen
updateSessionBox();
updateHeaderUsername();
}
// Bindet die Initialisierungsfunktion an das globale Fensterobjekt für navigation.js
window.initLoginPage = initLoginPage;
// Stellt Authentifizierungsfunktionen global bereit, damit andere Module (z. B. play.js)
// prüfen können, ob und wer eingeloggt ist.
window.AppAuth = {
getAuth: readAuth,
clearAuth: clearAuth,
};
// Beim allerersten Laden der Anwendung direkt den Header anpassen
document.addEventListener("DOMContentLoaded", updateHeaderUsername);
})();

View File

@ -1,9 +1,24 @@
/**
* Nachrichten-Dashboard und Duell-System (Challenges).
* Dieses Modul verwaltet den Abruf und Versand von Textnachrichten, die Gruppierung von
* Nachrichten zu Duell-Konversationen, das Erstellen neuer Herausforderungen mit zufälligen
* Texten und die visuelle Aufbereitung von Spielergebnissen (Gewonnen/Verloren/Unentschieden).
*/
(function () {
// --- Konstanten & Konfiguration ---
const MESSAGE_TYPE_CHALLENGE = "challenge";
const MESSAGE_TYPE_CHALLENGE_RESULT = "challenge-result";
// Abfrage-Intervall für neue Nachrichten (30 Sekunden)
const MESSAGE_POLL_INTERVAL_MS = 30000;
// Key für die aktive Challenge im SessionStorage
const ACTIVE_CHALLENGE_STORAGE_KEY = "loremIpsumActiveChallenge";
// Eindeutiges Kennzeichen für JSON-strukturierte Challenge-Daten innerhalb normaler Textnachrichten
const CHALLENGE_DATA_PREFIX = "[[loremIpsumChallenge:";
// Satzteile zur Generierung der zu merkenden Sätze in Duellen
const CHALLENGE_TEXT_PARTS = {
subjects: [
"Der flinke Entwickler",
@ -55,14 +70,24 @@
],
};
let currentMessages = [];
let currentUsers = [];
let messagePollingInterval = null;
// --- Modulweiter Status ---
let currentMessages = []; // Cache für alle geladenen Nachrichten
let currentUsers = []; // Liste aller anderen Benutzer im System
let messagePollingInterval = null; // ID des Polling-Timers
/**
* Holt ein zufälliges Element aus einem Array.
* @param {Array} items - Das Quell-Array.
*/
function getRandomChallengeTextPart(items) {
return items[Math.floor(Math.random() * items.length)];
}
/**
* Generiert einen grammatikalisch korrekten, zufälligen Text für ein Duell.
* Besteht aus zwei Hauptsätzen und einem Schlusssatz.
* @returns {string} Der generierte Challenge-Text.
*/
function generateChallengeText() {
const firstSentence =
[
@ -87,6 +112,12 @@
getRandomChallengeTextPart(CHALLENGE_TEXT_PARTS.endings);
}
/**
* Baut einen Nachrichtentext auf, in den die Spieldaten (JSON) eingebettet sind.
* @param {Object} challengeData - Die strukturierten Spieldaten.
* @param {string} displayText - Der für den Nutzer lesbare Textteil der Nachricht.
* @returns {string} Der präparierte Gesamttext für die API.
*/
function buildEmbeddedChallengeText(challengeData, displayText) {
return CHALLENGE_DATA_PREFIX +
JSON.stringify(challengeData) +
@ -95,6 +126,9 @@
displayText;
}
/**
* Holt die aktuellen Zugangsdaten aus dem AppAuth-Modul.
*/
function getAuth() {
if (!window.AppAuth || typeof window.AppAuth.getAuth !== "function") {
return null;
@ -108,10 +142,16 @@
return auth;
}
/**
* Bereinigt Benutzernamen für Vergleiche.
*/
function normalizeUsername(username) {
return String(username || "").trim().toLowerCase();
}
/**
* Holt eine Instanz des MessageService.
*/
function getMessageService() {
if (!window.config || !window.MessageService) {
return null;
@ -120,6 +160,9 @@
return new window.MessageService(window.config);
}
/**
* Holt eine Instanz des UserService.
*/
function getUserService() {
if (!window.config || !window.UserService) {
return null;
@ -128,6 +171,9 @@
return new window.UserService(window.config);
}
/**
* Holt eine Instanz des ChallengeService.
*/
function getChallengeService() {
if (!window.config || !window.ChallengeService) {
return null;
@ -136,9 +182,15 @@
return new window.ChallengeService(window.config);
}
/**
* Normalisiert ein vom Server empfangenes Nachrichtenobjekt.
* Extrahiert ggf. eingebettete JSON-Challenge-Daten und vereinheitlicht
* abweichende Backend-Feldnamen (z.B. date/time/createdAt).
* @param {Object} message - Das rohe Nachrichtenobjekt.
* @returns {Object} Die normalisierte Nachricht.
*/
function normalizeMessage(message) {
// Das Backend und unser Nachrichten-Fallback liefern Challenge-Daten leicht unterschiedlich.
// Hier werden beide Formen in ein einheitliches Message-Objekt gebracht.
// Verschiedene mögliche Text-Eigenschaften des Backends prüfen
const textCandidates = [
message.text,
message.content,
@ -146,14 +198,20 @@
message.challenge?.text,
message.result?.text,
];
// Findet den Text, der unsere eingebetteten JSON-Daten enthält, oder den ersten nicht-leeren Text
const rawText = textCandidates.find((value) => String(value ?? "").includes(CHALLENGE_DATA_PREFIX))
?? textCandidates.find((value) => value !== null && value !== undefined)
?? "";
// Versuchen, eingebettetes JSON aus dem Text zu parsen
const embeddedChallenge = extractEmbeddedChallenge(rawText);
const type = message.type ?? MESSAGE_TYPE_CHALLENGE;
const backendChallenge = message.challenge
?? message.result
?? (type === MESSAGE_TYPE_CHALLENGE || type === MESSAGE_TYPE_CHALLENGE_RESULT ? message : null);
// Zusammenführen von Backend-Challenge-Daten und lokal extrahierten Daten
const challenge = embeddedChallenge.challenge
? { ...(backendChallenge ?? {}), ...embeddedChallenge.challenge }
: backendChallenge;
@ -163,16 +221,20 @@
sender: message.sender ?? message.from ?? "",
recipient: message.recipient ?? message.to ?? "",
type: type,
text: embeddedChallenge.text,
text: embeddedChallenge.text, // Nur der lesbare Teil
read: Boolean(message.read),
createdAt: message.createdAt ?? message.time ?? message.date ?? "",
challenge: challenge,
};
}
/**
* Extrahiert ein eingebettetes JSON-Objekt aus einem Nachrichtentext.
* Sucht nach dem Muster: [[loremIpsumChallenge: {JSON} ]]
* @param {string} text - Der rohe Nachrichtentext.
* @returns {Object} Ein Objekt mit dem bereinigten Anzeigetext und dem extrahierten JSON-Objekt (oder null).
*/
function extractEmbeddedChallenge(text) {
// Fallback fuer Challenge-Daten, die im Nachrichtentext mitgesendet werden.
// So koennen wir bestehende Message-Endpunkte nutzen, ohne neue Backend-Felder zu verlangen.
const rawText = String(text ?? "");
const startIndex = rawText.indexOf(CHALLENGE_DATA_PREFIX);
if (startIndex === -1) {
@ -190,7 +252,9 @@
};
}
// JSON-String ausschneiden
const json = rawText.slice(startIndex + CHALLENGE_DATA_PREFIX.length, endIndex);
// Den JSON-Teil aus dem Text entfernen, damit der Empfänger ihn nicht sieht
const displayText = (
rawText.slice(0, startIndex) +
rawText.slice(endIndex + 2)
@ -202,6 +266,7 @@
challenge: JSON.parse(json),
};
} catch {
// Falls JSON beschädigt ist, den Text unverändert zurückgeben
return {
text: rawText,
challenge: null,
@ -209,10 +274,16 @@
}
}
/**
* Holt die eindeutige ID einer Challenge aus verschiedenen Backend-Datenstrukturen.
*/
function getChallengeId(challenge) {
return challenge?.challengeId ?? challenge?.challenge_id ?? challenge?.id ?? null;
}
/**
* Ermittelt den Namen des Herausforderers.
*/
function getChallengeChallenger(challenge, fallbackName) {
return challenge?.challenger
?? challenge?.challengerName
@ -222,6 +293,9 @@
?? "";
}
/**
* Ermittelt den Namen des Herausgeforderten (Gegners).
*/
function getChallengeOpponent(challenge, fallbackName) {
return challenge?.opponent
?? challenge?.opponentName
@ -232,6 +306,9 @@
?? "";
}
/**
* Ermittelt den Score des Gegners.
*/
function getOpponentScore(challenge) {
return challenge?.opponentScore
?? challenge?.challengedScore
@ -239,6 +316,9 @@
?? null;
}
/**
* Ermittelt den vorgegebenen Rundentext der Challenge.
*/
function getChallengeText(challenge) {
return challenge?.challengeText
?? challenge?.roundText
@ -246,10 +326,18 @@
?? null;
}
/**
* Prüft, ob ein Wert eine gültige Punktzahl darstellt.
*/
function hasScore(value) {
return value !== null && value !== undefined && value !== "";
}
/**
* Bestimmt die Rolle des aktuell angemeldeten Benutzers in einer Challenge.
* @param {Object} message - Die normalisierte Nachricht.
* @returns {string|null} "opponent" (Geforderter), "challenger" (Herausforderer) oder null.
*/
function getChallengeRole(message) {
const auth = getAuth();
const challenge = message.challenge;
@ -272,6 +360,10 @@
return null;
}
/**
* Prüft, ob der Geforderte (Opponent) die Challenge annehmen und spielen darf.
* Das ist der Fall, wenn er noch keinen Score hat und auch der Herausforderer noch nicht gespielt hat.
*/
function canOpponentAcceptChallenge(message) {
if (message.type !== MESSAGE_TYPE_CHALLENGE || !message.challenge) {
return false;
@ -284,6 +376,11 @@
return role === "opponent" && !hasScore(opponentScore) && !hasScore(challenge.challengerScore);
}
/**
* Prüft, ob der Herausforderer (Challenger) seine Runde spielen darf.
* Das ist der Fall, wenn der Gegner (Opponent) bereits gespielt hat,
* der Herausforderer selbst aber noch nicht.
*/
function canChallengerPlayChallenge(message) {
const role = getChallengeRole(message);
const challenge = message.challenge;
@ -293,9 +390,12 @@
&& !hasScore(challenge.challengerScore);
}
/**
* Ermittelt den Beschriftungs- und Aktivierungsstatus des Buttons für eine Challenge.
* @param {Object} message - Die normalisierte Nachricht.
* @returns {Object|null} Button-Konfiguration mit {disabled, label, role} oder null.
*/
function getChallengeButtonState(message) {
// Bestimmt den sichtbaren Status einer Challenge aus Sicht des eingeloggten Users.
// Dadurch gibt es pro Challenge genau einen aktiven oder deaktivierten Button.
const challenge = message.challenge;
const role = getChallengeRole(message);
if (!challenge || !role) {
@ -306,6 +406,7 @@
const challengerHasScore = hasScore(challenge.challengerScore);
const opponentHasScore = hasScore(opponentScore);
// Szenario 1: Beide haben bereits gespielt -> Duell vorbei
if (challengerHasScore && opponentHasScore) {
return {
disabled: true,
@ -313,6 +414,7 @@
};
}
// Szenario 2: Der angemeldete User wurde herausgefordert (Rolle Opponent)
if (role === "opponent") {
if (!opponentHasScore && !challengerHasScore) {
return {
@ -328,7 +430,9 @@
};
}
// Szenario 3: Der angemeldete User hat das Duell gestartet (Rolle Challenger)
if (role === "challenger") {
// Herausforderer spielt als zweites, nachdem der Gegner vorgelegt hat
if (opponentHasScore && !challengerHasScore) {
return {
disabled: false,
@ -337,6 +441,7 @@
};
}
// Gegner hat noch nicht reagiert
return {
disabled: true,
label: "Warte auf Gegner",
@ -346,15 +451,20 @@
return null;
}
/**
* Startet die Spielrunde für eine Challenge.
* Speichert die Challenge-Daten im SessionStorage ab und leitet auf die Spielseite weiter.
* @param {Object} message - Die Challenge-Nachricht.
* @param {string} role - Die zugewiesene Spielrolle ("opponent" / "challenger").
*/
function startChallenge(message, role) {
// Der aktive Challenge-Kontext wird nur fuer die naechste Spielrunde im Session Storage abgelegt.
// play.js liest diesen Zustand aus und weiss dadurch, ob es die erste oder finale Runde ist.
const challenge = message.challenge;
const challenger = getChallengeChallenger(challenge, message.sender);
const opponent = getChallengeOpponent(challenge, message.recipient);
const auth = getAuth();
const otherUser = role === "opponent" ? challenger : opponent;
// Challenge-Kontext sichern, damit play.js weiß, in welchem Modus gestartet wird
sessionStorage.setItem(
ACTIVE_CHALLENGE_STORAGE_KEY,
JSON.stringify({
@ -368,11 +478,15 @@
}),
);
// Dynamisch auf die Spielseite navigieren
if (typeof window.loadPage === "function") {
window.loadPage("play", "nav-play");
}
}
/**
* Normalisiert ein Benutzerobjekt/String zu einem Namen.
*/
function normalizeUser(user) {
if (typeof user === "string") {
return user;
@ -381,6 +495,9 @@
return user?.name ?? user?.username ?? "";
}
/**
* Formatiert einen ISO-Zeitstempel in de-CH Lokale Format (z.B. "09.06. 18:07").
*/
function formatMessageTime(value) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
@ -395,6 +512,9 @@
});
}
/**
* Blendet eine Feedbackmeldung ein.
*/
function setFeedback(message, type) {
const feedback = document.getElementById("messages-feedback");
if (!feedback) {
@ -406,6 +526,9 @@
feedback.classList.remove("d-none");
}
/**
* Aktiviert oder deaktiviert alle Formular-Interaktionen in der Inbox (während Ladezeiten).
*/
function setFormEnabled(enabled) {
const formElements = document.querySelectorAll(
"#challenge-form button, #challenge-form select, #challenge-form textarea, #mark-read-button, #refresh-messages-button",
@ -416,6 +539,11 @@
});
}
/**
* Aktualisiert den visuellen Zustand des Nachrichten-Links in der Navbar.
* Falls ungelesene Nachrichten für den Benutzer vorliegen, wird der Link rot hinterlegt.
* @param {Array<Object>} messages - Die Liste der Nachrichten.
*/
function updateMessagesNavState(messages = currentMessages) {
const navLink = document.getElementById("navbar-messages");
if (!navLink) {
@ -424,14 +552,19 @@
const auth = getAuth();
const ownName = normalizeUsername(auth?.username);
// Prüfen, ob eine eingehende Nachricht ungelesen ist
const hasUnreadMessages = messages.some((message) => {
const isIncoming = normalizeUsername(message.recipient) === ownName
|| normalizeUsername(message.sender) !== ownName;
return isIncoming && !message.read;
});
// Toggle der CSS-Klasse "has-unread-messages"
navLink.classList.toggle("has-unread-messages", hasUnreadMessages);
}
/**
* Markiert eine Nachricht beim Anklicken als gelesen und sendet dies an den Server.
*/
async function markMessageReadOnClick(message, item) {
const auth = getAuth();
const messageService = getMessageService();
@ -445,6 +578,7 @@
return;
}
// Optimistisches UI-Update: Sofort als gelesen markieren
message.read = true;
item.classList.remove("message-item-unread");
updateMessagesNavState(currentMessages);
@ -456,6 +590,7 @@
);
if (!result.ok) {
// Rollback bei Fehler im Backend
message.read = false;
item.classList.add("message-item-unread");
updateMessagesNavState(currentMessages);
@ -463,15 +598,17 @@
}
}
/**
* Markiert alle ungelesenen Nachrichten einer Challenge-Gruppe beim Klicken auf gelesen.
*/
async function markMessageGroupReadOnClick(messages, item) {
// Eine gruppierte Challenge-Karte kann mehrere einzelne Nachrichten enthalten.
// Beim Anklicken werden deshalb alle ungelesenen eingehenden Nachrichten der Gruppe markiert.
const auth = getAuth();
const messageService = getMessageService();
if (!auth || !messageService) {
return;
}
// Alle ungelesenen eingehenden Nachrichten der Gruppe filtern
const unreadIncomingMessages = messages.filter((message) => {
const isIncoming = normalizeUsername(message.recipient) === normalizeUsername(auth.username)
|| normalizeUsername(message.sender) !== normalizeUsername(auth.username);
@ -482,12 +619,14 @@
return;
}
// Optimistisches Update
unreadIncomingMessages.forEach((message) => {
message.read = true;
});
item.classList.remove("message-item-unread");
updateMessagesNavState(currentMessages);
// Alle Requests parallel an das Backend senden
const results = await Promise.all(
unreadIncomingMessages.map((message) =>
messageService.markMessageAsRead(auth.username, auth.password, message.id),
@ -495,6 +634,7 @@
);
if (results.some((result) => !result.ok)) {
// Rollback bei Fehlern
unreadIncomingMessages.forEach((message) => {
message.read = false;
});
@ -504,22 +644,32 @@
}
}
/**
* Hilfsfunktion zur Ermittlung des Zeitstempels einer Nachricht.
*/
function getMessageTimeValue(message) {
const time = new Date(message.createdAt).getTime();
return Number.isNaN(time) ? 0 : time;
}
/**
* Führt die Teildaten aus verschiedenen Nachrichten einer Challenge zusammen.
* Wenn z.B. Nachricht 1 die ID und den Text liefert, und Nachricht 2 den Gegner-Score,
* enthält das Ergebnis-Objekt alle Eigenschaften.
* @param {Array<Object>} messages - Die sortierte Liste der Challenge-Nachrichten.
* @returns {Object} Ein zusammengeführtes Datenobjekt der Challenge.
*/
function mergeChallengeData(messages) {
// Fuer die Challenge-Karte werden Teildaten aus mehreren Nachrichten zusammengefuehrt
// (z. B. zuerst Challenge-ID, spaeter Gegner-Score, am Schluss Gewinner).
return messages
.slice()
// Chronologisch aufsteigend sortieren für korrektes Überschreiben
.sort((a, b) => getMessageTimeValue(a) - getMessageTimeValue(b))
.reduce((merged, message) => {
if (!message.challenge) {
return merged;
}
// Alle nicht-leeren Werte in das Sammelobjekt übernehmen
Object.keys(message.challenge).forEach((key) => {
const value = message.challenge[key];
if (value !== null && value !== undefined && value !== "") {
@ -531,14 +681,21 @@
}, {});
}
/**
* Gruppiert eine Liste von Nachrichten.
* Normale Nachrichten bleiben eigenständig, während Nachrichten mit derselben Challenge-ID
* zu einer einzigen "Challenge-Gruppe" zusammengefasst werden. Das verhindert Inbox-Spam.
* @param {Array<Object>} messages - Liste aller Nachrichten.
* @returns {Array<Object>} Die gruppierten Einträge, absteigend nach Aktualität sortiert.
*/
function groupMessagesByChallenge(messages) {
// Nachrichten mit gleicher Challenge-ID werden zu einer Karte gruppiert,
// damit alte Zwischen-Nachrichten nicht einzeln die Inbox ueberladen.
const groupsByChallenge = new Map();
const standaloneGroups = [];
messages.forEach((message) => {
const challengeId = getChallengeId(message.challenge);
// Standardnachrichten ohne Challenge-ID werden direkt verarbeitet
if (challengeId === null || challengeId === undefined) {
standaloneGroups.push({
kind: "message",
@ -548,6 +705,7 @@
return;
}
// Challenge-Nachrichten werden nach ID gruppiert
const key = String(challengeId);
if (!groupsByChallenge.has(key)) {
groupsByChallenge.set(key, []);
@ -555,11 +713,14 @@
groupsByChallenge.get(key).push(message);
});
// Die Gruppierungen konsolidieren
const challengeGroups = Array.from(groupsByChallenge.values()).map((groupMessages) => {
// Absteigend sortieren, damit das neueste Element die Basis bildet
const sortedMessages = groupMessages
.slice()
.sort((a, b) => getMessageTimeValue(b) - getMessageTimeValue(a));
const latest = { ...sortedMessages[0] };
// Spieldaten aller Nachrichten der Gruppe verschmelzen
latest.challenge = mergeChallengeData(sortedMessages);
return {
@ -569,11 +730,17 @@
};
});
// Zusammenführen und absteigend nach Datum der neuesten Nachricht sortieren
return standaloneGroups
.concat(challengeGroups)
.sort((a, b) => getMessageTimeValue(b.latest) - getMessageTimeValue(a.latest));
}
/**
* Rendert die Benutzerliste im Sidebar-Bereich der Inbox zum schnellen Herausfordern.
* Befüllt auch das Empfänger-Dropdown im Formular.
* @param {Array<string|Object>} users - Liste aller User.
*/
function renderUserList(users) {
const userList = document.getElementById("messages-user-list");
const recipientSelect = document.getElementById("challenge-recipient");
@ -583,6 +750,8 @@
const auth = getAuth();
const ownName = normalizeUsername(auth?.username);
// Benutzernamen extrahieren, bereinigen, Duplikate entfernen und eigenen Namen filtern
const uniqueUsers = Array.from(new Set(users.map(normalizeUser)))
.filter(Boolean)
.filter((username) => normalizeUsername(username) !== ownName)
@ -600,11 +769,13 @@
return;
}
// Für jeden Benutzer einen Button in der Liste und eine Option im Select erstellen
uniqueUsers.forEach((username) => {
const userButton = document.createElement("button");
userButton.type = "button";
userButton.className = "messages-user-button";
userButton.textContent = username;
// Bei Klick den User als Empfänger auswählen und Fokus ins Textfeld setzen
userButton.addEventListener("click", () => {
recipientSelect.value = username;
document.getElementById("challenge-text")?.focus();
@ -618,8 +789,12 @@
});
}
/**
* Rendert die Nachrichtenliste (Inbox) im DOM.
* Erstellt Standard-Nachrichtenkarten oder komplexe Challenge-Karten.
* @param {Array<Object>} messages - Die anzuzeigenden Nachrichten.
*/
function renderMessages(messages = currentMessages) {
// Rendert normale Nachrichten einzeln und Challenge-Nachrichten als gruppierte Karten.
const messageList = document.getElementById("message-list");
if (!messageList) {
return;
@ -637,11 +812,13 @@
return;
}
// Nachrichten nach Gruppierung rendern
groupMessagesByChallenge(messages)
.forEach((messageGroup) => {
const message = messageGroup.latest;
const item = document.createElement("article");
const isOutgoing = normalizeUsername(message.sender) === ownName;
// Prüfen, ob mindestens eine Nachricht in der Gruppe ungelesen ist
const hasUnreadIncoming = messageGroup.messages.some((groupMessage) => {
const isIncoming = normalizeUsername(groupMessage.recipient) === ownName
|| normalizeUsername(groupMessage.sender) !== ownName;
@ -654,9 +831,11 @@
}
if (hasUnreadIncoming) {
item.classList.add("message-item-unread");
// Bei Klick alle ungelesenen Nachrichten dieser Gruppe als gelesen markieren
item.addEventListener("click", () => markMessageGroupReadOnClick(messageGroup.messages, item));
}
// Absender-/Empfängerzeile bestimmen
const fromToText = messageGroup.kind === "challenge"
? "Challenge"
: isOutgoing
@ -678,6 +857,7 @@
meta.append(sender, time);
item.append(meta, text);
// Bei Challenge-Gruppen mit mehreren Nachrichten einen "Verlauf einblenden"-Button anbieten
if (messageGroup.kind === "challenge" && messageGroup.messages.length > 1) {
const toggleButton = document.createElement("button");
toggleButton.type = "button";
@ -704,7 +884,7 @@
});
toggleButton.addEventListener("click", (event) => {
event.stopPropagation();
event.stopPropagation(); // Verhindert das Auslösen des Gelesen-Markierens der ganzen Karte
const isCollapsed = thread.classList.toggle("d-none");
toggleButton.textContent = isCollapsed ? "Verlauf anzeigen" : "Verlauf ausblenden";
});
@ -713,10 +893,12 @@
item.appendChild(thread);
}
// Falls die Challenge beendet ist (beide Scores vorhanden), eine Ergebnis-Grafik zeichnen
if (message.challenge && hasScore(message.challenge.challengerScore) && hasScore(message.challenge.opponentScore)) {
item.appendChild(createChallengeResultGraphic(message.challenge));
}
// Ggf. Button für "Annehmen" oder "Spielen" einblenden
const challengeButtonState = getChallengeButtonState(message);
if (challengeButtonState) {
const challengeButton = document.createElement("button");
@ -734,8 +916,12 @@
});
}
/**
* Baut die HTML-Ergebnisgrafik für eine beendete Challenge zusammen.
* @param {Object} result - Die zusammengeführten Challenge-Daten.
* @returns {HTMLElement} Die erstellte Ergebnis-Komponente.
*/
function createChallengeResultGraphic(result) {
// Baut die kompakte Ergebnisgrafik mit passendem Bild aus Sicht des aktuellen Users.
const graphic = document.createElement("div");
graphic.className = "challenge-result-graphic";
@ -743,6 +929,8 @@
const isDraw = result.winner === null || result.winner === "draw";
const auth = getAuth();
const isOwnWin = !isDraw && normalizeUsername(winnerName) === normalizeUsername(auth?.username);
// Status bestimmen aus Sicht des eingeloggten Users (Sieg / Niederlage / Draw)
const outcome = isDraw ? "draw" : isOwnWin ? "win" : "loss";
const outcomeText = outcome === "draw"
? "Unentschieden"
@ -792,6 +980,9 @@
return graphic;
}
/**
* Lädt die Liste aller registrierten Benutzer vom Server.
*/
async function loadUsers() {
const auth = getAuth();
const userService = getUserService();
@ -810,13 +1001,18 @@
renderUserList(result.body);
}
/**
* Lädt alle Nachrichten vom Backend und aktualisiert die Ansichten.
* Schaltet bei fehlendem Login auf den Platzhalter-Zustand um.
* @param {Object} options - Z. B. { showFeedback: true } für Aktualisierungsmeldung.
*/
async function loadMessages(options = {}) {
// Laedt Nachrichten vom Backend und blendet die Seite bei fehlendem Login in einen Hinweiszustand.
const auth = getAuth();
const messageService = getMessageService();
const loggedInDiv = document.getElementById("messages-content");
const loggedOutDiv = document.getElementById("messages-login-placeholder");
// Falls nicht angemeldet: Platzhalter einblenden, Daten leeren
if (!auth) {
if (loggedInDiv) loggedInDiv.classList.add("d-none");
if (loggedOutDiv) loggedOutDiv.classList.remove("d-none");
@ -828,6 +1024,7 @@
return;
}
// Falls angemeldet: Dashboard zeigen
if (loggedInDiv) loggedInDiv.classList.remove("d-none");
if (loggedOutDiv) loggedOutDiv.classList.add("d-none");
@ -847,6 +1044,7 @@
return;
}
// Nachrichten normalisieren, im Cache sichern und rendern
currentMessages = result.body.map(normalizeMessage);
renderMessages();
updateMessagesNavState();
@ -857,8 +1055,11 @@
}
}
/**
* Erstellt ein neues Duell (Challenge). Generiert den Spieltext,
* bettet ihn als JSON ein und sendet die Nachricht ab.
*/
async function handleChallengeSubmit(event) {
// Erstellt eine Challenge. Der Gegner spielt zuerst; der weitere Ablauf wird ueber Nachrichten gesteuert.
event.preventDefault();
const auth = getAuth();
@ -877,7 +1078,9 @@
return;
}
// Challenge-Satz generieren
const challengeText = generateChallengeText();
// JSON-Metadaten einbetten
const challengeMessage = buildEmbeddedChallengeText(
{ challengeText: challengeText },
text,
@ -895,14 +1098,19 @@
return;
}
// Reste im Storage säubern
sessionStorage.removeItem(ACTIVE_CHALLENGE_STORAGE_KEY);
setFeedback(
"Challenge an " + recipient + " wurde gesendet. Der Gegner spielt zuerst; danach bekommst du sein Resultat.",
"success",
);
textInput.value = ""; // Textfeld leeren
await loadMessages({ showFeedback: false });
}
/**
* Markiert alle Nachrichten auf einmal als gelesen.
*/
async function handleMarkRead() {
const auth = getAuth();
const messageService = getMessageService();
@ -913,6 +1121,7 @@
const result = await messageService.markAllMessagesAsRead(auth.username, auth.password);
if (!result.ok) {
// Fallback: Einzeln nacheinander als gelesen markieren, falls die Bulk-API fehlschlägt
const unreadMessages = currentMessages.filter((message) => !message.read);
const readResults = await Promise.all(
unreadMessages.map((message) =>
@ -930,6 +1139,10 @@
await loadMessages({ showFeedback: false });
}
/**
* Initialisiert die Nachrichten-Seite (Events binden).
* Wird von navigation.js nach dem Laden von messages.html aufgerufen.
*/
async function initMessagesPage() {
const challengeForm = document.getElementById("challenge-form");
const markReadButton = document.getElementById("mark-read-button");
@ -950,7 +1163,10 @@
await loadMessages({ showFeedback: false });
}
// Initialisierungsfunktion global registrieren
window.initMessagesPage = initMessagesPage;
// Statusaktualisierung global anbieten
window.updateMessagesNavState = function () {
loadMessages({ showFeedback: false }).catch((error) => {
console.error("Nachrichtenstatus konnte nicht geladen werden:", error);
@ -958,6 +1174,7 @@
});
};
// Polling starten, um im Hintergrund alle 30s den Status (z.B. ungelesene Nachrichten) zu prüfen
document.addEventListener("DOMContentLoaded", () => {
window.updateMessagesNavState();
if (!messagePollingInterval) {
@ -968,3 +1185,4 @@
}
});
})();
;

View File

@ -1,28 +1,51 @@
/**
* Navigation und dynamisches Laden von Einzelseiten (Single Page App Verhalten).
* Dieses Skript reagiert auf Klicks im Menü/Sidebar, lädt die gewünschte HTML-Teilseite
* per Fetch-API nach und führt die jeweilige Seite-Initialisierungsfunktion aus.
*/
document.addEventListener("DOMContentLoaded", () => {
/**
* Setzt die "active"-Klasse im Menü für die ausgewählte Seite,
* damit der Benutzer sieht, in welchem Bereich er sich befindet.
* @param {string} id - Die ID des Menü-Links (z.B. "nav-home").
*/
function setActiveMenu(id) {
//Alle Sidebar-Links zurücksetzen
// Alle Sidebar-Links zurücksetzen
document.querySelectorAll("#sidebar .nav-link").forEach(link => link.classList.remove("active"));
// Alle Navbar-Links zurücksetzen
document.querySelectorAll(".navbar-nav .nav-link").forEach(link => link.classList.remove("active"));
//Aktiven Link setzen
// Aktiven Link anhand seiner ID suchen und hervorheben
const activeLink = document.getElementById(id);
if (activeLink) activeLink.classList.add("active");
}
// Laedt Teilseiten dynamisch in den Hauptbereich.
// Danach wird die passende Init-Funktion aufgerufen, weil die Elemente erst nach dem Laden existieren.
/**
* Lädt eine Teilseite (.html) dynamisch aus dem "pages/"-Ordner in den Hauptbereich.
* Nach dem Laden werden die DOM-Elemente initialisiert (z.B. Event-Listener gebunden),
* indem die entsprechende globale "init..."-Funktion aufgerufen wird.
* @param {string} page - Der Name der HTML-Datei (ohne Endung, z.B. "play").
* @param {string} menuId - Die ID des zugehörigen Menüpunkts zur visuellen Aktivierung.
*/
window.loadPage = function loadPage(page, menuId) {
// fetch lädt das HTML-Snippet. cache: "no-store" verhindert veraltete Stände im Browser-Cache.
fetch("pages/" + page + ".html", { cache: "no-store" })
.then(response => {
if (!response.ok) {
throw new Error("HTTP " + response.status);
throw new Error("HTTP-Fehler " + response.status);
}
return response.text();
})
.then(data => {
// Das geladene HTML-Fragment in den Haupt-Container rendern
document.getElementById("main-content").innerHTML = data;
// Aktiven Menüpunkt visuell kennzeichnen
setActiveMenu(menuId);
// Da das DOM nun neu aufgebaut wurde, müssen wir die Event-Handler
// für die jeweilige Seite neu registrieren (Init-Funktionen).
if (page === "login" && typeof window.initLoginPage === "function") {
window.initLoginPage();
}
@ -41,11 +64,13 @@ document.addEventListener("DOMContentLoaded", () => {
})
.catch(error => {
console.error("Fehler beim Laden von " + page + ":", error);
// Fehlermeldung für den Benutzer in den Hauptbereich einbetten
document.getElementById("main-content").innerHTML = "<div class='alert alert-danger'>Fehler beim Laden der Seite: " + error.message + "</div>";
});
};
// Navigation wird zentral verdrahtet, damit alle Seiten ueber denselben Lade-Mechanismus laufen.
// --- Menü-Verkabelung ---
// Alle Navigations-Elemente aus dem DOM abrufen
const navHome = document.getElementById("nav-home");
const navPlay = document.getElementById("nav-play");
const navMyScores = document.getElementById("nav-my-scores");
@ -53,14 +78,18 @@ document.addEventListener("DOMContentLoaded", () => {
const navbarLogin = document.getElementById("navbar-login");
const navbarMessages = document.getElementById("navbar-messages");
// Standard-Verhalten der Links (Seiten-Reload) verhindern und loadPage() aufrufen
if (navHome) navHome.onclick = (e) => { e.preventDefault(); loadPage("home", "nav-home"); };
if (navPlay) navPlay.onclick = (e) => { e.preventDefault(); loadPage("play", "nav-play"); };
if (navMyScores) navMyScores.onclick = (e) => { e.preventDefault(); loadPage("scores", "nav-my-scores"); };
if (navLeaderboard) navLeaderboard.onclick = (e) => { e.preventDefault(); loadPage("leaderboard", "nav-leaderboard"); };
if (navbarLogin) navbarLogin.onclick = (e) => { e.preventDefault(); loadPage("login", "navbar-login"); };
if (navbarMessages) navbarMessages.onclick = (e) => { e.preventDefault(); loadPage("messages", "navbar-messages"); };
// Prüfen, ob neue ungelesene Nachrichten vorliegen, um den Nachrichten-Link rot zu markieren
if (typeof window.updateMessagesNavState === "function") window.updateMessagesNavState();
//Startseite laden
// Beim allerersten Aufruf der App die Startseite ("home") laden
loadPage("home", "nav-home");
});

View File

@ -1,10 +1,22 @@
/**
* Spiellogik (Lorem Ipsum Merk- und Schreibspiel).
* Dieses Modul verwaltet die verschiedenen Phasen des Spiels (Startansicht, Einprägen, Eingabe, Auswertung),
* berechnet die Punktzahl durch Wortvergleich und speichert das Ergebnis im Backend.
* Unterstützt auch das Duell-System (Challenges) zwischen zwei Benutzern.
*/
(function () {
// --- Konfiguration ---
// --- Konfiguration & Konstanten ---
// Die Zeit in Sekunden, die der Spieler hat, um sich den Text einzuprägen.
const MEMORIZE_TIME_SECONDS = 15;
// Storage-Schlüssel, unter dem eine aktive Challenge im SessionStorage zwischengespeichert wird.
const ACTIVE_CHALLENGE_STORAGE_KEY = "loremIpsumActiveChallenge";
// Präfix zur Kennzeichnung von eingebetteten JSON-Challenge-Daten in Textnachrichten.
const CHALLENGE_DATA_PREFIX = "[[loremIpsumChallenge:";
// Bausteine fuer den zufaelligen Rundentext. Alles bleibt lokal, damit das Spiel ohne Backend starten kann.
// Bausteine für den zufälligen Rundentext. Alles bleibt lokal, damit das Spiel ohne Backend starten kann.
const TEXT_PARTS = {
subjects: [
"Der flinke Entwickler",
@ -56,37 +68,48 @@
],
};
let timerInterval;
let currentTime = 0;
let inputStartMs = null;
// --- Spielstatus Variablen ---
let timerInterval; // Intervall-ID für den Memorize-Timer
let currentTime = 0; // Aktueller Countdown-Stand
let inputStartMs = null; // Zeitstempel (ms), wann der Benutzer mit der Eingabe begonnen hat
// Der aktuell angezeigte Text muss bis zur Auswertung stabil bleiben.
let currentGameText = "";
let lastGeneratedText = "";
let lastGeneratedText = ""; // Verhindert direkt aufeinanderfolgende gleiche Texte
// DOM-Referenzen werden erst gesetzt, nachdem pages/play.html dynamisch geladen wurde.
let phaseStart;
let phaseMemorize;
let phaseInput;
let phaseResult;
let targetTextDisplay;
let timerDisplay;
let userTextInput;
let resultScore;
let resultOriginal;
let resultInput;
let gameStatus;
let scoreSaveFeedback;
let btnSubmitScore;
let activeChallenge = null;
// DOM-Referenzen (werden in initPlayPage gesucht, da play.html dynamisch nachgeladen wird)
let phaseStart; // Start-Container (mit "Starten"-Button)
let phaseMemorize; // Einpräge-Container (zeigt den Text und Countdown)
let phaseInput; // Eingabe-Container (Textarea)
let phaseResult; // Ergebnis-Container (Vergleich und erreichte Punkte)
let targetTextDisplay; // Element zur Anzeige des zu merkenden Textes
let timerDisplay; // Element zur Anzeige des Countdowns
let userTextInput; // Textarea für die Benutzereingabe
let resultScore; // Element zur Anzeige der Punktzahl im Ergebnis
let resultOriginal; // Container für farblich markierten Originaltext
let resultInput; // Container für farblich markierten eingegebenen Text
let gameStatus; // Badge-Element zur Statusanzeige (z.B. "Lernphase", "Eingabe")
let scoreSaveFeedback; // Container für Rückmeldungen beim Speichern
let btnSubmitScore; // Button "Auswerten & Absenden"
let activeChallenge = null; // Enthält geladene Challenge-Daten (falls Spiel aus Inbox gestartet)
// --- Funktionen ---
// --- Hilfsfunktionen ---
/**
* Wählt ein zufälliges Element aus einem Array aus.
* @param {Array} items - Das Quell-Array.
* @returns {*} Ein zufälliges Element.
*/
function getRandomItem(items) {
return items[Math.floor(Math.random() * items.length)];
}
// Erstellt pro Runde zwei zufaellige Saetze plus Schluss-Satz und vermeidet direkte Wiederholungen.
/**
* Erstellt pro Runde zwei zufällige Hauptsätze plus einen Schlusssatz.
* Stellt sicher, dass sich zwei aufeinanderfolgende Runden-Texte unterscheiden.
* @returns {string} Ein generierter Satz.
*/
function generateGameText() {
let generatedText = "";
@ -119,15 +142,19 @@
return generatedText;
}
/**
* Startet eine neue Spielrunde. Blendet die Startphase aus und startet den Countdown.
*/
function startGame() {
if (!phaseStart || !phaseMemorize) return;
inputStartMs = null;
inputStartMs = null; // Zeitstempel zurücksetzen
// Startansicht ausblenden und den neu generierten Text fuer die Lernphase anzeigen.
// Startansicht ausblenden und den Einpräge-Bildschirm anzeigen
phaseStart.classList.add("d-none");
phaseMemorize.classList.remove("d-none");
// Status-Badge aktualisieren
if (gameStatus) {
gameStatus.textContent = "Lernphase";
gameStatus.className = "badge fs-6 px-3 py-2";
@ -135,10 +162,11 @@
gameStatus.style.color = "#1b1b2f";
}
// Text ermitteln (aus Challenge oder neu generiert) und anzeigen
currentGameText = getRoundText();
if (targetTextDisplay) targetTextDisplay.textContent = currentGameText;
// Nach Ablauf des Timers wird automatisch zur Eingabe gewechselt.
// Countdown-Timer starten
currentTime = MEMORIZE_TIME_SECONDS;
if (timerDisplay) timerDisplay.textContent = currentTime;
@ -146,21 +174,26 @@
currentTime--;
if (timerDisplay) timerDisplay.textContent = currentTime;
// Nach Ablauf des Timers automatisch in die Eingabephase wechseln
if (currentTime <= 0) {
endMemorizePhase();
}
}, 1000);
}
/**
* Beendet die Einprägephase, stoppt den Timer und öffnet das Eingabefeld.
*/
function endMemorizePhase() {
clearInterval(timerInterval);
if (!phaseMemorize || !phaseInput) return;
// Text verschwindet, Eingabefeld erscheint: ab hier zaehlt nur noch das Gedaechtnis.
// Text ausblenden (wichtig gegen Abschreiben!) und Eingabefeld zeigen
phaseMemorize.classList.add("d-none");
phaseInput.classList.remove("d-none");
// Status-Badge anpassen
if (gameStatus) {
gameStatus.textContent = "Eingabe";
gameStatus.className = "badge fs-6 px-3 py-2";
@ -168,15 +201,22 @@
gameStatus.style.color = "#fff";
}
// Fokus ins Textfeld setzen, damit der Spieler sofort tippen kann
if (userTextInput) {
userTextInput.value = "";
userTextInput.focus();
}
// Zeitmessung für die Eingabedauer starten
inputStartMs = Date.now();
}
// Entfernt alles, was beim Vergleichen nicht zaehlen soll.
/**
* Normalisiert ein einzelnes Wort für den fehlertoleranten Vergleich.
* Konvertiert zu Kleinbuchstaben, löst Umlaute/ß auf und entfernt Satzzeichen.
* @param {string} word - Das zu bereinigende Wort.
* @returns {string} Das normalisierte Wort.
*/
function normalizeWord(word) {
return word
.toLowerCase()
@ -184,18 +224,31 @@
.replace(/ö/g, "oe")
.replace(/ü/g, "ue")
.replace(/ß/g, "ss")
// Entfernt Satz- und Sonderzeichen
.replace(/[.,\/#!$%\^&\*;:{}=\-_`~()]/g, "");
}
// Behält die sichtbaren Woerter separat, damit Satzzeichen in der Ergebnisanzeige erhalten bleiben.
/**
* Zerlegt einen Text anhand von Leerzeichen in ein Array von Wörtern.
* Leere Elemente (z.B. durch mehrfache Leerzeichen) werden ausgefiltert.
* @param {string} text - Der Eingabetext.
* @returns {Array<string>} Liste der Wörter.
*/
function getWords(text) {
return text.split(/\s+/).filter((word) => word.length > 0);
}
/**
* Berechnet die Punktzahl auf Basis der korrekt geschriebenen Wörter.
* Es wird die exakte Position der Wörter verglichen (Wort an Index i).
* @param {string} original - Der vorgegebene Originaltext.
* @param {string} input - Die Eingabe des Spielers.
* @returns {number} Anzahl der übereinstimmenden Wörter.
*/
function calculateScore(original, input) {
if (!original || !input) return 0;
// Score-Regel: gleiche Woerter an gleicher Position, Satzzeichen und Grossschreibung ignoriert.
// Beide Texte in normalisierte Wort-Listen zerlegen
const cleanOriginal = getWords(original)
.map(normalizeWord)
.filter((word) => word.length > 0);
@ -204,6 +257,7 @@
.filter((word) => word.length > 0);
let correctWords = 0;
// Nur bis zum kürzeren der beiden Texte vergleichen, um Out-of-Bounds zu vermeiden
const limit = Math.min(cleanOriginal.length, cleanInput.length);
for (let i = 0; i < limit; i++) {
@ -215,7 +269,12 @@
return correctWords;
}
// Baut ein einzelnes farbiges Wort-Label fuer den Ergebnisvergleich.
/**
* Erstellt ein farbiges Badge-Element für die Ergebnisanzeige.
* @param {string} word - Das darzustellende Wort.
* @param {boolean} isCorrect - Bestimmt die Farbe (Grün für korrekt, Rot für falsch).
* @returns {HTMLElement} Das erstellte span-Element.
*/
function createWordBadge(word, isCorrect) {
const badge = document.createElement("span");
badge.className =
@ -224,7 +283,11 @@
return badge;
}
// Markiert Original und Eingabe nach derselben Positionslogik wie calculateScore().
/**
* Visualisiert den Wortvergleich zwischen Original und Eingabe im Ergebnisbereich.
* @param {string} original - Der Originaltext.
* @param {string} input - Der eingegebene Text.
*/
function renderWordComparison(original, input) {
if (!resultOriginal || !resultInput) return;
@ -234,14 +297,14 @@
resultOriginal.innerHTML = "";
resultInput.innerHTML = "";
// Original: rot, wenn das eingegebene Wort an dieser Position fehlt oder falsch ist.
// Originaltext-Badges: Rot markieren, wenn an dieser Position das Wort fehlt oder falsch eingegeben wurde
originalWords.forEach((word, index) => {
const isCorrect =
normalizeWord(word) === normalizeWord(inputWords[index] || "");
resultOriginal.appendChild(createWordBadge(word, isCorrect));
});
// Eingabe: rot, wenn das Wort nicht zum Originalwort an derselben Position passt.
// Eingegebene Badges: Rot markieren, wenn das Wort an dieser Stelle nicht zum Original passt
inputWords.forEach((word, index) => {
const isCorrect =
normalizeWord(word) === normalizeWord(originalWords[index] || "");
@ -249,6 +312,9 @@
});
}
/**
* Holt die aktuellen Authentifizierungsdaten.
*/
function getAuth() {
if (!window.AppAuth || typeof window.AppAuth.getAuth !== "function") {
return null;
@ -257,6 +323,9 @@
return window.AppAuth.getAuth();
}
/**
* Holt eine Instanz des ScoreService.
*/
function getScoreService() {
if (!window.config || !window.ScoreService) {
return null;
@ -265,6 +334,9 @@
return new window.ScoreService(window.config);
}
/**
* Holt eine Instanz des ChallengeService.
*/
function getChallengeService() {
if (!window.config || !window.ChallengeService) {
return null;
@ -273,6 +345,9 @@
return new window.ChallengeService(window.config);
}
/**
* Holt eine Instanz des MessageService.
*/
function getMessageService() {
if (!window.config || !window.MessageService) {
return null;
@ -281,9 +356,13 @@
return new window.MessageService(window.config);
}
/**
* Liest eine im SessionStorage hinterlegte aktive Challenge aus.
* @returns {Object|null} Challenge-Datenobjekt oder null.
*/
function readActiveChallenge() {
// Challenge-Runden werden aus der Nachrichten-Seite gestartet.
// Der dafuer gespeicherte Kontext entscheidet, welcher API-/Nachrichten-Flow nach dem Spiel laeuft.
// Challenge-Runden werden auf der Nachrichten-Seite gestartet.
// Der gespeicherte Kontext entscheidet über den API-Fluss nach Spielende.
const raw = sessionStorage.getItem(ACTIVE_CHALLENGE_STORAGE_KEY);
if (!raw) {
return null;
@ -301,44 +380,66 @@
}
}
/**
* Prüft, ob aktuell eine aktive Herausforderung vorliegt.
*/
function hasActiveChallenge() {
return Boolean(activeChallenge && activeChallenge.id !== undefined && activeChallenge.id !== null);
}
/**
* Prüft, ob es sich um die zweite Runde einer Challenge handelt.
* In der zweiten Runde spielt der Herausforderer gegen den Score des Gegners.
*/
function isChallengeSecondRound() {
// In der zweiten Runde spielt der Herausforderer gegen den bereits bekannten Gegner-Score.
return hasActiveChallenge()
&& activeChallenge.role === "challenger"
&& activeChallenge.opponentScore !== null
&& activeChallenge.opponentScore !== undefined;
}
/**
* Prüft, ob es sich um die erste Runde einer Challenge handelt.
* In der ersten Runde spielt der herausgeforderte User und sendet danach sein Ergebnis.
*/
function isChallengeFirstRound() {
// In der ersten Runde spielt der herausgeforderte User und sendet sein Resultat an den Herausforderer.
return hasActiveChallenge() && activeChallenge.role === "opponent";
}
/**
* Schreibt den aktuellen Challenge-Kontext in den SessionStorage.
*/
function writeActiveChallenge(challenge) {
sessionStorage.setItem(ACTIVE_CHALLENGE_STORAGE_KEY, JSON.stringify(challenge));
}
/**
* Ermittelt den Text für die Spielrunde.
* Bei Challenges müssen beide Spieler denselben Text erhalten, welcher aus dem Kontext ausgelesen wird.
* @returns {string} Der Rundentext.
*/
function getRoundText() {
// Bei Challenges spielen beide User mit demselben Text aus dem Challenge-Kontext.
// Falls alte Challenges diesen Text noch nicht haben, gibt es weiterhin einen lokalen Fallback.
if (!activeChallenge) {
return generateGameText();
}
// Wenn ein fester Challenge-Text existiert, wird dieser verwendet
if (typeof activeChallenge.challengeText === "string" && activeChallenge.challengeText.trim()) {
return activeChallenge.challengeText;
}
// Falls bei einer alten Challenge kein Text hinterlegt war, generieren wir einen und speichern ihn im Kontext
const generatedText = generateGameText();
activeChallenge.challengeText = generatedText;
writeActiveChallenge(activeChallenge);
return generatedText;
}
/**
* Zeigt Feedback zum Speicherstatus des Scores (z. B. "Score erfolgreich gespeichert").
* @param {string} message - Der Feedbacktext.
* @param {string} type - Der Bootstrap-Alert-Typ (danger, warning, success, info).
*/
function showScoreSaveFeedback(message, type) {
if (!scoreSaveFeedback) return;
@ -347,9 +448,11 @@
scoreSaveFeedback.classList.remove("d-none");
}
/**
* Speichert das Ergebnis eines normalen Spiels im Backend.
* @param {Object} scoreData - Das Score-Datenobjekt.
*/
async function saveScore(scoreData) {
// Normale Spielrunden werden direkt im Score-/Leaderboard-Backend gespeichert.
// Challenge-Runden verwenden je nach Rolle einen separaten Ablauf weiter unten.
const auth = getAuth();
if (!auth || !auth.username || !auth.password) {
showScoreSaveFeedback(
@ -359,7 +462,6 @@
return;
}
// Auth-Daten kommen aus login.js; der ScoreService setzt daraus die Backend-Header.
const scoreService = getScoreService();
if (!scoreService) {
showScoreSaveFeedback(
@ -406,9 +508,12 @@
);
}
/**
* Zeigt das Endergebnis eines Duells direkt im Browser an (Sieg, Niederlage, Unentschieden).
* Wird nach der finalen Runde einer Challenge ausgeführt.
* @param {number} score - Die eigene Punktzahl.
*/
function renderChallengeResult(score) {
// Zeigt das lokale Ergebnis der finalen Challenge-Runde sofort an,
// unabhaengig davon, ob der Backend-Abschluss erfolgreich ist.
if (!scoreSaveFeedback || !isChallengeSecondRound()) {
return;
}
@ -421,12 +526,15 @@
const auth = getAuth();
const ownName = auth?.username || "Du";
const opponentName = activeChallenge.opponent;
// Sieger ermitteln
const result = score > opponentScore
? "winner"
: score < opponentScore
? "loser"
: "draw";
// Texte und Bilder je nach Ausgang des Spiels konfigurieren
const headline = result === "winner"
? "Du gewinnst die Challenge"
: result === "loser"
@ -443,6 +551,7 @@
? "image/verloren.png"
: "image/unentschieden.png";
// Ergebnisgrafik zusammenbauen
const graphic = document.createElement("div");
graphic.className = "play-challenge-result play-challenge-result-" + result;
@ -479,12 +588,18 @@
scores.append(ownScore, otherScore);
graphic.append(outcomeHeader, title, scores);
// Grafik unter dem Speicher-Feedback einfügen
scoreSaveFeedback.insertAdjacentElement("afterend", graphic);
}
/**
* Sendet den Abschluss einer Challenge an das dafür vorgesehene Challenge-Backend.
* Falls dieser Endpoint nicht existiert (altes Backend), liefert die Funktion false.
* @param {Object} scoreData - Das Score-Datenobjekt.
* @returns {Promise<boolean>} True, falls das Speichern im Challenge-Backend erfolgreich war.
*/
async function completeChallenge(scoreData) {
// Primaerer Abschluss ueber das Challenge-Backend.
// Wenn dieser Endpoint ablehnt, faellt submitScore auf den Nachrichten-Fallback zurueck.
const auth = getAuth();
if (!auth || !hasActiveChallenge()) {
return;
@ -510,10 +625,11 @@
);
if (!result.ok) {
console.warn("Challenge-Abschluss nicht moeglich, nutze Nachrichten-Fallback. Status:", result.status);
console.warn("Challenge-Abschluss über API nicht möglich, nutze Nachrichten-Fallback. Status:", result.status);
return false;
}
// Challenge erfolgreich abgeschlossen
sessionStorage.removeItem(ACTIVE_CHALLENGE_STORAGE_KEY);
activeChallenge = null;
showScoreSaveFeedback(
@ -528,9 +644,15 @@
return true;
}
/**
* Ablauf für die ERSTE Runde einer Challenge (der Geforderte hat gespielt).
* Der Score wird normal gespeichert und der Herausforderer wird benachrichtigt.
* @param {Object} scoreData - Die Score-Daten des Geforderten.
*/
async function finishFirstChallengeRound(scoreData) {
// Der Gegner speichert zuerst seinen Score und informiert danach den Herausforderer.
// Score im normalen Ranking ablegen
await saveScore(scoreData);
// Dem Herausforderer eine Nachricht schicken mit den erreichten Punkten
await notifyChallenger(scoreData);
sessionStorage.removeItem(ACTIVE_CHALLENGE_STORAGE_KEY);
@ -542,14 +664,20 @@
}
}
/**
* Benachrichtigt den Herausforderer per Inbox-Nachricht über das eigene Ergebnis.
* Da der Challenge-Ablauf über Nachrichten läuft, betten wir die Spieldaten
* als JSON-String in die Textnachricht ein.
* @param {Object} scoreData - Die erzielten Spieldaten.
*/
async function notifyChallenger(scoreData) {
// Nutzt den bestehenden Nachrichten-Endpunkt, um dem Herausforderer den Gegner-Score zu senden.
const auth = getAuth();
const messageService = getMessageService();
if (!auth || !messageService || !activeChallenge.challenger) {
return;
}
// Zu übermittelnde Spieldaten strukturieren
const challengeData = {
id: activeChallenge.id,
challenger: activeChallenge.challenger,
@ -558,6 +686,7 @@
challengeText: scoreData.text,
};
// Betten das JSON verschlüsselt in das Nachrichtenfeld ein
const messageText =
CHALLENGE_DATA_PREFIX +
JSON.stringify(challengeData) +
@ -584,6 +713,12 @@
}
}
/**
* Ermittelt den Namen des Siegers auf Basis der Punktzahlen.
* @param {number} challengerScore - Punkte des Herausforderers.
* @param {number} opponentScore - Punkte des Gegners.
* @returns {string} Der Gewinnername oder "draw" bei Gleichstand.
*/
function getChallengeWinner(challengerScore, opponentScore) {
if (challengerScore > opponentScore) {
return activeChallenge.challenger || getAuth()?.username || "Herausforderer";
@ -596,6 +731,13 @@
return "draw";
}
/**
* Hilfsfunktion zum Senden der Abschlussnachricht (Challenge-Resultat) an den Gegner.
* @param {string} recipient - Der Empfänger der Nachricht.
* @param {Object} resultData - Das Resultatsobjekt.
* @param {string} text - Der Nachrichtentext.
* @returns {Promise<boolean>} True bei Erfolg.
*/
async function sendChallengeResultMessage(recipient, resultData, text) {
const auth = getAuth();
const messageService = getMessageService();
@ -621,18 +763,25 @@
return result.ok;
}
/**
* Fallback-Ablauf für die ZWEITE Runde (Herausforderer beendet das Spiel),
* falls die direkte Complete-API des Backends fehlschlägt.
* Das Ergebnis wird berechnet und per Nachricht an den Gegner gesendet.
* @param {Object} scoreData - Die Spieldaten des Herausforderers.
*/
async function finishFinalChallengeWithMessages(scoreData) {
// Fallback fuer den finalen Challenge-Abschluss:
// Score speichern, Gewinner lokal berechnen und Ergebnis per Nachricht an den Gegner senden.
const auth = getAuth();
if (!auth || !activeChallenge) {
return;
}
// Eigenen Score speichern
await saveScore(scoreData);
const opponentScore = Number(activeChallenge.opponentScore);
const winner = getChallengeWinner(scoreData.score, opponentScore);
// Resultat-JSON zusammenbauen
const resultData = {
id: activeChallenge.id,
challenger: activeChallenge.challenger || auth.username,
@ -659,6 +808,7 @@
winnerText +
".";
// Das Ergebnis per Message an den Gegner übermitteln
const opponentMessageSent = await sendChallengeResultMessage(
activeChallenge.opponent,
resultData,
@ -686,9 +836,11 @@
}
}
/**
* Beendet die Spielrunde, wertet die Eingabe aus, rendert die Fehler
* und leitet die Speicherung je nach Spielmodus ein.
*/
async function submitScore() {
// Gemeinsamer Abschluss fuer normale Spiele und Challenge-Runden.
// Der aktive Challenge-Kontext entscheidet, welcher Speicher-/Nachrichtenfluss verwendet wird.
if (!userTextInput) return;
const userInput = userTextInput.value.trim();
@ -697,14 +849,16 @@
return;
}
// Button sperren gegen Doppelklicks
if (btnSubmitScore) {
btnSubmitScore.disabled = true;
btnSubmitScore.textContent = "Wird ausgewertet...";
}
// Punktzahl berechnen
const score = calculateScore(currentGameText, userInput);
// Ergebnis sofort anzeigen; das Speichern im Backend passiert danach asynchron.
// Eingabebereich ausblenden und Ergebnisbereich einblenden
if (phaseInput) phaseInput.classList.add("d-none");
if (phaseResult) phaseResult.classList.remove("d-none");
@ -715,15 +869,16 @@
gameStatus.style.color = "#fff";
}
// Ergebnis anzeigen
if (resultScore) resultScore.textContent = score;
renderWordComparison(currentGameText, userInput);
renderChallengeResult(score);
// Dauer der Eingabe ermitteln (mindestens 1 Sekunde)
const inputDurationSeconds = inputStartMs
? Math.max(1, Math.round((Date.now() - inputStartMs) / 1000))
: MEMORIZE_TIME_SECONDS;
// Genau dieser Rundentext wird gespeichert, damit Leaderboard/Score-Details nachvollziehbar bleiben.
const scoreData = {
score: score,
time: inputDurationSeconds,
@ -733,21 +888,26 @@
try {
if (isChallengeFirstRound()) {
// Erste Runde des Duells
showScoreSaveFeedback("Resultat wird an den Herausforderer gesendet...", "info");
await finishFirstChallengeRound(scoreData);
} else if (hasActiveChallenge()) {
// Finale Runde des Duells
showScoreSaveFeedback("Challenge-Resultat wird gesendet...", "info");
let completed = false;
try {
// Versuchen über die offizielle API abzuschließen
completed = await completeChallenge(scoreData);
} catch (error) {
console.warn("Challenge-Abschluss fehlgeschlagen, nutze Nachrichten-Fallback.", error);
}
if (!completed) {
// Falls API fehlschlägt, auf den Nachrichten-Ablauf ausweichen
showScoreSaveFeedback("Backend-Abschluss nicht moeglich. Ergebnis wird ueber Nachrichten gesendet...", "info");
await finishFinalChallengeWithMessages(scoreData);
}
} else {
// Normales Spiel ohne Duell
await saveScore(scoreData);
}
} catch (error) {
@ -764,12 +924,15 @@
}
}
/**
* Globale Initialisierungsfunktion, aufgerufen von navigation.js
* nach dem Laden von play.html.
*/
window.initPlayPage = function initPlayPage() {
// Die Spielseite wird dynamisch geladen; daher werden DOM-Elemente und Events hier initialisiert.
clearInterval(timerInterval);
activeChallenge = readActiveChallenge();
clearInterval(timerInterval); // Bestehende Timer vorsichtshalber stoppen
activeChallenge = readActiveChallenge(); // Prüfen, ob wir aus einer Challenge gestartet wurden
// Die Navigation laedt play.html per fetch; deshalb werden die Elemente erst hier gesucht.
// DOM-Referenzen holen
phaseStart = document.getElementById("phaseStart");
phaseMemorize = document.getElementById("phaseMemorize");
phaseInput = document.getElementById("phaseInput");
@ -789,6 +952,7 @@
const btnRestart = document.getElementById("btnRestart");
const btnLeaderboard = document.getElementById("btnLeaderboard");
// Falls eine Challenge aktiv ist, Hinweistext über den Gegner und dessen Score einblenden
const challengeHint = document.getElementById("challengeHint");
if (challengeHint && activeChallenge) {
const opponentScore = activeChallenge.opponentScore !== null && activeChallenge.opponentScore !== undefined
@ -801,6 +965,7 @@
challengeHint.classList.remove("d-none");
}
// Event-Listener binden
if (btnStart) btnStart.addEventListener("click", startGame);
if (btnSubmitScore) btnSubmitScore.addEventListener("click", submitScore);
if (btnRestart)
@ -817,7 +982,9 @@
}
});
// Anti-Cheating Maßnahmen im Eingabebereich einrichten
if (userTextInput) {
// Kopieren, Einfügen, Ausschneiden und Tastenkombinationen sperren
userTextInput.addEventListener("keydown", (e) => {
const key = e.key.toLowerCase();
if (
@ -832,6 +999,8 @@
userTextInput.addEventListener("paste", (e) => e.preventDefault());
userTextInput.addEventListener("copy", (e) => e.preventDefault());
userTextInput.addEventListener("cut", (e) => e.preventDefault());
// Sobald getippt wird, startet die Zeitmessung (falls nicht schon geschehen)
userTextInput.addEventListener("input", () => {
if (!inputStartMs) {
inputStartMs = Date.now();
@ -839,6 +1008,7 @@
});
}
// Textauswahl auf dem zu merkenden Text verhindern, um Kopieren via Maus zu blockieren
if (targetTextDisplay) {
targetTextDisplay.addEventListener("copy", (e) => e.preventDefault());
targetTextDisplay.style.userSelect = "none";

View File

@ -1,151 +1,210 @@
(function () {
function formatTime(seconds) {
if (typeof seconds !== "number" || Number.isNaN(seconds)) {
return "-";
}
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
return `${minutes}:${String(remainingSeconds).padStart(2, "0")}`;
}
function normalizeUsername(username) {
return String(username ?? "").trim().toLowerCase();
}
function getLoggedInAuth() {
if (!window.AppAuth || typeof window.AppAuth.getAuth !== "function") {
return null;
}
const auth = window.AppAuth.getAuth();
if (!auth || !auth.username || !auth.password) {
return null;
}
return auth;
}
function getScoreService() {
if (!window.config || !window.ScoreService) {
return null;
}
return new window.ScoreService(window.config);
}
function getDisplayedRank(entry, index) {
const place = Number(entry?.place);
if (!Number.isNaN(place) && place > 0) {
return place;
}
return index + 1;
}
function setFeedback(message, type) {
const feedback = document.getElementById("scores-feedback");
if (!feedback) {
return;
}
feedback.className = "alert alert-" + type + " mb-4";
feedback.textContent = message;
feedback.classList.remove("d-none");
}
function renderScores(entries) {
const tableBody = document.getElementById("scores-body");
if (!tableBody) {
return;
}
tableBody.innerHTML = "";
if (!Array.isArray(entries) || entries.length === 0) {
tableBody.innerHTML = '<tr><td colspan="4" class="text-center text-muted py-4">Keine eigenen Scores gefunden.</td></tr>';
return;
}
const sortedEntries = entries.slice().sort((a, b) => {
const placeA = Number(a?.place);
const placeB = Number(b?.place);
if (!Number.isNaN(placeA) && !Number.isNaN(placeB) && placeA !== placeB) {
return placeA - placeB;
}
const scoreA = Number(a?.score ?? 0);
const scoreB = Number(b?.score ?? 0);
if (scoreB !== scoreA) {
return scoreB - scoreA;
}
const timeA = Number(a?.time ?? Number.MAX_SAFE_INTEGER);
const timeB = Number(b?.time ?? Number.MAX_SAFE_INTEGER);
return timeA - timeB;
});
sortedEntries.forEach((entry, index) => {
const row = document.createElement("tr");
row.innerHTML = `
<td>${getDisplayedRank(entry, index)}</td>
<td>${entry.username ?? "-"}</td>
<td>${formatTime(entry.time)} min</td>
<td>${entry.score ?? "-"}</td>
`;
tableBody.appendChild(row);
});
}
async function loadMyScores() {
const auth = getLoggedInAuth();
if (!auth || !auth.username) {
setFeedback("Bitte logge dich ein, um deine Scores zu sehen.", "warning");
renderScores([]);
return;
}
const scoreService = getScoreService();
if (!scoreService) {
setFeedback("Score-Service konnte nicht geladen werden.", "danger");
renderScores([]);
return;
}
try {
const result = await scoreService.getScoreByName(auth.username);
if (!result.ok || !Array.isArray(result.body)) {
setFeedback("Deine Scores konnten nicht geladen werden.", "danger");
renderScores([]);
return;
}
const ownScores = result.body.filter((entry) => {
if (!entry || !entry.username) {
return false;
}
return normalizeUsername(entry.username) === normalizeUsername(auth.username);
});
if (ownScores.length === 0) {
setFeedback("Für deinen Account wurden noch keine Scores gefunden.", "info");
}
renderScores(ownScores);
} catch (error) {
console.error("Fehler beim Laden der eigenen Scores:", error);
setFeedback("Deine Scores konnten nicht geladen werden.", "danger");
renderScores([]);
}
}
window.initScoresPage = function initScoresPage() {
loadMyScores().catch((error) => {
console.error("Fehler beim Initialisieren der Scores-Seite:", error);
setFeedback("Deine Scores konnten nicht geladen werden.", "danger");
renderScores([]);
});
};
})();
/**
* Eigene Ergebnisse (Scores) verwalten und anzeigen.
* Dieses Modul lädt nach der Initialisierung die bisherigen Spielergebnisse des
* eingeloggten Benutzers und stellt diese sortiert in einer Tabelle dar.
*/
(function () {
/**
* Formatiert Sekunden in ein lesbares MM:SS Format (z.B. 75 Sekunden -> "1:15").
* @param {number} seconds - Anzahl Sekunden.
* @returns {string} Formatiertes Zeit-String oder "-" bei ungültigem Wert.
*/
function formatTime(seconds) {
if (typeof seconds !== "number" || Number.isNaN(seconds)) {
return "-";
}
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
// padStart stellt sicher, dass Sekunden immer zweistellig ausgegeben werden (z.B. "05" statt "5")
return `${minutes}:${String(remainingSeconds).padStart(2, "0")}`;
}
/**
* Normalisiert einen Benutzernamen (Trimming und Kleinschreibung) für robuste Vergleiche.
* @param {string} username - Der Benutzername.
* @returns {string} Der bereinigte Benutzername.
*/
function normalizeUsername(username) {
return String(username ?? "").trim().toLowerCase();
}
/**
* Holt die aktuellen Authentifizierungsdaten aus dem globalen Auth-Modul.
* @returns {Object|null} Auth-Objekt (mit username/password) oder null, falls nicht angemeldet.
*/
function getLoggedInAuth() {
if (!window.AppAuth || typeof window.AppAuth.getAuth !== "function") {
return null;
}
const auth = window.AppAuth.getAuth();
if (!auth || !auth.username || !auth.password) {
return null;
}
return auth;
}
/**
* Initialisiert und liefert eine Instanz des ScoreService.
* @returns {ScoreService|null} Eine ScoreService-Instanz oder null, falls Konfiguration fehlt.
*/
function getScoreService() {
if (!window.config || !window.ScoreService) {
return null;
}
return new window.ScoreService(window.config);
}
/**
* Ermittelt den anzuzeigenden Rang eines Score-Eintrags.
* Falls das Backend keinen Platz/Rang liefert, wird die Position in der Liste (+1) als Fallback genutzt.
* @param {Object} entry - Der Score-Eintrag.
* @param {number} index - Der Listenindex des Eintrags.
* @returns {number} Der anzuzeigende Rang (1-basiert).
*/
function getDisplayedRank(entry, index) {
const place = Number(entry?.place);
if (!Number.isNaN(place) && place > 0) {
return place;
}
return index + 1;
}
/**
* Blendet eine Feedback-Nachmeldung (Erfolg/Fehler/Info) auf der Oberfläche ein.
* @param {string} message - Der anzuzeigende Text.
* @param {string} type - Der Bootstrap-Alert-Typ (danger, warning, info, success).
*/
function setFeedback(message, type) {
const feedback = document.getElementById("scores-feedback");
if (!feedback) {
return;
}
feedback.className = "alert alert-" + type + " mb-4";
feedback.textContent = message;
feedback.classList.remove("d-none");
}
/**
* Generiert die HTML-Zeilen für die Score-Tabelle aus den geladenen Einträgen.
* Sortiert die Einträge: 1. nach Platzierung (falls vorhanden), 2. nach Score (absteigend), 3. nach Zeit (aufsteigend).
* @param {Array<Object>} entries - Liste von Score-Einträgen vom Backend.
*/
function renderScores(entries) {
const tableBody = document.getElementById("scores-body");
if (!tableBody) {
return;
}
tableBody.innerHTML = "";
// Falls keine Einträge vorhanden sind, Hinweistext ausgeben
if (!Array.isArray(entries) || entries.length === 0) {
tableBody.innerHTML = '<tr><td colspan="4" class="text-center text-muted py-4">Keine eigenen Scores gefunden.</td></tr>';
return;
}
// Kopie des Arrays anlegen und sortieren
const sortedEntries = entries.slice().sort((a, b) => {
// 1. Priorität: Offizielle Platzierung (falls vorhanden)
const placeA = Number(a?.place);
const placeB = Number(b?.place);
if (!Number.isNaN(placeA) && !Number.isNaN(placeB) && placeA !== placeB) {
return placeA - placeB;
}
// 2. Priorität: Höhere Punktzahl gewinnt
const scoreA = Number(a?.score ?? 0);
const scoreB = Number(b?.score ?? 0);
if (scoreB !== scoreA) {
return scoreB - scoreA;
}
// 3. Priorität: Bei Punktgleichheit gewinnt die schnellere Zeit
const timeA = Number(a?.time ?? Number.MAX_SAFE_INTEGER);
const timeB = Number(b?.time ?? Number.MAX_SAFE_INTEGER);
return timeA - timeB;
});
// Für jeden sortierten Eintrag eine Tabellenzeile erstellen und anhängen
sortedEntries.forEach((entry, index) => {
const row = document.createElement("tr");
row.innerHTML = `
<td>${getDisplayedRank(entry, index)}</td>
<td>${entry.username ?? "-"}</td>
<td>${formatTime(entry.time)} min</td>
<td>${entry.score ?? "-"}</td>
`;
tableBody.appendChild(row);
});
}
/**
* Fordert die Scores des Benutzers vom Backend an und filtert sie.
*/
async function loadMyScores() {
const auth = getLoggedInAuth();
// Prüfen, ob der Benutzer überhaupt eingeloggt ist
if (!auth || !auth.username) {
setFeedback("Bitte logge dich ein, um deine Scores zu sehen.", "warning");
renderScores([]);
return;
}
const scoreService = getScoreService();
if (!scoreService) {
setFeedback("Score-Service konnte nicht geladen werden.", "danger");
renderScores([]);
return;
}
try {
// Scores für diesen Namen vom Backend abrufen
const result = await scoreService.getScoreByName(auth.username);
if (!result.ok || !Array.isArray(result.body)) {
setFeedback("Deine Scores konnten nicht geladen werden.", "danger");
renderScores([]);
return;
}
// Sicherheits-Filterung: Nur Scores behalten, die exakt zum aktuell eingeloggten User passen
const ownScores = result.body.filter((entry) => {
if (!entry || !entry.username) {
return false;
}
return normalizeUsername(entry.username) === normalizeUsername(auth.username);
});
if (ownScores.length === 0) {
setFeedback("Für deinen Account wurden noch keine Scores gefunden.", "info");
}
// Scores rendern
renderScores(ownScores);
} catch (error) {
console.error("Fehler beim Laden der eigenen Scores:", error);
setFeedback("Deine Scores konnten nicht geladen werden.", "danger");
renderScores([]);
}
}
/**
* Globale Initialisierungsfunktion, die von navigation.js aufgerufen wird,
* sobald die scores.html-Teilseite erfolgreich geladen wurde.
*/
window.initScoresPage = function initScoresPage() {
loadMyScores().catch((error) => {
console.error("Fehler beim Initialisieren der Scores-Seite:", error);
setFeedback("Deine Scores konnten nicht geladen werden.", "danger");
renderScores([]);
});
};
})();

View File

@ -1,8 +1,14 @@
<!-- Startseite der Anwendung -->
<div class="card mb-4 text-start">
<!-- Willkommens-Überschrift und Kurzbeschreibung des Spiels -->
<div class="mb-4">
<h2 class="fw-bold mb-1">Willkommen beim Lorem Ipsum Game</h2>
<p class="text-muted mb-0 fs-6">Teste deine Fähigkeiten im Umgang mit Lorem Ipsum Texten! Je schneller und genauer du bist, desto höher ist dein Score.</p>
</div>
<!-- Handlungsaufforderung an den Spieler -->
<p class="card-text fs-6">Wähle eine Option aus der Navigation, um zu starten. Viel Spaß beim Spielen!</p>
<!-- Projekt-Logo zentriert im unteren Bereich -->
<img id="logo_img" src="image/Logo_loremIpsum.png" alt="Lorem Ipsum Game" class="img-fluid mt-3 d-block mx-auto">
</div>
</div>

View File

@ -1,14 +1,15 @@
<!-- Hauptbereich der Rangliste -->
<!-- Rangliste (Leaderboard) - Zeigt die globalen Top-10 Spieler an -->
<div class="card mb-4 text-start">
<!-- Header-Bereich mit Titel und Kurzbeschreibung -->
<div class="mb-4">
<h2 class="fw-bold mb-1">Leaderboard</h2>
<p class="text-muted mb-0 fs-6">Hier siehst du ein Leaderboard mit den 10 besten Usern.</p>
</div>
<!-- Tabellarische Darstellung der besten Eintraege -->
<!-- Tabellarische Darstellung der besten Einträge -->
<table class="leaderboard-table">
<thead>
<!-- Spalten fuer Rang, Nutzer, Zeit und Punkte -->
<!-- Spaltenköpfe für Rang, Benutzername, benötigte Zeit und Punktzahl -->
<tr>
<th>Rank</th>
<th>User</th>
@ -16,7 +17,8 @@
<th>Score</th>
</tr>
</thead>
<!-- Wird zur Laufzeit durch js/leaderboard.js befuellt -->
<!-- Tabellenkörper: Wird dynamisch zur Laufzeit durch js/leaderboard.js mit Zeilen befüllt -->
<tbody id="leaderboard-body"></tbody>
</table>
</div>

View File

@ -1,76 +1,81 @@
<!-- Login Seite -->
<div class="card mb-4 text-start">
<div class="mb-4">
<h2 class="fw-bold mb-1">Account</h2>
<p class="text-muted mb-0 fs-6">Verwalte deine Sitzung und Account-Details.</p>
</div>
<div id="auth-feedback" class="alert d-none" role="status" aria-live="polite"></div>
<!-- Logout und Account löschen -->
<div class="card bg-light mb-3" id="current-session-box">
<h3 class="mb-2">Aktuelle Sitzung</h3>
<p class="h5 mb-2" id="current-session-text">Nicht eingeloggt.</p>
<div class="d-flex gap-2">
<button id="logout-button" type="button" class="btn btn-outline-secondary btn-sm" disabled>Logout</button>
<button id="delete-account-button" type="button" class="btn btn-danger btn-sm" disabled>Account löschen</button>
</div>
</div>
<!-- Login und Account erstellen -->
<div class="row g-3" id="auth-forms-row">
<div class="col-12 col-lg-6">
<div class="card h-100">
<h3 class="mb-3">Login</h3>
<form id="login-form">
<div class="h5 mb-3">
<label for="login-username" class="form-label">Username</label>
<input id="login-username" class="form-control" type="text" required maxlength="40" autocomplete="username" />
</div>
<div class="h5 mb-3">
<label for="login-password" class="form-label">Passwort</label>
<input id="login-password" class="form-control" type="password" required maxlength="100" autocomplete="current-password" />
</div>
<button type="submit" class="btn btn-primary">Einloggen</button>
</form>
</div>
</div>
<div class="col-12 col-lg-6">
<div class="card h-100">
<h3 class="mb-3">Neuen Account erstellen</h3>
<form id="register-form">
<div class="h5 mb-3">
<label for="register-username" class="form-label">Gewünschter Username</label>
<input id="register-username" class="form-control" type="text" required maxlength="40" autocomplete="username" />
</div>
<button type="submit" class="btn btn-success">Account anlegen</button>
</form>
<p class="text-muted mb-0 fs-6">Hinweis: Das Passwort wird vom Backend erstellt und bei Erfolg angezeigt.</p>
</div>
</div>
</div>
</div>
<!-- Modal für Account-Erstellung -->
<div class="modal fade" id="password-modal" tabindex="-1" aria-labelledby="password-modal-label" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="password-modal-label">Account erfolgreich erstellt!</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="alert alert-info mb-3">
<strong>Username:</strong> <span id="modal-username"></span><br/>
<strong>Passwort:</strong> <span id="modal-password" style="font-family: monospace; background-color: #f0f0f0; padding: 4px 8px; border-radius: 4px;"></span>
</div>
<p class="text-muted small mb-0">Bitte speichere das Passwort. Du benötigst es zum Einloggen.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">Weiter zur Startseite</button>
</div>
</div>
</div>
</div>
<!-- Account-Verwaltung (Login, Registrierung, Sitzungsanzeige) -->
<div class="card mb-4 text-start">
<!-- Header-Bereich mit Kurzbeschreibung -->
<div class="mb-4">
<h2 class="fw-bold mb-1">Account</h2>
<p class="text-muted mb-0 fs-6">Verwalte deine Sitzung und Account-Details.</p>
</div>
<!-- Alert-Box für Fehlermeldungen und Erfolgsmeldungen (wird per js/login.js gesteuert) -->
<div id="auth-feedback" class="alert d-none" role="status" aria-live="polite"></div>
<!-- Aktuelle Sitzung: Zeigt den Login-Status und bietet Buttons für Logout und Account löschen -->
<div class="card bg-light mb-3" id="current-session-box">
<h3 class="mb-2">Aktuelle Sitzung</h3>
<p class="h5 mb-2" id="current-session-text">Nicht eingeloggt.</p>
<div class="d-flex gap-2">
<button id="logout-button" type="button" class="btn btn-outline-secondary btn-sm" disabled>Logout</button>
<button id="delete-account-button" type="button" class="btn btn-danger btn-sm" disabled>Account löschen</button>
</div>
</div>
<!-- Formular-Bereich: Login und Registrierung nebeneinander (auf Desktop) -->
<div class="row g-3" id="auth-forms-row">
<!-- Linke Spalte: Login-Formular -->
<div class="col-12 col-lg-6">
<div class="card h-100">
<h3 class="mb-3">Login</h3>
<form id="login-form">
<div class="h5 mb-3">
<label for="login-username" class="form-label">Username</label>
<input id="login-username" class="form-control" type="text" required maxlength="40" autocomplete="username" />
</div>
<div class="h5 mb-3">
<label for="login-password" class="form-label">Passwort</label>
<input id="login-password" class="form-control" type="password" required maxlength="100" autocomplete="current-password" />
</div>
<button type="submit" class="btn btn-primary">Einloggen</button>
</form>
</div>
</div>
<!-- Rechte Spalte: Registrierungs-Formular -->
<div class="col-12 col-lg-6">
<div class="card h-100">
<h3 class="mb-3">Neuen Account erstellen</h3>
<form id="register-form">
<div class="h5 mb-3">
<label for="register-username" class="form-label">Gewünschter Username</label>
<input id="register-username" class="form-control" type="text" required maxlength="40" autocomplete="username" />
</div>
<button type="submit" class="btn btn-success">Account anlegen</button>
</form>
<p class="text-muted mb-0 fs-6 mt-2">Hinweis: Das Passwort wird vom Backend erstellt und bei Erfolg angezeigt.</p>
</div>
</div>
</div>
</div>
<!-- Bootstrap Modal: Zeigt das einmalig generierte Passwort nach erfolgreicher Account-Erstellung an -->
<div class="modal fade" id="password-modal" tabindex="-1" aria-labelledby="password-modal-label" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="password-modal-label">Account erfolgreich erstellt!</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<!-- Anzeige der generierten Zugangsdaten -->
<div class="alert alert-info mb-3">
<strong>Username:</strong> <span id="modal-username"></span><br/>
<strong>Passwort:</strong> <span id="modal-password" style="font-family: monospace; background-color: #f0f0f0; padding: 4px 8px; border-radius: 4px;"></span>
</div>
<p class="text-muted small mb-0">Bitte speichere das Passwort. Du benötigst es zum Einloggen.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">Weiter zur Startseite</button>
</div>
</div>
</div>
</div>

View File

@ -1,4 +1,6 @@
<!-- Nachrichten- und Challenge-Dashboard -->
<div class="card messages-card mb-4 text-start">
<!-- Header-Bereich mit Aktualisierungs-Button -->
<div class="messages-header mb-4">
<div>
<h2 class="fw-bold mb-1">Nachrichten</h2>
@ -7,43 +9,53 @@
<button class="btn btn-sm" id="refresh-messages-button" type="button">Aktualisieren</button>
</div>
<!-- Platzhalter: Wird angezeigt, wenn der Benutzer nicht angemeldet ist -->
<div id="messages-login-placeholder" class="alert alert-warning mt-3 d-none">
Melde dich zuerst an, bevor du eine Nachricht verschicken kannst.
</div>
<!-- Hauptbereich der Inbox (Sichtbar wenn eingeloggt) -->
<div id="messages-content" class="d-none">
<!-- Status- und Feedbackanzeige -->
<div id="messages-feedback" class="alert d-none mt-3 mb-0" role="alert"></div>
<div class="row g-4 mt-2">
<!-- Linke Spalte: Sidebar mit der Liste aller verfügbaren Benutzer -->
<div class="col-lg-4">
<section class="messages-panel">
<h3>User</h3>
<div id="messages-user-list" class="messages-user-list"></div>
</section>
</div>
<section class="messages-panel">
<h3>User</h3>
<!-- Wird zur Laufzeit per js/messages.js mit User-Schaltflächen befüllt -->
<div id="messages-user-list" class="messages-user-list"></div>
</section>
</div>
<div class="col-lg-8">
<section class="messages-panel mb-4">
<h3>Challenge senden</h3>
<form id="challenge-form" class="message-form">
<label for="challenge-recipient" class="form-label">Empfaenger</label>
<select id="challenge-recipient" class="form-select mb-3"></select>
<!-- Rechte Spalte: Formular zum Erstellen von Challenges und die Inbox -->
<div class="col-lg-8">
<!-- Sektion: Challenge senden -->
<section class="messages-panel mb-4">
<h3>Challenge senden</h3>
<form id="challenge-form" class="message-form">
<label for="challenge-recipient" class="form-label">Empfaenger</label>
<select id="challenge-recipient" class="form-select mb-3"></select>
<label for="challenge-text" class="form-label">Nachricht</label>
<textarea id="challenge-text" class="form-control mb-3" rows="3">Kannst du mich schlagen? Spiel eine Runde, dann bin ich dran.</textarea>
<button class="btn" type="submit">Challenge senden</button>
</form>
</section>
<label for="challenge-text" class="form-label">Nachricht</label>
<textarea id="challenge-text" class="form-control mb-3" rows="3">Kannst du mich schlagen? Spiel eine Runde, dann bin ich dran.</textarea>
<button class="btn" type="submit">Challenge senden</button>
</form>
</section>
<section class="messages-panel">
<div class="messages-inbox-title">
<h3>Posteingang</h3>
<button class="btn btn-sm" id="mark-read-button" type="button">Als gelesen markieren</button>
</div>
<div id="message-list" class="message-list"></div>
</section>
</div>
<!-- Sektion: Posteingang (Liste der empfangenen/gesendeten Nachrichten) -->
<section class="messages-panel">
<div class="messages-inbox-title">
<h3>Posteingang</h3>
<button class="btn btn-sm" id="mark-read-button" type="button">Als gelesen markieren</button>
</div>
<!-- Wird dynamisch durch js/messages.js gerendert (normale Messages & Challenge-Karten) -->
<div id="message-list" class="message-list"></div>
</section>
</div>
</div>
</div>
</div>

View File

@ -1,19 +1,21 @@
<!-- Spielseite: Die vier Phasen werden per play.js ein- und ausgeblendet. -->
<!-- Spielseite: Die vier Phasen werden dynamisch per play.js ein- und ausgeblendet. -->
<div class="game-container text-start">
<!-- Status-Badge zeigt die aktuelle Spielphase: Bereit, Lernphase, Eingabe, Abgeschlossen. -->
<!-- Status-Header: Zeigt Titel, Challenge-Hinweise und das Status-Badge der aktuellen Spielphase (Bereit, Lernphase, Eingabe, Abgeschlossen) -->
<div class="d-flex justify-content-between align-items-md-center flex-column flex-md-row mb-4">
<div class="mb-3 mb-md-0">
<h2 class="fw-bold mb-1">Lorem Ipsum - Challenge your brain</h2>
<!-- Challenge-Hinweis: Zeigt an, gegen wen und um welchen Score gespielt wird (wird per JS eingeblendet) -->
<div id="challengeHint" class="challenge-hint d-none"></div>
<p class="text-muted mb-0 fs-6">Merken Sie sich den Text so gut wie möglich.</p>
</div>
<!-- Status-Badge der aktuellen Spielphase -->
<div id="gameStatus" class="badge bg-secondary fs-6 px-3 py-2" style="width: fit-content;">
Bereit
</div>
</div>
<!-- Phase 1: Startbildschirm vor der Textanzeige. -->
<!-- Phase 1: Startbildschirm vor der Textanzeige -->
<div id="phaseStart" class="card">
<div class="card-body text-center py-5">
<h3 class="mb-3">Sind Sie bereit?</h3>
@ -27,14 +29,16 @@
</div>
</div>
<!-- Phase 2: Merken. Der zufaellige Rundentext wird in #targetTextDisplay eingesetzt. -->
<!-- Phase 2: Lern-/Einprägephase. Der Runden-Text wird per JS in #targetTextDisplay eingefügt. -->
<div id="phaseMemorize" class="d-none">
<div class="card border-warning" style="border-left: 5px solid #ffd166; background-color: #fffbf0;">
<div class="card-body text-center py-4">
<h4 class="text-warning-emphasis mb-3" style="color: #b58900;">Lernphase läuft...</h4>
<!-- Rundentext-Anzeige -->
<p id="targetTextDisplay" class="lead fw-bold mb-4 fst-italic" style="color: #1b1b2f; font-size: 1.4rem; line-height: 1.6;">
<!-- Text wird per JS eingefügt -->
</p>
<!-- Countdown-Anzeige -->
<div class="mt-3">
<span class="badge bg-danger fs-6 px-3 py-2 blink-animation">
Verbleibende Zeit: <span id="timerDisplay">15</span>s
@ -44,11 +48,12 @@
</div>
</div>
<!-- Phase 3: Eingabe aus dem Gedaechtnis. Der Originaltext ist hier bewusst ausgeblendet. -->
<!-- Phase 3: Eingabephase. Der Originaltext wird ausgeblendet und das Textfeld wird fokussiert. -->
<div id="phaseInput" class="d-none">
<div class="card">
<div class="card-body">
<label for="userTextInput" class="form-label fw-bold mb-2">Geben Sie den Text aus dem Gedächtnis ein:</label>
<!-- Eingabefeld für den gemerkten Text (Cheating-Sperren in play.js verhindern Copy/Paste) -->
<textarea id="userTextInput" class="form-control mb-3" rows="8" placeholder="Tippen Sie hier den gemerkten Text ein..."></textarea>
<div class="d-grid">
<button id="btnSubmitScore" class="btn btn-success btn-lg" style="background-color: #28a745; border: none;">
@ -59,23 +64,28 @@
</div>
</div>
<!-- Phase 4: Auswertung inklusive Backend-Feedback und Vergleich Original/Eingabe. -->
<!-- Phase 4: Auswertungs- und Ergebnisphase (inklusive API-Feedback und farbigem Wortvergleich) -->
<div id="phaseResult" class="d-none">
<div class="card text-center" style="border-top: 5px solid #28a745;">
<div class="card-body py-5">
<h3 class="text-success mb-2">Ergebnis</h3>
<!-- Erzielte Punktzahl -->
<div class="display-1 fw-bold my-4" id="resultScore" style="color: #4a6fa5;">0</div>
<p class="text-muted mb-5">Punkte (korrekte Wörter an der richtigen Position)</p>
<!-- Wird von saveScore() befuellt: gespeichert, nur lokal berechnet oder Fehler. -->
<!-- Feedback-Box für API-Antworten (wird per JS befüllt) -->
<div id="scoreSaveFeedback" class="alert d-none mb-4" role="alert"></div>
<!-- Wortvergleich: Gegenüberstellung des Originaltexts und der Eingabe -->
<div class="row g-4 text-start mb-5">
<!-- Originaltext mit rot/grünen Wort-Badges -->
<div class="col-md-6">
<div class="p-3 h-100" style="background: #f8f9fa; border-radius: 8px;">
<small class="text-muted d-block mb-2 text-uppercase fw-bold" style="font-size: 0.75rem;">Originaltext</small>
<p class="mb-0 fst-italic" id="resultOriginal" style="font-size: 0.95rem; line-height: 1.5;"></p>
</div>
</div>
<!-- Benutzereingabe mit rot/grünen Wort-Badges -->
<div class="col-md-6">
<div class="p-3 h-100" style="background: #f8f9fa; border-radius: 8px;">
<small class="text-muted d-block mb-2 text-uppercase fw-bold" style="font-size: 0.75rem;">Ihre Eingabe</small>
@ -84,6 +94,7 @@
</div>
</div>
<!-- Navigation nach dem Spiel -->
<div class="d-flex justify-content-center gap-3">
<button id="btnRestart" class="btn px-4">
Nochmal spielen
@ -98,3 +109,5 @@
</div>
</div>

View File

@ -1,26 +1,31 @@
<div class="card mb-4 text-start">
<div class="mb-4">
<h2 class="fw-bold mb-1">Meine Scores</h2>
<p class="text-muted mb-0 fs-6">Hier siehst du alle gespeicherten Scores deines Accounts.</p>
</div>
<div id="scores-feedback" class="alert d-none mb-4" role="alert"></div>
<div class="table-responsive">
<table class="table leaderboard-table mb-0">
<thead>
<tr>
<th scope="col">Rang</th>
<th scope="col">User</th>
<th scope="col">time</th>
<th scope="col">Score</th>
</tr>
</thead>
<tbody id="scores-body">
<tr>
<td colspan="4" class="text-center text-muted py-4">Lade Scores ...</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Eigene Scores-Ansicht des angemeldeten Benutzers -->
<div class="card mb-4 text-start">
<!-- Header-Bereich mit Titel und Kurzbeschreibung -->
<div class="mb-4">
<h2 class="fw-bold mb-1">Meine Scores</h2>
<p class="text-muted mb-0 fs-6">Hier siehst du alle gespeicherten Scores deines Accounts.</p>
</div>
<!-- Alert-Box für Rückmeldungen (wird bei Fehlern/Info-Meldungen per js/scores.js eingeblendet) -->
<div id="scores-feedback" class="alert d-none mb-4" role="alert"></div>
<!-- Tabelle zur Auflistung der eigenen Scores -->
<div class="table-responsive">
<table class="table leaderboard-table mb-0">
<thead>
<tr>
<th scope="col">Rang</th>
<th scope="col">User</th>
<th scope="col">time</th>
<th scope="col">Score</th>
</tr>
</thead>
<!-- Tabellenkörper: Wird per js/scores.js mit den eigenen Ergebnissen des Users befüllt -->
<tbody id="scores-body">
<tr>
<td colspan="4" class="text-center text-muted py-4">Lade Scores ...</td>
</tr>
</tbody>
</table>
</div>
</div>