diff --git a/assets/css/custom.css b/assets/css/custom.css index a9b6183..0dbb321 100644 --- a/assets/css/custom.css +++ b/assets/css/custom.css @@ -137,9 +137,9 @@ p { border-radius: 6px; padding: 5px 10px; } -#logo-img { - max-width: 500px; - width: 100%; +#logo_img { + max-width: 300px; + width: 50%; height: auto; } #main-area { @@ -213,3 +213,67 @@ p { } +/* Table styles leaderboard */ +.leaderboard-table { + border-collapse: collapse; + width: 100%; + background: #ffffff; + border-radius: 10px; + overflow: hidden; +} + +.leaderboard-table th, +.leaderboard-table td { + padding: 8px; + text-align: left; + border-bottom: 1px solid #ddd; +} + +.leaderboard-table thead th { + background: #1b1b2f; + color: #ffffff; + font-weight: 600; + letter-spacing: 0.02em; +} + +.leaderboard-table tbody tr { + transition: background-color 0.2s ease; +} + +.leaderboard-table tbody tr:nth-child(even) { + background-color: #f7f9fc; +} + +.leaderboard-table tbody tr:hover { + background-color: #dbe6f7; +} + +.leaderboard-table tbody tr.leaderboard-row-current-user, +.leaderboard-table tbody tr.leaderboard-row-current-user:nth-child(even), +.leaderboard-table tbody tr.leaderboard-row-current-user:hover { + background-color: #dff3df; +} + +.leaderboard-table tbody tr.leaderboard-row-current-user td { + font-weight: 600; +} + +.leaderboard-table tbody tr.leaderboard-row-gap td { + border-bottom: none; + padding: 0; + height: 16px; + background: transparent; +} + +.leaderboard-table tbody tr.leaderboard-row-gap:hover, +.leaderboard-table tbody tr.leaderboard-row-gap:hover td { + background: transparent; +} + +.leaderboard-table tbody tr.leaderboard-row-current-user-extra td { + border-top: 2px solid #b8dfb8; +} + +.leaderboard-table tbody tr:last-child td { + border-bottom: none; +} \ No newline at end of file diff --git a/index.html b/index.html index d9b72dd..9c84e94 100644 --- a/index.html +++ b/index.html @@ -3,14 +3,41 @@ - Lorem Ipsum - Das Spiel + Lorem Ipsum - Das Spiel + + + - + + +
- +
- +

Dashboard

-
+
+ +
+ + +
@@ -58,8 +132,9 @@ + + - \ No newline at end of file diff --git a/js/leaderboard.js b/js/leaderboard.js new file mode 100644 index 0000000..3595811 --- /dev/null +++ b/js/leaderboard.js @@ -0,0 +1,164 @@ +// Formatiert Sekunden als m:ss. Bei ungültigem Wert wird ein Platzhalter angezeigt. +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")}`; +} + +// Liefert den aktuellen Login-Kontext, falls Auth global verfügbar ist. +function getLoggedInAuth() { + if (!window.AppAuth || typeof window.AppAuth.getAuth !== "function") { + return null; + } + + const auth = window.AppAuth.getAuth(); + if (!auth || !auth.username) { + return null; + } + + return auth; +} + +// Vereinheitlicht Benutzernamen für robuste Vergleiche (z. B. Groß-/Kleinschreibung). +function normalizeUsername(username) { + return String(username ?? "").trim().toLowerCase(); +} + +function getLoggedInUsername() { + const auth = getLoggedInAuth(); + if (!auth) { + return null; + } + + return normalizeUsername(auth.username); +} + +// Nutzt den vom Backend gelieferten Rang, fallback auf die aktuelle Listenposition. +function getDisplayedRank(entry, index) { + const place = Number(entry?.place); + if (!Number.isNaN(place) && place > 0) { + return place; + } + + return index + 1; +} + +// Bestes Ergebnis: höchste Punktzahl, bei Gleichstand die geringere Zeit. +function getBestScoreEntry(entries) { + return entries + .slice() + .sort((a, b) => { + 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; + })[0] ?? null; +} + +async function getCurrentUserLeaderboardEntry(username) { + if (!window.ScoreService || !username) { + return null; + } + + const scoreService = new window.ScoreService(window.config); + const result = await scoreService.getScoreByName(username); + + if (!result.ok || !Array.isArray(result.body) || result.body.length === 0) { + return null; + } + + return getBestScoreEntry(result.body); +} + +// Rendert die Top-Liste und markiert den eingeloggten Nutzer visuell. +function renderLeaderboard(entries, extraUserEntry = null) { + const tableBody = document.getElementById("leaderboard-body"); + if (!tableBody) { + return; + } + + const loggedInUsername = getLoggedInUsername(); + + tableBody.innerHTML = ""; + + entries.forEach((entry, index) => { + const row = document.createElement("tr"); + const rowUsername = normalizeUsername(entry.username); + + if (loggedInUsername && rowUsername === loggedInUsername) { + row.classList.add("leaderboard-row-current-user"); + } + + row.innerHTML = ` + ${getDisplayedRank(entry, index)} + ${entry.username ?? "-"} + ${formatTime(entry.time)} min + ${entry.score ?? "-"} + `; + + tableBody.appendChild(row); + }); + + if (extraUserEntry) { + // Trennt Top-10 und eigenen Eintrag optisch, wenn der Nutzer nicht in den Top-10 ist. + const spacerRow = document.createElement("tr"); + spacerRow.classList.add("leaderboard-row-gap"); + spacerRow.innerHTML = ''; + tableBody.appendChild(spacerRow); + + const userRow = document.createElement("tr"); + userRow.classList.add("leaderboard-row-current-user"); + userRow.classList.add("leaderboard-row-current-user-extra"); + userRow.innerHTML = ` + ${getDisplayedRank(extraUserEntry, 0)} + ${extraUserEntry.username ?? "-"} + ${formatTime(extraUserEntry.time)} min + ${extraUserEntry.score ?? "-"} + `; + + tableBody.appendChild(userRow); + } +} + +async function loadTopTenLeaderboard() { + const leaderboardService = new window.LeaderboardService(window.config); + const result = await leaderboardService.getLeaderboard(0, 10); + + if (!result.ok || !Array.isArray(result.body)) { + renderLeaderboard([]); + return; + } + + const auth = getLoggedInAuth(); + let extraUserEntry = null; + + if (auth && auth.username) { + const loggedInUsername = normalizeUsername(auth.username); + // Falls der Nutzer nicht in den Top-10 erscheint, wird sein bestes Ergebnis separat gezeigt. + const isInTopTen = result.body.some( + (entry) => normalizeUsername(entry.username) === loggedInUsername, + ); + + if (!isInTopTen) { + extraUserEntry = await getCurrentUserLeaderboardEntry(auth.username); + } + } + + renderLeaderboard(result.body, extraUserEntry); +} + +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 73aaf38..03d97ed 100644 --- a/js/login.js +++ b/js/login.js @@ -71,7 +71,7 @@ logoutButton.disabled = false; deleteAccountButton.disabled = false; currentSessionBox.classList.remove("d-none"); - authFormsRow.classList.add("d-none"); + authFormsRow.classList.remove("d-none"); } else { sessionText.textContent = "Nicht eingeloggt."; logoutButton.disabled = true; @@ -100,6 +100,7 @@ const usernameInput = document.getElementById("login-username"); const passwordInput = document.getElementById("login-password"); + const submitButton = event.submitter; const username = usernameInput.value.trim(); const password = passwordInput.value.trim(); @@ -108,7 +109,29 @@ return; } - const result = await userService.getUser(username, password); + if (submitButton) { + submitButton.disabled = true; + submitButton.textContent = "Einloggen..."; + } + + let result; + try { + result = await userService.getUser(username, password); + } catch (error) { + console.error("Login fehlgeschlagen:", error); + setFeedback("Login fehlgeschlagen: Backend ist nicht erreichbar.", "danger"); + if (submitButton) { + submitButton.disabled = false; + submitButton.textContent = "Einloggen"; + } + return; + } + + if (submitButton) { + submitButton.disabled = false; + submitButton.textContent = "Einloggen"; + } + if (result.ok) { saveAuth(username, password); setFeedback("Login erfolgreich.", "success"); diff --git a/js/navigation.js b/js/navigation.js index 6111ac0..622579e 100644 --- a/js/navigation.js +++ b/js/navigation.js @@ -23,6 +23,12 @@ document.addEventListener("DOMContentLoaded", () => { if (page === "login" && typeof window.initLoginPage === "function") { window.initLoginPage(); } + if (page === "leaderboard" && typeof window.initLeaderboardPage === "function") { + window.initLeaderboardPage(); + } + if (page === "play" && typeof window.initPlayPage === "function") { + window.initPlayPage(); + } }) .catch(error => { console.error("Fehler beim Laden von " + page + ":", error); @@ -46,4 +52,4 @@ document.addEventListener("DOMContentLoaded", () => { //Startseite laden loadPage("home", "nav-home"); -}); \ No newline at end of file +}); diff --git a/js/play.js b/js/play.js new file mode 100644 index 0000000..a73dd06 --- /dev/null +++ b/js/play.js @@ -0,0 +1,379 @@ +(function() { + // --- Konfiguration --- + const MEMORIZE_TIME_SECONDS = 15; + + // Bausteine fuer den zufaelligen Rundentext. Alles bleibt lokal, damit das Spiel ohne Backend starten kann. + const TEXT_PARTS = { + subjects: [ + "Der flinke Entwickler", + "Die neugierige Studentin", + "Ein mueder Professor", + "Das kleine Frontend", + "Der mutige Browser", + "Eine schlaue Funktion", + "Der vergessliche Server", + "Die kreative Gruppe" + ], + actions: [ + "sortiert leise", + "debuggt geduldig", + "vergleicht heimlich", + "speichert vorsichtig", + "rendert ploetzlich", + "zaehlt konzentriert", + "testet neugierig", + "kompiliert langsam" + ], + objects: [ + "sieben blaue Buttons", + "drei lange Variablen", + "neun goldene Woerter", + "vier kaputte Formulare", + "acht schnelle Requests", + "zwei leuchtende Karten", + "fuenf stille Fehlermeldungen", + "sechs winzige Icons" + ], + places: [ + "im hellen Dashboard", + "unter dem dunklen Navbar", + "neben dem alten Footer", + "zwischen Login und Leaderboard", + "vor dem ersten Kaffee", + "waehrend der Lernphase", + "hinter dem lokalen Server", + "mitten im Semesterprojekt" + ], + endings: [ + "Danach lacht der Code, weil alles endlich funktioniert.", + "Am Ende merkt sich niemand die Semikolons, aber alle die Punkte.", + "Kurz darauf blinkt die Konsole und behauptet, sie sei unschuldig.", + "Spaeter landet der Score im Ranking und wartet auf Applaus.", + "Dabei bleibt die Seite ruhig, obwohl der Timer dramatisch tickt.", + "Zum Schluss gewinnt, wer die Woerter sauber in Reihenfolge bringt." + ] + }; + + let timerInterval; + let currentTime = 0; + + // Der aktuell angezeigte Text muss bis zur Auswertung stabil bleiben. + let currentGameText = ""; + let lastGeneratedText = ""; + + // 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; + + // --- Funktionen --- + + function getRandomItem(items) { + return items[Math.floor(Math.random() * items.length)]; + } + + // Erstellt pro Runde zwei zufaellige Saetze plus Schluss-Satz und vermeidet direkte Wiederholungen. + function generateGameText() { + let generatedText = ""; + + do { + const firstSentence = [ + getRandomItem(TEXT_PARTS.subjects), + getRandomItem(TEXT_PARTS.actions), + getRandomItem(TEXT_PARTS.objects), + getRandomItem(TEXT_PARTS.places) + ].join(" ") + "."; + + const secondSentence = [ + getRandomItem(TEXT_PARTS.subjects), + getRandomItem(TEXT_PARTS.actions), + getRandomItem(TEXT_PARTS.objects), + getRandomItem(TEXT_PARTS.places) + ].join(" ") + "."; + + generatedText = firstSentence + " " + secondSentence + " " + getRandomItem(TEXT_PARTS.endings); + } while (generatedText === lastGeneratedText); + + lastGeneratedText = generatedText; + return generatedText; + } + + function startGame() { + if (!phaseStart || !phaseMemorize) return; + + // Startansicht ausblenden und den neu generierten Text fuer die Lernphase anzeigen. + phaseStart.classList.add('d-none'); + phaseMemorize.classList.remove('d-none'); + + if(gameStatus) { + gameStatus.textContent = "Lernphase"; + gameStatus.className = "badge fs-6 px-3 py-2"; + gameStatus.style.backgroundColor = "#ffd166"; + gameStatus.style.color = "#1b1b2f"; + } + + currentGameText = generateGameText(); + if(targetTextDisplay) targetTextDisplay.textContent = currentGameText; + + // Nach Ablauf des Timers wird automatisch zur Eingabe gewechselt. + currentTime = MEMORIZE_TIME_SECONDS; + if(timerDisplay) timerDisplay.textContent = currentTime; + + timerInterval = setInterval(() => { + currentTime--; + if(timerDisplay) timerDisplay.textContent = currentTime; + + if (currentTime <= 0) { + endMemorizePhase(); + } + }, 1000); + } + + function endMemorizePhase() { + clearInterval(timerInterval); + + if (!phaseMemorize || !phaseInput) return; + + // Text verschwindet, Eingabefeld erscheint: ab hier zaehlt nur noch das Gedaechtnis. + phaseMemorize.classList.add('d-none'); + phaseInput.classList.remove('d-none'); + + if(gameStatus) { + gameStatus.textContent = "Eingabe"; + gameStatus.className = "badge fs-6 px-3 py-2"; + gameStatus.style.backgroundColor = "#4a6fa5"; + gameStatus.style.color = "#fff"; + } + + if(userTextInput) { + userTextInput.value = ""; + userTextInput.focus(); + } + } + + // Entfernt alles, was beim Vergleichen nicht zaehlen soll. + function normalizeWord(word) { + return word.toLowerCase().replace(/[.,\/#!$%\^&\*;:{}=\-_`~()]/g, ""); + } + + // Behält die sichtbaren Woerter separat, damit Satzzeichen in der Ergebnisanzeige erhalten bleiben. + function getWords(text) { + return text.split(/\s+/).filter(word => word.length > 0); + } + + function calculateScore(original, input) { + if (!original || !input) return 0; + + // Score-Regel: gleiche Woerter an gleicher Position, Satzzeichen und Grossschreibung ignoriert. + const cleanOriginal = getWords(original).map(normalizeWord).filter(word => word.length > 0); + const cleanInput = getWords(input).map(normalizeWord).filter(word => word.length > 0); + + let correctWords = 0; + const limit = Math.min(cleanOriginal.length, cleanInput.length); + + for (let i = 0; i < limit; i++) { + if (cleanInput[i] === cleanOriginal[i]) { + correctWords++; + } + } + + return correctWords; + } + + // Baut ein einzelnes farbiges Wort-Label fuer den Ergebnisvergleich. + function createWordBadge(word, isCorrect) { + const badge = document.createElement("span"); + badge.className = "badge me-1 mb-1 " + (isCorrect ? "text-bg-success" : "text-bg-danger"); + badge.textContent = word; + return badge; + } + + // Markiert Original und Eingabe nach derselben Positionslogik wie calculateScore(). + function renderWordComparison(original, input) { + if (!resultOriginal || !resultInput) return; + + const originalWords = getWords(original); + const inputWords = getWords(input); + + resultOriginal.innerHTML = ""; + resultInput.innerHTML = ""; + + // Original: rot, wenn das eingegebene Wort an dieser Position fehlt oder falsch ist. + 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. + inputWords.forEach((word, index) => { + const isCorrect = normalizeWord(word) === normalizeWord(originalWords[index] || ""); + resultInput.appendChild(createWordBadge(word, isCorrect)); + }); + } + + function getAuth() { + if (!window.AppAuth || typeof window.AppAuth.getAuth !== "function") { + return null; + } + + return window.AppAuth.getAuth(); + } + + function getScoreService() { + if (!window.config || !window.ScoreService) { + return null; + } + + return new window.ScoreService(window.config); + } + + function showScoreSaveFeedback(message, type) { + if (!scoreSaveFeedback) return; + + scoreSaveFeedback.className = "alert alert-" + type + " mb-4"; + scoreSaveFeedback.textContent = message; + scoreSaveFeedback.classList.remove("d-none"); + } + + async function saveScore(scoreData) { + const auth = getAuth(); + if (!auth || !auth.username || !auth.password) { + showScoreSaveFeedback( + "Score wurde nur lokal berechnet. Bitte einloggen, damit er im Leaderboard gespeichert wird.", + "warning" + ); + return; + } + + // Auth-Daten kommen aus login.js; der ScoreService setzt daraus die Backend-Header. + const scoreService = getScoreService(); + if (!scoreService) { + showScoreSaveFeedback("Score-Service konnte nicht geladen werden.", "danger"); + return; + } + + showScoreSaveFeedback("Score wird gespeichert...", "info"); + + const result = await scoreService.postScore( + auth.username, + auth.password, + scoreData.score, + scoreData.time, + scoreData.text, + scoreData.userWrittenText + ); + + if (result.ok) { + const place = result.body && result.body.place ? " Platz " + result.body.place + "." : ""; + showScoreSaveFeedback("Score erfolgreich gespeichert." + place, "success"); + return; + } + + if (result.status === 401) { + showScoreSaveFeedback("Score konnte nicht gespeichert werden: Login ist nicht gültig.", "danger"); + return; + } + + showScoreSaveFeedback("Score konnte nicht gespeichert werden (Status " + result.status + ").", "danger"); + } + + async function submitScore() { + if (!userTextInput) return; + + const userInput = userTextInput.value.trim(); + if (!userInput) { + alert("Bitte geben Sie einen Text ein."); + return; + } + + if (btnSubmitScore) { + btnSubmitScore.disabled = true; + btnSubmitScore.textContent = "Wird ausgewertet..."; + } + + const score = calculateScore(currentGameText, userInput); + + // Ergebnis sofort anzeigen; das Speichern im Backend passiert danach asynchron. + if (phaseInput) phaseInput.classList.add('d-none'); + if (phaseResult) phaseResult.classList.remove('d-none'); + + if(gameStatus) { + gameStatus.textContent = "Abgeschlossen"; + gameStatus.className = "badge fs-6 px-3 py-2"; + gameStatus.style.backgroundColor = "#28a745"; + gameStatus.style.color = "#fff"; + } + + if (resultScore) resultScore.textContent = score; + renderWordComparison(currentGameText, userInput); + + // Genau dieser Rundentext wird gespeichert, damit Leaderboard/Score-Details nachvollziehbar bleiben. + const scoreData = { + score: score, + time: MEMORIZE_TIME_SECONDS, + text: currentGameText, + userWrittenText: userInput + }; + + console.log("Score bereit zum Senden:", scoreData); + + try { + await saveScore(scoreData); + } catch (error) { + console.error("Fehler beim Speichern des Scores:", error); + showScoreSaveFeedback("Score konnte wegen eines technischen Fehlers nicht gespeichert werden.", "danger"); + } finally { + if (btnSubmitScore) { + btnSubmitScore.disabled = false; + btnSubmitScore.textContent = "Auswerten & Absenden"; + } + } + } + + window.initPlayPage = function initPlayPage() { + clearInterval(timerInterval); + + // Die Navigation laedt play.html per fetch; deshalb werden die Elemente erst hier gesucht. + phaseStart = document.getElementById('phaseStart'); + phaseMemorize = document.getElementById('phaseMemorize'); + phaseInput = document.getElementById('phaseInput'); + phaseResult = document.getElementById('phaseResult'); + + targetTextDisplay = document.getElementById('targetTextDisplay'); + timerDisplay = document.getElementById('timerDisplay'); + userTextInput = document.getElementById('userTextInput'); + resultScore = document.getElementById('resultScore'); + resultOriginal = document.getElementById('resultOriginal'); + resultInput = document.getElementById('resultInput'); + gameStatus = document.getElementById('gameStatus'); + scoreSaveFeedback = document.getElementById('scoreSaveFeedback'); + + const btnStart = document.getElementById('btnStartGame'); + btnSubmitScore = document.getElementById('btnSubmitScore'); + const btnRestart = document.getElementById('btnRestart'); + const btnLeaderboard = document.getElementById('btnLeaderboard'); + + if (btnStart) btnStart.addEventListener('click', startGame); + if (btnSubmitScore) btnSubmitScore.addEventListener('click', submitScore); + if (btnRestart) btnRestart.addEventListener('click', () => window.loadPage("play", "nav-play")); + if (btnLeaderboard) btnLeaderboard.addEventListener('click', () => { + const navLink = document.getElementById('nav-leaderboard'); + if (navLink) { + navLink.click(); // Nutzt die bestehende Navigation inklusive Active-State. + } else { + console.warn("Sidebar Link #nav-leaderboard nicht gefunden."); + } + }); + }; + +})(); diff --git a/pages/home.html b/pages/home.html index 731056c..585c591 100644 --- a/pages/home.html +++ b/pages/home.html @@ -1,8 +1,7 @@
-

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. +

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. Viel Spaß beim Spielen!

- -

Wähle eine Option aus der Navigation, um zu starten.

- Lorem Ipsum Game +

Wähle eine Option aus der Navigation, um zu starten.

+ Lorem Ipsum Game
\ No newline at end of file diff --git a/pages/leaderboard.html b/pages/leaderboard.html index 7fbb136..99e3973 100644 --- a/pages/leaderboard.html +++ b/pages/leaderboard.html @@ -1,5 +1,19 @@ +

Leaderboard

-

Hier kannst du die besten Spieler und ihre Scores sehen.

+ + + + + + + + + + + + + +
RankUsertimeScore
diff --git a/pages/play.html b/pages/play.html index a28877a..6d7f4d8 100644 --- a/pages/play.html +++ b/pages/play.html @@ -1,7 +1,99 @@ -
-

Spiel Starten

-

Hier kannst du das Spiel starten. Viel Erfolg!

+ +
+ + +
+
+

Lorem Ipsum - Challenge you brain

+

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

+
+
+ Bereit +
+
+ + +
+
+

Sind Sie bereit?

+

+ Sie sehen gleich einen Text für 15 Sekunden.
+ Versuchen Sie, sich so viele Wörter wie möglich zu merken! +

+ +
+
+ + +
+
+
+

Lernphase läuft...

+

+ +

+
+ + Verbleibende Zeit: 15s + +
+
+
+
+ + +
+
+
+ + +
+ +
+
+
+
+ + +
+
+
+

Ergebnis

+
0
+

Punkte (korrekte Wörter an der richtigen Position)

+ + + +
+
+
+ Originaltext +

+
+
+
+
+ Ihre Eingabe +

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