diff --git a/README.md b/README.md index 10e6cf9..77dc15d 100644 --- a/README.md +++ b/README.md @@ -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. | --- diff --git a/assets/src/service/challenge-service.js b/assets/src/service/challenge-service.js index a42fa4d..30b240d 100644 --- a/assets/src/service/challenge-service.js +++ b/assets/src/service/challenge-service.js @@ -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} 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} 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; + diff --git a/assets/src/service/config-service.js b/assets/src/service/config-service.js index afb2d4a..cd68cdb 100644 --- a/assets/src/service/config-service.js +++ b/assets/src/service/config-service.js @@ -1,5 +1,12 @@ -const config = { - API_BASE_URL: "https://webdev.iten-web.ch/10001/api/" -} - -window.config = config; \ No newline at end of file +/** + * 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; \ No newline at end of file diff --git a/assets/src/service/leaderboard-service.js b/assets/src/service/leaderboard-service.js index ccab26d..1aa30f1 100644 --- a/assets/src/service/leaderboard-service.js +++ b/assets/src/service/leaderboard-service.js @@ -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} 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; + diff --git a/assets/src/service/message-service.js b/assets/src/service/message-service.js index 7522750..72997e5 100644 --- a/assets/src/service/message-service.js +++ b/assets/src/service/message-service.js @@ -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} 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} 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} 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} 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; + diff --git a/assets/src/service/score-service.js b/assets/src/service/score-service.js index 4204943..41d4162 100644 --- a/assets/src/service/score-service.js +++ b/assets/src/service/score-service.js @@ -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} 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} 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} 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; + diff --git a/assets/src/service/user-service.js b/assets/src/service/user-service.js index 0910e10..0e33c09 100644 --- a/assets/src/service/user-service.js +++ b/assets/src/service/user-service.js @@ -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} 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} 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} 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} 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; + diff --git a/index.html b/index.html index c50eb33..6f22b81 100644 --- a/index.html +++ b/index.html @@ -4,12 +4,15 @@ Lorem Ipsum - Das Spiel - - + + + + + - + - - + +
- + - +
- +

Dashboard

- +
- +
- +
Made with für das Modul Frontend
Made with Bootstrap 5.3.8.
@@ -90,7 +94,7 @@
- +
Impressum @@ -113,6 +117,7 @@
+

© 2026 Modul Frontend Projekt. Alle Rechte vorbehalten. @@ -125,20 +130,26 @@

- + + + + + + - + + diff --git a/js/leaderboard.js b/js/leaderboard.js index 3595811..089f0dd 100644 --- a/js/leaderboard.js +++ b/js/leaderboard.js @@ -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} 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} 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} 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 = ''; 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([]); }); }; + diff --git a/js/login.js b/js/login.js index eb11215..635ed70 100644 --- a/js/login.js +++ b/js/login.js @@ -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: ") 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); })(); + diff --git a/js/messages.js b/js/messages.js index a16a24f..5bbf97b 100644 --- a/js/messages.js +++ b/js/messages.js @@ -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} 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} 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} messages - Liste aller Nachrichten. + * @returns {Array} 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} 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} 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 @@ } }); })(); +; diff --git a/js/navigation.js b/js/navigation.js index 535d123..a940ff4 100644 --- a/js/navigation.js +++ b/js/navigation.js @@ -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 = "
Fehler beim Laden der Seite: " + error.message + "
"; }); }; - // 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"); }); + diff --git a/js/play.js b/js/play.js index 3f73a25..f3b446f 100644 --- a/js/play.js +++ b/js/play.js @@ -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} 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} 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} 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"; diff --git a/js/scores.js b/js/scores.js index 53f1b2f..61dd66f 100644 --- a/js/scores.js +++ b/js/scores.js @@ -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 = 'Keine eigenen Scores gefunden.'; - 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 = ` - ${getDisplayedRank(entry, index)} - ${entry.username ?? "-"} - ${formatTime(entry.time)} min - ${entry.score ?? "-"} - `; - 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([]); - }); - }; -})(); \ No newline at end of file +/** + * 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} 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 = 'Keine eigenen Scores gefunden.'; + 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 = ` + ${getDisplayedRank(entry, index)} + ${entry.username ?? "-"} + ${formatTime(entry.time)} min + ${entry.score ?? "-"} + `; + 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([]); + }); + }; +})(); \ No newline at end of file diff --git a/pages/home.html b/pages/home.html index 0a5496f..de7e7fb 100644 --- a/pages/home.html +++ b/pages/home.html @@ -1,8 +1,14 @@ +
+

Willkommen beim Lorem Ipsum Game

Teste deine Fähigkeiten im Umgang mit Lorem Ipsum Texten! Je schneller und genauer du bist, desto höher ist dein Score.

+

Wähle eine Option aus der Navigation, um zu starten. Viel Spaß beim Spielen!

+ + Lorem Ipsum Game -
\ No newline at end of file + + \ No newline at end of file diff --git a/pages/leaderboard.html b/pages/leaderboard.html index 2d65451..3d70f37 100644 --- a/pages/leaderboard.html +++ b/pages/leaderboard.html @@ -1,14 +1,15 @@ - +
+

Leaderboard

Hier siehst du ein Leaderboard mit den 10 besten Usern.

- + - + @@ -16,7 +17,8 @@ - +
Rank UserScore
+ diff --git a/pages/login.html b/pages/login.html index 5465103..6b3a801 100644 --- a/pages/login.html +++ b/pages/login.html @@ -1,76 +1,81 @@ - -
-
-

Account

-

Verwalte deine Sitzung und Account-Details.

-
- -
- - -
-

Aktuelle Sitzung

-

Nicht eingeloggt.

-
- - -
-
- - -
-
-
-

Login

-
-
- - -
-
- - -
- -
-
-
- -
-
-

Neuen Account erstellen

-
-
- - -
- -
-

Hinweis: Das Passwort wird vom Backend erstellt und bei Erfolg angezeigt.

-
-
-
-
- - - - \ No newline at end of file + +
+ +
+

Account

+

Verwalte deine Sitzung und Account-Details.

+
+ + +
+ + +
+

Aktuelle Sitzung

+

Nicht eingeloggt.

+
+ + +
+
+ + +
+ +
+
+

Login

+
+
+ + +
+
+ + +
+ +
+
+
+ + +
+
+

Neuen Account erstellen

+
+
+ + +
+ +
+

Hinweis: Das Passwort wird vom Backend erstellt und bei Erfolg angezeigt.

+
+
+
+
+ + + + \ No newline at end of file diff --git a/pages/messages.html b/pages/messages.html index 3d07ae8..eeaf709 100644 --- a/pages/messages.html +++ b/pages/messages.html @@ -1,4 +1,6 @@ +
+

Nachrichten

@@ -7,43 +9,53 @@
+
Melde dich zuerst an, bevor du eine Nachricht verschicken kannst.
+
+
+
-
-

User

-
-
-
+
+

User

+ +
+
+
-
-
-

Challenge senden

-
- - + +
+ +
+

Challenge senden

+ + + + + + + + + +
- - - - - -
- -
-
-

Posteingang

- -
-
-
-
+ +
+
+

Posteingang

+ +
+ +
+
+
+ diff --git a/pages/play.html b/pages/play.html index 2c49de0..c4f5147 100644 --- a/pages/play.html +++ b/pages/play.html @@ -1,19 +1,21 @@ - +
- +

Lorem Ipsum - Challenge your brain

+

Merken Sie sich den Text so gut wie möglich.

+
Bereit
- +

Sind Sie bereit?

@@ -27,14 +29,16 @@
- +

Lernphase läuft...

+

+
Verbleibende Zeit: 15s @@ -44,11 +48,12 @@
- +
+
- +

Ergebnis

+
0

Punkte (korrekte Wörter an der richtigen Position)

- + + +
+
Originaltext

+
Ihre Eingabe @@ -84,6 +94,7 @@
+
+
+ diff --git a/pages/scores.html b/pages/scores.html index a33d29e..df507a6 100644 --- a/pages/scores.html +++ b/pages/scores.html @@ -1,26 +1,31 @@ -
-
-

Meine Scores

-

Hier siehst du alle gespeicherten Scores deines Accounts.

-
- - - -
- - - - - - - - - - - - - - -
RangUsertimeScore
Lade Scores ...
-
-
\ No newline at end of file + +
+ +
+

Meine Scores

+

Hier siehst du alle gespeicherten Scores deines Accounts.

+
+ + + + + +
+ + + + + + + + + + + + + + + +
RangUsertimeScore
Lade Scores ...
+
+
\ No newline at end of file