message_challenges #9
432
README.md
432
README.md
@ -1,22 +1,424 @@
|
||||
# lorem_ipsum
|
||||
# Lorem Ipsum – Memory Game Webanwendung
|
||||
|
||||
## Inhaltsverzeichnis
|
||||
- [Projektbeschreibung](#projektbeschreibung)
|
||||
- [Anforderungen & Features](#anforderungen--features)
|
||||
- [Installation & Setup](#installation--setup)
|
||||
- [Verwendung](#verwendung)
|
||||
- [Projektstruktur](#projektstruktur)
|
||||
- [Technologie-Stack](#technologie-stack)
|
||||
- [Architektur & Technische Entscheidungen](#architektur--technische-entscheidungen)
|
||||
- [Sicherheit](#sicherheit)
|
||||
- [KI-Werkzeuge & deren Einsatz](#ki-werkzeuge--deren-einsatz)
|
||||
- [Herausforderungen & Lerneffekte](#herausforderungen--lerneffekte)
|
||||
- [Team](#team)
|
||||
|
||||
---
|
||||
|
||||
## Projektbeschreibung
|
||||
Das Projekt Lorem Ipsum ist ein kleines Spiel, bei dem ein User einen Text für eine gewisse Zeit angezeigt bekommt. Der User soll sich in dieser Zeit möglichst viel des Textes auswendig merken. Nach Ablauf der Zeit kann der Nutzer den Text in einem Feld eingeben. Er oder sie erhält Punkte entsprechend dem, wieviele Wörter richtig eingetippt wurden.
|
||||
|
||||
Der User kann seinen Score auf ein globales Leaderboard hochladen und sich mit anderen Usern vergleichen. Der User kann sich eine Top10 Tabelle des globalen Leaderboards anzeigen lassen.
|
||||
**Lorem Ipsum** ist eine interaktive Webanwendung, die das Kurzzeitgedächtnis trainiert. Nutzer werden mit zufälligen Texten präsentiert, die sie sich innerhalb einer definierten Zeitspanne einprägen müssen. Nach Ablauf der Zeit reproduzieren sie den Text so akkurat wie möglich. Das System bewertet die Leistung durch Wort-für-Wort-Vergleich und vergibt Punkte.
|
||||
|
||||
## Features
|
||||
- Ein User kann sich einen Account anlegen. und erhält dann ein Zugangspasswort.
|
||||
- Ein User kann in seinem Namen erspielte Scores submitten.
|
||||
- Ein User kann das Spiel spielen.
|
||||
- Ein User kann sich all seine Scores anzeigen lassen.
|
||||
- Ein User kann das globale Leaderboard anschauen.
|
||||
Die Anwendung ermöglicht soziale Interaktion durch ein globales Leaderboard, auf dem Nutzer ihre Scores veröffentlichen und sich mit anderen Spielern vergleichen können.
|
||||
|
||||
## Technologieen
|
||||
Als CSS Framework wird Bootstrap 5.3.8 verwendet.
|
||||
---
|
||||
|
||||
## Gruppenmitglieder
|
||||
## Anforderungen & Features
|
||||
|
||||
* Florin Gartmann
|
||||
* Daniela Studer-Müller
|
||||
* Adrian Joost
|
||||
### Funktionale Anforderungen
|
||||
|
||||
| Feature | Beschreibung |
|
||||
|---------|-------------|
|
||||
| **Benutzerverwaltung** | Benutzer können ein Konto erstellen und erhalten ein eindeutiges Passwort |
|
||||
| **Spielmechanik** | Zufällige Texte werden angezeigt und müssen nach Ablauf einer Zeitspanne reproduziert werden |
|
||||
| **Score-System** | Automatische Bewertung basierend auf korrekt eingegebenen Wörtern mit Punkt-Vergabe |
|
||||
| **Persönliche Scores** | Nutzer können alle ihre bisherigen Scores ansehen und verwalten |
|
||||
| **Globales Leaderboard** | Echtzeit-Anzeige der Top 10 Spieler mit ihren besten Scores |
|
||||
| **Nachrichten-System** | Nutzer können sich gegenseitig Nachrichten/Challenges schreiben |
|
||||
|
||||
---
|
||||
|
||||
## Installation & Setup
|
||||
|
||||
### Voraussetzungen
|
||||
- Moderner Webbrowser (Chrome, Firefox, Safari, Edge)
|
||||
- Node.js (optional, nur für lokale Backend-Entwicklung)
|
||||
- Zugriff auf die Backend-API (`config-service.js` für API-Konfiguration)
|
||||
|
||||
### Schnellstart
|
||||
|
||||
```bash
|
||||
# 1. Repository klonen
|
||||
git clone <repository-url>
|
||||
cd lorem_ipsum
|
||||
|
||||
# 2. Abhängigkeiten installieren (falls Backend vorhanden)
|
||||
npm install
|
||||
|
||||
# 3. Applikation starten
|
||||
# Option A: Mit Live-Server (VS Code Extension)
|
||||
# Rechtsklick auf index.html → "Open with Live Server"
|
||||
|
||||
# Option B: Mit Python (falls vorhanden)
|
||||
python -m http.server 8000
|
||||
# Dann öffnen: http://localhost:8000
|
||||
|
||||
# Option C: Mit Node.js
|
||||
npx http-server
|
||||
```
|
||||
|
||||
### Backend-Konfiguration
|
||||
Die API-Endpoints sind in `assets/src/service/config-service.js` konfiguriert:
|
||||
|
||||
```javascript
|
||||
// config-service.js
|
||||
export const API_BASE_URL = "https://api.example.com";
|
||||
export const API_VERSION = "v1";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verwendung
|
||||
|
||||
### Für neue Benutzer
|
||||
|
||||
1. **Account erstellen**: Auf der Startseite auf "Registrieren" klicken
|
||||
2. **Login**: Mit dem erhaltenen Passwort anmelden
|
||||
3. **Spiel spielen**: "Spiel Starten" wählen
|
||||
- Text wird 15 Sekunden lang angezeigt
|
||||
- Nach Ablauf Text reproduzieren
|
||||
- Automatische Bewertung wird angezeigt
|
||||
4. **Scores verwalten**: Persönliche Scores einsehen und ggf. auf Leaderboard hochladen
|
||||
5. **Leaderboard**: Top 10 Spieler in Echtzeit betrachten
|
||||
6. **Nachrichten**: Mit anderen Spielern kommunizieren oder Challenges stellen
|
||||
|
||||
### Beispiel-Workflows
|
||||
|
||||
**Workflow 1: Account erstellen**
|
||||
```
|
||||
Home → Login/Registrieren → Username eingeben → Username und Passwort speichern
|
||||
```
|
||||
|
||||
**Workflow 2: Leaderboard ansehen**
|
||||
```
|
||||
Home → Leaderboard → Top 10 wird geladen und angezeigt
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Projektstruktur
|
||||
|
||||
```
|
||||
lorem_ipsum/
|
||||
├── index.html # Startseite
|
||||
├── README.md # Dokumentation (diese Datei)
|
||||
├── pages/
|
||||
│ ├── login.html # Login/Registrierung
|
||||
│ ├── home.html # Startseite nach Login
|
||||
│ ├── play.html # Spielseite
|
||||
│ ├── scores.html # Persönliche Scores
|
||||
│ ├── leaderboard.html # Globales Leaderboard
|
||||
│ └── messages.html # Nachrichts-/Challenge-System
|
||||
├── js/
|
||||
│ ├── login.js # Login-Logik & Event-Handler
|
||||
│ ├── play.js # Spiel-Logik (Timer, Text-Vergleich)
|
||||
│ ├── leaderboard.js # Leaderboard-Rendering
|
||||
│ ├── navigation.js # Navigations-Controls
|
||||
│ └── messages.js # Nachrichten-Funktionalität
|
||||
├── assets/
|
||||
│ ├── css/
|
||||
│ │ └── custom.css # Custom-Styles
|
||||
│ ├── src/
|
||||
│ │ ├── service/
|
||||
│ │ │ ├── user-service.js # User-API-Aufrufe (Login, Register)
|
||||
│ │ │ ├── leaderboard-service.js # Leaderboard-API-Aufrufe
|
||||
│ │ │ ├── score-service.js # Score-Management
|
||||
│ │ │ ├── message-service.js # Nachrichten-API
|
||||
│ │ │ ├── challenge-service.js # Challenge-API
|
||||
│ │ │ └── config-service.js # API-Konfiguration & Konstanten
|
||||
│ │ └── sites/
|
||||
│ │ └── exampleAPI.js # API-Test/Beispiel
|
||||
│ └── bootstrap-5.3.8-dist/ # Bootstrap Framework (v5.3.8)
|
||||
└── pages/
|
||||
└── [weitere Pages wie oben]
|
||||
```
|
||||
|
||||
### Dateibenennungs-Konventionen
|
||||
- **HTML-Dateien**: `kebab-case` (z.B. `login.html`, `play.html`)
|
||||
- **JavaScript-Dateien**: `kebab-case` (z.B. `login.js`, `play.js`, `user-service.js`)
|
||||
- **Service-Dateien**: Suffix `-service.js` (z.B. `score-service.js`, `leaderboard-service.js`)
|
||||
- **CSS-Klassen**: Bootstrap-Utilities + Custom Selektoren; BEM bei komplexen Komponenten (z.B. `.play-challenge-result__title`)
|
||||
|
||||
---
|
||||
|
||||
## Technologie-Stack
|
||||
|
||||
| Layer | Technologie | Version | Begründung |
|
||||
|-------|-------------|---------|-----------|
|
||||
| **Markup** | HTML5 | 5 | Semantische Struktur, barrierefreundlich |
|
||||
| **Styling** | Bootstrap CSS | 5.3.8 | Responsive Grid, vordefinierte Komponenten, Accessibility |
|
||||
| **Custom CSS** | CSS3 | - | Flexbox/Grid für Layout-Feinheiten, Custom Properties |
|
||||
| **Frontend-Logik** | Vanilla JavaScript | ES6+ | Keine externen Abhängigkeiten nötig, vollständige Kontrolle |
|
||||
| **API-Kommunikation** | Fetch API | - | Modern, Promise-basiert, Error-Handling via try/catch |
|
||||
| **Versionsverwaltung** | Git | - | Nachvollziehbarer Entwicklungsverlauf |
|
||||
|
||||
### Warum Vanilla JS statt Framework?
|
||||
- Projekt-Anforderungen sind moderat (keine komplexe State-Verwaltung nötig)
|
||||
- Fetch API mit async/await bietet ausreichende Abstraktionen
|
||||
- Schnellere Ladezeiten ohne Framework-Overhead
|
||||
- Volles Verständnis über den Code ohne "Black Box"-Effekte
|
||||
|
||||
### Warum Bootstrap?
|
||||
- Responsive Design out-of-the-box (12er Grid-System)
|
||||
- Umfangreiches Komponenten-Portfolio (Modals, Forms, Cards)
|
||||
- WCAG Level A Compliance bereits implementiert
|
||||
- Konsistentes Design über alle Seiten hinweg
|
||||
|
||||
---
|
||||
|
||||
## Architektur & Technische Entscheidungen
|
||||
|
||||
### Service-orientierte Architektur
|
||||
|
||||
Die Anwendung folgt einer **Service-orientierten Architektur**:
|
||||
|
||||
```
|
||||
UI Layer (HTML/CSS)
|
||||
↓
|
||||
Event Handler (login.js, play.js, ...)
|
||||
↓
|
||||
Service Layer (user-service.js, score-service.js, ...)
|
||||
↓
|
||||
Fetch API → Backend REST API
|
||||
```
|
||||
|
||||
**Vorteile:**
|
||||
- **Separation of Concerns**: Jeder Service hat eine klare Verantwortung
|
||||
- **Wiederverwendbarkeit**: Services können von mehreren Seiten genutzt werden
|
||||
- **Testbarkeit**: Services können isoliert getestet werden
|
||||
- **Wartbarkeit**: API-Änderungen erfordern nur Anpassung in einem Ort
|
||||
|
||||
### Datenfluss beim Spielen
|
||||
|
||||
```javascript
|
||||
1. play.js: generateGameText() // Zufälligen Text lokal generieren
|
||||
2. play.js: Timer starten (15s)
|
||||
3. User tippt Text ein
|
||||
4. play.js: Wort-für-Wort-Vergleich nach Position
|
||||
5. score-service.postScore() // Score zum Server senden
|
||||
6. UI zeigt Ergebnis sofort an, Speichern läuft asynchron
|
||||
```
|
||||
|
||||
### Authentifizierungs-Design
|
||||
|
||||
**Besonderheit: Backend generiert Passwörter statt User-Input**
|
||||
|
||||
```
|
||||
Konventionell (problematisch): Unser Ansatz (bewusst gewählt):
|
||||
User gibt Passwort ein → Backend generiert zufälliges Passwort
|
||||
Risiko: User nimmt häufig genutztes → Keine Passwort-Reuse-Gefahr
|
||||
Passwort → Für diese Session gültig
|
||||
```
|
||||
|
||||
**Begründung dieser Entscheidung:**
|
||||
1. **Sicherheitsausrichtung**: Nutzer gefährden kein häufig genutztes Passwort (Lernaspekt: User-Sicherheit)
|
||||
2. **Frontend-Fokus**: Komplexe Auth-Logik (Hashing, Salting) sind nicht im scope dieses Moduls
|
||||
3. **Transparenz**: Diese Limitierung ist im Code und README dokumentiert – nicht versteckt
|
||||
|
||||
**Konsequenzen für OWASP:**
|
||||
- ⚠️ localStorage speichert plaintext-Passwort (A07:2021 – nicht konform)
|
||||
- ✅ Aber: Generierte Passwörter sind Wegwerf-Token, kein häufig genutztes Passwort
|
||||
|
||||
### HTML5 Semantik
|
||||
|
||||
Alle HTML-Dateien nutzen semantisch korrekte Elemente:
|
||||
|
||||
```html
|
||||
<header> <!-- Navigation und Kopfzeile -->
|
||||
<main>
|
||||
<section> <!-- Logische Abschnitte -->
|
||||
<article> <!-- Spielinhalte -->
|
||||
<form> <!-- Benutzer-Input -->
|
||||
<footer> <!-- Impressum, Credits -->
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sicherheit
|
||||
|
||||
### OWASP-Risiken im Kontext dieser Anwendung
|
||||
|
||||
#### 1. **Cross-Site Scripting (XSS)** – A03:2021
|
||||
**Risiko**: Nutzer-Input (Scores, Nachrichten) könnte als Code interpretiert werden
|
||||
|
||||
**Implementierte Maßnahmen:**
|
||||
```javascript
|
||||
// ✅ SICHER: Text-Encoding statt HTML-Parsing
|
||||
const scoreElement = document.createElement('div');
|
||||
scoreElement.textContent = userScore; // textContent encodiert automatisch
|
||||
// ❌ NICHT SICHER: scoreElement.innerHTML = userScore
|
||||
|
||||
// ✅ SICHER: Template Literals mit textContent
|
||||
const formattedScore = `User: ${userName}`; // nie mit innerHTML
|
||||
```
|
||||
|
||||
**Begründung**: `textContent` und DOM-APIs encodieren automatisch; `innerHTML` mit Benutzerdaten ist ein XSS-Vektor.
|
||||
|
||||
#### 2. **Authentifizierung & Session-Management** – A07:2021
|
||||
**Risiko**: Passwort-Speicherung im localStorage, plaintext-Übertragung als Custom-Header
|
||||
|
||||
**Implementierte Maßnahmen:**
|
||||
- ✅ API nutzt HTTPS (TLS-Verschlüsselung für Übertragung)
|
||||
- ✅ localStorage mit Auth-Daten nur clientseitig (nicht persistent auf Server)
|
||||
|
||||
**OWASP-Perspektive – nicht konform:**
|
||||
```javascript
|
||||
// ❌ Passwort unverschlüsselt im localStorage
|
||||
localStorage.setItem('auth', JSON.stringify({
|
||||
username: user,
|
||||
password: generatedPassword
|
||||
}));
|
||||
|
||||
// ✅ Produktiv würde man verwenden:
|
||||
// - HTTP-Only Cookies (vom Server gesetzt, nicht via JS zugreifbar)
|
||||
// - JWT mit Refresh-Token-Rotation
|
||||
// - OAuth 2.0 / OpenID Connect
|
||||
```
|
||||
|
||||
**Begründung dieser Limitierung**: Siehe Abschnitt [Authentifizierungs-Design](#authentifizierungs-design) in der Architektur. Die Designentscheidung, dass das Backend Passwörter generiert, minimiert das Reuse-Risiko. Auth-Komplexität ist Backend-Responsibility.
|
||||
|
||||
---
|
||||
|
||||
## KI-Werkzeuge & deren Einsatz
|
||||
|
||||
### ChatGPT / Claude – Code-Review & Debugging
|
||||
**Einsatzbereiche:**
|
||||
- Fehlersuche in asynchronen Promise-Ketten
|
||||
- Code-Style-Verbesserungen (ES6+ Best Practices)
|
||||
- Regex-Optimierungen für Text-Vergleich
|
||||
- Generierung visueller Assets: Logo, Icon sowie Challenge-Grafiken für Gewonnen/Verloren/Unentschieden wurden mit ChatGPT erstellt und anschließend im Projekt eingebunden
|
||||
|
||||
**Kritische Bewertung:**
|
||||
- ✅ **Effektiv für**: Schnelle Fehlerbehebung, Best-Practice-Vorschläge
|
||||
- ⚠️ **Vorsicht**: KI-Vorschläge nicht blind übernehmen, Logik selbst verstehen
|
||||
- ✅ **Angewendet**: Alle KI-Vorschläge wurden auf Korrektheit geprüft
|
||||
|
||||
### GitHub Copilot – Auto-Completion
|
||||
**Einsatzbereiche:**
|
||||
- Service-Boilerplate-Code (Fetch-Aufrufe, Error-Handling)
|
||||
- Wiederholte DOM-Manipulationen
|
||||
|
||||
**Kritische Bewertung:**
|
||||
- ✅ **Hilfreich für**: Reduktion von Boilerplate-Code
|
||||
- ⚠️ **Problematisch**: Code-Qualität variiert, manchmal ineffiziente Patterns
|
||||
- ✅ **Angewendet**: Nur nach manueller Prüfung und Refactoring integriert
|
||||
|
||||
---
|
||||
|
||||
## Herausforderungen & Lerneffekte
|
||||
|
||||
### 🔴 Herausforderungen
|
||||
|
||||
#### 1. **Text-Vergleich mit Sonderzeichen**
|
||||
**Problem**: Unterschiedliche Behandlung von Umlauten, Leerzeichen, Satzzeichen
|
||||
```
|
||||
Original: "Café"
|
||||
User-Input: "cafe" // Accent wird nicht erkannt
|
||||
```
|
||||
**Lösung**:
|
||||
- Normalisierung mit `String.prototype.normalize('NFD')`
|
||||
- Regelwerk für Sonderzeichen-Ignorieren
|
||||
- **Gelernt**: Unicode-Handling in JavaScript ist komplex
|
||||
|
||||
#### 2. **Asynchrone State-Verwaltung**
|
||||
**Problem**: Race-Conditions bei mehreren parallelen API-Calls
|
||||
```javascript
|
||||
// ❌ PROBLEMATISCH
|
||||
const data1 = fetch('/users'); // Promise 1
|
||||
const data2 = fetch('/scores'); // Promise 2
|
||||
// Welche Promise löst sich zuerst auf?
|
||||
```
|
||||
**Lösung**:
|
||||
- `Promise.all()` für parallele Aufrufe
|
||||
- `async/await` für sequenzielle Aufrufe
|
||||
- Globale Loading-States um UI zu synchronisieren
|
||||
- **Gelernt**: Promise-Sequenzierung ist essentiell für konsistente UI
|
||||
|
||||
#### 3. **Responsive Design auf mobilen Geräten**
|
||||
**Problem**: Bootstrap Grid bricht bei sehr kleinen Viewports zusammen
|
||||
**Lösung**:
|
||||
- Custom Breakpoints mit CSS Media Queries
|
||||
- Flexbox für flexible Card-Layouts
|
||||
- Touch-friendly Button-Größen (min 44x44px)
|
||||
- **Gelernt**: Mobile-first Approach hätte von Anfang an helfen sollen
|
||||
|
||||
### 🟢 Lerneffekte
|
||||
|
||||
| Bereich | Erkenntnis |
|
||||
|---------|-----------|
|
||||
| **Fetch API & Error-Handling** | `try/catch` bei Async/Await ist unmissbar; HTTP 400/500 Responses sind nicht automatisch Exceptions |
|
||||
| **DOM-Manipulation** | Event-Delegation spart Code; `querySelector` ist ausreichend für diese Projekt-Größe |
|
||||
| **State Management** | Ohne centralized Store (Redux, Vuex) führt komplexe State schnell zu Bugs; globale Variablen sind anfällig |
|
||||
| **Git Workflow** | Feature-Branches + aussagekräftige Commit-Messages machen Debugging später viel einfacher |
|
||||
| **Accessibility** | WCAG nicht als "nice-to-have" sehen; frühe Integration spart viel Refactoring |
|
||||
| **Testing-Ansatz** | Manuelles Testen auf mehreren Browsern/Geräten ist zeitaufwendig; automatisierte Tests würden helfen |
|
||||
|
||||
### 💡 Was würde anders gemacht werden
|
||||
|
||||
1. **Service Layer von Anfang an**: Hätte API-Changes sehr vereinfacht
|
||||
2. **TypeScript**: Würde viele Typo-Fehler early-stage finden
|
||||
3. **Unit-Tests**: Für Score-Logik und Text-Vergleich empfohlen
|
||||
4. **CSS-Präprozessor** (SASS): Für komplexere Styling-Szenarien hilfreich
|
||||
|
||||
---
|
||||
|
||||
## Code-Kommentierung & Qualität
|
||||
|
||||
### Kommentierung-Standard
|
||||
|
||||
```javascript
|
||||
// ✅ KURZ & AUSSAGEKRÄFTIG (bevorzugt)
|
||||
// Vergleiche Texte zeichenweise und zähle korrekte Wörter
|
||||
function compareTexts(original, userInput) { ... }
|
||||
|
||||
// ❌ ÜBERFLÜSSIG (code ist selbsterklärend)
|
||||
// Inkrementiere counter um 1
|
||||
counter++;
|
||||
|
||||
// ⚠️ NUR FÜR NON-OFFENSICHTLICHES
|
||||
// Normalisiere Unicode-Sequenzen, da Nutzer-Input oft Umlaute enthält (z.B. "Café")
|
||||
const normalized = str.normalize('NFD');
|
||||
```
|
||||
|
||||
### Naming-Konventionen
|
||||
|
||||
- Funktionen: **Verb + Substantiv** → `getRandomText()`, `compareScores()`
|
||||
- Variablen: **aussagekräftig** → `userInputText` (nicht `txt` oder `u`)
|
||||
- Konstanten: **UPPER_SNAKE_CASE** → `MAX_TEXT_LENGTH = 5000`
|
||||
- Private Funktionen: **Prefix `_`** → `_calculateScore()` (Convention only)
|
||||
|
||||
---
|
||||
|
||||
## Team
|
||||
|
||||
| Name |
|
||||
|------|
|
||||
| **Florin Gartmann** |
|
||||
| **Daniela Studer-Müller** |
|
||||
| **Adrian Joost** |
|
||||
|
||||
---
|
||||
|
||||
## Lizenz
|
||||
|
||||
Dieses Projekt ist für Ausbildungszwecke entwickelt worden.
|
||||
|
||||
---
|
||||
|
||||
## Weitere Ressourcen
|
||||
|
||||
- [Bootstrap 5 Dokumentation](https://getbootstrap.com/docs/5.3/)
|
||||
- [MDN: Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API)
|
||||
- [OWASP Top 10](https://owasp.org/www-project-top-ten/)
|
||||
|
||||
**Zuletzt aktualisiert:** 2026-05-24
|
||||
|
||||
@ -32,6 +32,24 @@ body {
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
.navbar .nav-link.has-unread-messages {
|
||||
background-color: #dc3545;
|
||||
color: #ffffff;
|
||||
font-weight: bold;
|
||||
border-radius: 6px;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
.navbar .nav-link.has-unread-messages::after {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin-left: 8px;
|
||||
border-radius: 50%;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
/* Main content styles */
|
||||
.container-dashboard {
|
||||
flex-wrap: wrap;
|
||||
@ -45,9 +63,7 @@ body {
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
|
||||
padding: 25px;
|
||||
font-size: 30px;
|
||||
flex: 1 1 100%;
|
||||
text-align: center;
|
||||
transition: transform 0.3s, box-shadow 0.3s;
|
||||
}
|
||||
.container > div:not(.modal):hover {
|
||||
@ -93,6 +109,13 @@ p {
|
||||
.btn:hover {
|
||||
background-color: #2b4a7c;
|
||||
}
|
||||
.btn:disabled,
|
||||
.btn.disabled {
|
||||
background-color: #adb5bd;
|
||||
color: #495057;
|
||||
cursor: not-allowed;
|
||||
opacity: 1;
|
||||
}
|
||||
.btn-danger {
|
||||
background-color: #a84848;
|
||||
color: #fff;
|
||||
@ -220,6 +243,7 @@ p {
|
||||
background: #ffffff;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.leaderboard-table th,
|
||||
@ -227,6 +251,7 @@ p {
|
||||
padding: 8px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #ddd;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.leaderboard-table thead th {
|
||||
@ -276,4 +301,258 @@ p {
|
||||
|
||||
.leaderboard-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Messages */
|
||||
.container > .messages-card:not(.modal),
|
||||
.messages-card {
|
||||
text-align: left;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.container > .messages-card:not(.modal):hover,
|
||||
.messages-card:hover {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.messages-header,
|
||||
.messages-inbox-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.messages-panel {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #dbe3ef;
|
||||
border-radius: 8px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.messages-panel h3 {
|
||||
font-size: 1.1rem;
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
|
||||
.messages-user-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.messages-user-button {
|
||||
width: 100%;
|
||||
border: 1px solid #c9d6e8;
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
color: #1b1b2f;
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
transition: background-color 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.messages-user-button:hover {
|
||||
background: #e8f0fb;
|
||||
border-color: #4a6fa5;
|
||||
}
|
||||
|
||||
.message-form textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.message-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.message-item {
|
||||
border-left: 4px solid #4a6fa5;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.message-item-unread {
|
||||
border-left-color: #dc3545;
|
||||
background: #fff4f4;
|
||||
}
|
||||
|
||||
.challenge-message-group {
|
||||
border-left-color: #6f7fb7;
|
||||
}
|
||||
|
||||
.challenge-thread {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.challenge-thread.d-none {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.challenge-thread-toggle {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #4a6fa5;
|
||||
display: inline-flex;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
margin-top: 10px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.challenge-thread-toggle:hover {
|
||||
color: #2b4a7c;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.challenge-thread-item {
|
||||
border-radius: 6px;
|
||||
background: #f8fafc;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.challenge-thread-item span {
|
||||
display: block;
|
||||
color: #64748b;
|
||||
font-size: 0.82rem;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.message-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
color: #1b1b2f;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.message-meta span,
|
||||
.messages-empty {
|
||||
color: #667085;
|
||||
}
|
||||
|
||||
.message-item p,
|
||||
.messages-empty {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.challenge-status-button:disabled {
|
||||
background-color: #e5e7eb;
|
||||
border: 1px solid #cbd5e1;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.challenge-result-graphic,
|
||||
.play-challenge-result {
|
||||
margin-top: 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #dbe3ef;
|
||||
background: #ffffff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.challenge-result-headline,
|
||||
.play-challenge-result-title {
|
||||
padding: 10px 14px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.challenge-outcome {
|
||||
align-items: center;
|
||||
background: #f8fafc;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.challenge-outcome img {
|
||||
border-radius: 8px;
|
||||
height: 72px;
|
||||
object-fit: cover;
|
||||
width: 72px;
|
||||
}
|
||||
|
||||
.challenge-outcome strong {
|
||||
color: #1b1b2f;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.challenge-outcome-win {
|
||||
background: #e8f7ee;
|
||||
}
|
||||
|
||||
.challenge-outcome-loss {
|
||||
background: #fdecec;
|
||||
}
|
||||
|
||||
.challenge-outcome-draw {
|
||||
background: #eef3fb;
|
||||
}
|
||||
|
||||
.challenge-result-win,
|
||||
.play-challenge-result-winner .play-challenge-result-title {
|
||||
background: #198754;
|
||||
}
|
||||
|
||||
.challenge-result-draw,
|
||||
.play-challenge-result-draw .play-challenge-result-title {
|
||||
background: #4a6fa5;
|
||||
}
|
||||
|
||||
.play-challenge-result-loser .play-challenge-result-title {
|
||||
background: #a84848;
|
||||
}
|
||||
|
||||
.challenge-result-scores,
|
||||
.play-challenge-result-scores {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1px;
|
||||
background: #dbe3ef;
|
||||
}
|
||||
|
||||
.challenge-result-score,
|
||||
.play-challenge-result-score {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
background: #ffffff;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.challenge-result-score span,
|
||||
.play-challenge-result-score span {
|
||||
color: #4a6fa5;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.challenge-hint {
|
||||
display: inline-block;
|
||||
margin-top: 8px;
|
||||
border-radius: 6px;
|
||||
background: #fff4cc;
|
||||
color: #1b1b2f;
|
||||
padding: 6px 10px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.messages-header,
|
||||
.messages-inbox-title,
|
||||
.message-meta {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.challenge-result-scores,
|
||||
.play-challenge-result-scores {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
66
assets/src/service/challenge-service.js
Normal file
66
assets/src/service/challenge-service.js
Normal file
@ -0,0 +1,66 @@
|
||||
class ChallengeService {
|
||||
constructor(config) {
|
||||
this.baseUrl = config.API_BASE_URL;
|
||||
this.urlTail = "challenges";
|
||||
}
|
||||
|
||||
async postChallenge(username, password, opponent, text) {
|
||||
const response = await fetch(`${this.baseUrl}${this.urlTail}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Username": username,
|
||||
"X-Password": password,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
opponent: opponent,
|
||||
text: text,
|
||||
}),
|
||||
});
|
||||
|
||||
let body = null;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch {
|
||||
console.error("Error in postChallenge");
|
||||
}
|
||||
|
||||
return {
|
||||
status: response.status,
|
||||
ok: response.ok,
|
||||
body: body,
|
||||
};
|
||||
}
|
||||
|
||||
async completeChallenge(username, password, challengeId, score, time, text, userWrittenText) {
|
||||
const response = await fetch(`${this.baseUrl}${this.urlTail}/${challengeId}/complete`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Username": username,
|
||||
"X-Password": password,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
score: score,
|
||||
time: time,
|
||||
text: text,
|
||||
userWrittenText: userWrittenText,
|
||||
}),
|
||||
});
|
||||
|
||||
let body = null;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch {
|
||||
console.error("Error in completeChallenge");
|
||||
}
|
||||
|
||||
return {
|
||||
status: response.status,
|
||||
ok: response.ok,
|
||||
body: body,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
window.ChallengeService = ChallengeService;
|
||||
@ -1,5 +1,5 @@
|
||||
const config = {
|
||||
API_BASE_URL: "https://316214-1.web.fhgr.education/10001/api/"
|
||||
API_BASE_URL: "https://webdev.iten-web.ch/10001/api/"
|
||||
}
|
||||
|
||||
window.config = config;
|
||||
106
assets/src/service/message-service.js
Normal file
106
assets/src/service/message-service.js
Normal file
@ -0,0 +1,106 @@
|
||||
class MessageService {
|
||||
constructor(config) {
|
||||
this.baseUrl = config.API_BASE_URL;
|
||||
this.urlTail = "messages";
|
||||
}
|
||||
|
||||
async getMessages(username, password) {
|
||||
const response = await fetch(`${this.baseUrl}${this.urlTail}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"X-Username": username,
|
||||
"X-Password": password,
|
||||
},
|
||||
});
|
||||
|
||||
let body = null;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch {
|
||||
console.error("Error in getMessages");
|
||||
}
|
||||
|
||||
return {
|
||||
status: response.status,
|
||||
ok: response.ok,
|
||||
body: body,
|
||||
};
|
||||
}
|
||||
|
||||
async postMessage(username, password, recipient, type, text) {
|
||||
const response = await fetch(`${this.baseUrl}${this.urlTail}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Username": username,
|
||||
"X-Password": password,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
recipient: recipient,
|
||||
type: type,
|
||||
text: text,
|
||||
}),
|
||||
});
|
||||
|
||||
let body = null;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch {
|
||||
console.error("Error in postMessage");
|
||||
}
|
||||
|
||||
return {
|
||||
status: response.status,
|
||||
ok: response.ok,
|
||||
body: body,
|
||||
};
|
||||
}
|
||||
|
||||
async markMessageAsRead(username, password, messageId) {
|
||||
const response = await fetch(`${this.baseUrl}${this.urlTail}/${messageId}/read`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"X-Username": username,
|
||||
"X-Password": password,
|
||||
},
|
||||
});
|
||||
|
||||
let body = null;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch {
|
||||
console.error("Error in markMessageAsRead");
|
||||
}
|
||||
|
||||
return {
|
||||
status: response.status,
|
||||
ok: response.ok,
|
||||
body: body,
|
||||
};
|
||||
}
|
||||
|
||||
async markAllMessagesAsRead(username, password) {
|
||||
const response = await fetch(`${this.baseUrl}${this.urlTail}/read`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"X-Username": username,
|
||||
"X-Password": password,
|
||||
},
|
||||
});
|
||||
|
||||
let body = null;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch {
|
||||
console.error("Error in markAllMessagesAsRead");
|
||||
}
|
||||
|
||||
return {
|
||||
status: response.status,
|
||||
ok: response.ok,
|
||||
body: body,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
window.MessageService = MessageService;
|
||||
@ -26,6 +26,28 @@ class UserService {
|
||||
};
|
||||
}
|
||||
|
||||
async getUsers(username, password) {
|
||||
const response = await fetch(`${this.baseUrl}users`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"X-Username": username,
|
||||
"X-Password": password,
|
||||
},
|
||||
});
|
||||
let body = null;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch {
|
||||
console.error("Error in getUsers");
|
||||
}
|
||||
|
||||
return {
|
||||
status: response.status,
|
||||
ok: response.ok,
|
||||
body: body,
|
||||
};
|
||||
}
|
||||
|
||||
async deleteUser(username, password) {
|
||||
const response = await fetch(`${this.baseUrl}${this.urlTail}`, {
|
||||
method: "DELETE",
|
||||
|
||||
BIN
docs/Bewertungsraster_Frontend.xlsx
Normal file
BIN
docs/Bewertungsraster_Frontend.xlsx
Normal file
Binary file not shown.
@ -86,6 +86,42 @@ If a user with that name already exists, it should return a 400.
|
||||
}
|
||||
```
|
||||
|
||||
### GET all users
|
||||
|
||||
**Endpoint**
|
||||
|
||||
GET /users
|
||||
|
||||
**Description** Returns all existing users so the frontend can show a list of possible challenge recipients. Requires authentication.
|
||||
|
||||
**Authentication Headers (required)**
|
||||
- X-Username
|
||||
- X-Password
|
||||
|
||||
**Response (List\<UserListItemDTO\>)**
|
||||
|
||||
``` json
|
||||
[
|
||||
{
|
||||
"username": "string"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Alternative response shape accepted by the frontend:
|
||||
|
||||
``` json
|
||||
[
|
||||
{
|
||||
"name": "string"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**Status Codes**
|
||||
- 200 if users were loaded successfully
|
||||
- 401 if authentication failed
|
||||
|
||||
------------------------------------------------------------------------
|
||||
|
||||
## 2. Score
|
||||
@ -193,5 +229,266 @@ The response should return the created object. It should give the place, the sco
|
||||
]
|
||||
```
|
||||
|
||||
------------------------------------------------------------------------
|
||||
|
||||
## 4. Messages / Challenges
|
||||
|
||||
Messages are used for user-to-user challenges. A challenge is just a message with `type: "challenge"`.
|
||||
All message endpoints require authentication via the existing headers.
|
||||
|
||||
**Authentication Headers (required)**
|
||||
- X-Username
|
||||
- X-Password
|
||||
|
||||
### GET messages
|
||||
|
||||
**Endpoint**
|
||||
|
||||
GET /messages
|
||||
|
||||
**Description** Returns all messages relevant for the authenticated user. The response should include received messages and may also include sent messages, so the frontend can show a complete conversation history. The frontend uses this endpoint on page load and polls it regularly to turn the navbar item "Nachrichten" red when unread messages exist.
|
||||
|
||||
**Response (List\<MessageDTO\>)**
|
||||
|
||||
``` json
|
||||
[
|
||||
{
|
||||
"id": 0,
|
||||
"sender": "string",
|
||||
"recipient": "string",
|
||||
"type": "challenge",
|
||||
"text": "string",
|
||||
"read": false,
|
||||
"createdAt": "2026-05-23T15:42:00",
|
||||
"challenge": null
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"sender": "System",
|
||||
"recipient": "string",
|
||||
"type": "challenge-result",
|
||||
"text": "Daniela gewinnt gegen Florin mit 18 zu 14 Punkten.",
|
||||
"read": false,
|
||||
"createdAt": "2026-05-23T15:48:00",
|
||||
"challenge": {
|
||||
"id": 5,
|
||||
"challenger": "Florin",
|
||||
"opponent": "Daniela",
|
||||
"challengerScore": 14,
|
||||
"opponentScore": 18,
|
||||
"winner": "Daniela"
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**Status Codes**
|
||||
- 200 if messages were loaded successfully
|
||||
- 401 if authentication failed
|
||||
|
||||
### POST message
|
||||
|
||||
**Endpoint**
|
||||
|
||||
POST /messages
|
||||
|
||||
**Description** Creates a new message from the authenticated user to another existing user. The sender must be taken from the authentication headers, not from the request body.
|
||||
|
||||
**Request Body (CreateMessageDTO)**
|
||||
|
||||
``` json
|
||||
{
|
||||
"recipient": "string",
|
||||
"type": "challenge",
|
||||
"text": "string"
|
||||
}
|
||||
```
|
||||
|
||||
**Response (MessageDTO)**
|
||||
|
||||
``` json
|
||||
{
|
||||
"id": 0,
|
||||
"sender": "string",
|
||||
"recipient": "string",
|
||||
"type": "challenge",
|
||||
"text": "string",
|
||||
"read": false,
|
||||
"createdAt": "2026-05-23T15:42:00"
|
||||
}
|
||||
```
|
||||
|
||||
**Status Codes**
|
||||
- 201 if the message was created successfully
|
||||
- 400 if recipient, type, or text is missing or invalid
|
||||
- 401 if authentication failed
|
||||
- 404 if the recipient user does not exist
|
||||
|
||||
### PATCH single message as read
|
||||
|
||||
**Endpoint**
|
||||
|
||||
PATCH /messages/{id}/read
|
||||
|
||||
**Description** Marks one received message as read. Only the recipient of the message is allowed to mark it as read.
|
||||
|
||||
**Response (MessageDTO)**
|
||||
|
||||
``` json
|
||||
{
|
||||
"id": 0,
|
||||
"sender": "string",
|
||||
"recipient": "string",
|
||||
"type": "challenge",
|
||||
"text": "string",
|
||||
"read": true,
|
||||
"createdAt": "2026-05-23T15:42:00"
|
||||
}
|
||||
```
|
||||
|
||||
**Status Codes**
|
||||
- 200 if the message was marked as read
|
||||
- 401 if authentication failed
|
||||
- 403 if the authenticated user is not the recipient
|
||||
- 404 if the message does not exist
|
||||
|
||||
### PATCH all messages as read
|
||||
|
||||
**Endpoint**
|
||||
|
||||
PATCH /messages/read
|
||||
|
||||
**Description** Marks all received messages of the authenticated user as read. This endpoint is optional, but recommended. If it is not implemented, the frontend can call `PATCH /messages/{id}/read` for each unread message.
|
||||
|
||||
**Response**
|
||||
|
||||
``` json
|
||||
{
|
||||
"message": "Alle Nachrichten wurden als gelesen markiert."
|
||||
}
|
||||
```
|
||||
|
||||
**Status Codes**
|
||||
- 200 if all received messages were marked as read
|
||||
- 401 if authentication failed
|
||||
|
||||
------------------------------------------------------------------------
|
||||
|
||||
## 5. Challenges
|
||||
|
||||
Challenges are competitive rounds between two users. The challenged user's current reference result is sent back when the challenge is created. The challenger then plays a round. When the round is completed, the backend compares both scores and creates result messages for both users.
|
||||
|
||||
All challenge endpoints require authentication via the existing headers.
|
||||
|
||||
**Authentication Headers (required)**
|
||||
- X-Username
|
||||
- X-Password
|
||||
|
||||
### POST challenge
|
||||
|
||||
**Endpoint**
|
||||
|
||||
POST /challenges
|
||||
|
||||
**Description** Creates a challenge from the authenticated user against another user. The backend should determine the opponent's reference result, ideally the opponent's best score from `/score/{opponent}`. The response is used by the frontend to start the challenger into the game with the opponent score visible.
|
||||
|
||||
**Request Body (CreateChallengeDTO)**
|
||||
|
||||
``` json
|
||||
{
|
||||
"opponent": "string",
|
||||
"text": "Ich fordere dich zu einer Lorem-Ipsum-Challenge heraus!"
|
||||
}
|
||||
```
|
||||
|
||||
**Response (ChallengeDTO)**
|
||||
|
||||
``` json
|
||||
{
|
||||
"id": 0,
|
||||
"challenger": "string",
|
||||
"opponent": "string",
|
||||
"opponentScore": 18,
|
||||
"opponentScoreId": 42,
|
||||
"status": "open",
|
||||
"createdAt": "2026-05-23T15:42:00"
|
||||
}
|
||||
```
|
||||
|
||||
**Backend behavior**
|
||||
- The sender/challenger must be taken from the authentication headers.
|
||||
- `opponentScore` should be the opponent's best available score.
|
||||
- If the opponent has no score yet, return `opponentScore: 0` or reject with 409. Returning 0 is easier for the frontend.
|
||||
- The backend may also create an informational message for the opponent that they were challenged.
|
||||
|
||||
**Status Codes**
|
||||
- 201 if the challenge was created successfully
|
||||
- 400 if opponent is missing or invalid
|
||||
- 401 if authentication failed
|
||||
- 404 if the opponent user does not exist
|
||||
- 409 optional, if the opponent has no score and the backend does not want to use 0
|
||||
|
||||
### POST complete challenge
|
||||
|
||||
**Endpoint**
|
||||
|
||||
POST /challenges/{id}/complete
|
||||
|
||||
**Description** Completes an open challenge with the authenticated user's played result. The backend compares challenger and opponent scores and creates a `challenge-result` message for both users.
|
||||
|
||||
**Request Body (CompleteChallengeDTO)**
|
||||
|
||||
``` json
|
||||
{
|
||||
"score": 14,
|
||||
"time": 15,
|
||||
"text": "string",
|
||||
"userWrittenText": "string"
|
||||
}
|
||||
```
|
||||
|
||||
**Response (ChallengeResultDTO)**
|
||||
|
||||
``` json
|
||||
{
|
||||
"id": 0,
|
||||
"challenger": "Florin",
|
||||
"opponent": "Daniela",
|
||||
"challengerScore": 14,
|
||||
"opponentScore": 18,
|
||||
"winner": "Daniela",
|
||||
"status": "completed",
|
||||
"completedAt": "2026-05-23T15:48:00",
|
||||
"messages": [
|
||||
{
|
||||
"id": 10,
|
||||
"recipient": "Florin",
|
||||
"type": "challenge-result"
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"recipient": "Daniela",
|
||||
"type": "challenge-result"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
For a draw, `winner` can be `null` or `"draw"`.
|
||||
|
||||
**Backend behavior**
|
||||
- Only the original challenger should be allowed to complete the challenge.
|
||||
- The backend should persist the challenger's new result or link to the already persisted score if `/score` is called separately.
|
||||
- After comparing the scores, the backend must create one unread result message for the challenger and one unread result message for the opponent.
|
||||
- The result message should have `type: "challenge-result"` and include the nested `challenge` result object shown in `GET /messages`, so the frontend can render the winner/loser graphic.
|
||||
|
||||
**Status Codes**
|
||||
- 200 if the challenge was completed successfully
|
||||
- 400 if score data is missing or invalid
|
||||
- 401 if authentication failed
|
||||
- 403 if the authenticated user is not allowed to complete the challenge
|
||||
- 404 if the challenge does not exist
|
||||
- 409 if the challenge was already completed
|
||||
|
||||
|
||||
|
||||
|
||||
BIN
image/sieg.png
Normal file
BIN
image/sieg.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 111 KiB |
BIN
image/unentschieden.png
Normal file
BIN
image/unentschieden.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 144 KiB |
BIN
image/verloren.png
Normal file
BIN
image/verloren.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 118 KiB |
12
index.html
12
index.html
@ -7,7 +7,7 @@
|
||||
<link rel="icon" type="image/png" href="image/icon_l.png">
|
||||
<!-- Bootstrap CSS -->
|
||||
<link rel="stylesheet" href="assets/bootstrap-5.3.8-dist/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="assets/css/custom.css">
|
||||
<link rel="stylesheet" href="assets/css/custom.css?v=challenge-flow-20260528e">
|
||||
|
||||
<!-- Kleine Korrektur direkt hier, falls Sie custom.css nicht sofort ändern wollen -->
|
||||
<style>
|
||||
@ -131,10 +131,14 @@
|
||||
<script src="assets/src/service/user-service.js"></script>
|
||||
<script src="assets/src/service/score-service.js"></script>
|
||||
<script src="assets/src/service/leaderboard-service.js"></script>
|
||||
<script src="assets/src/service/message-service.js"></script>
|
||||
<script src="assets/src/service/challenge-service.js"></script>
|
||||
<script src="js/login.js"></script>
|
||||
<script src="js/leaderboard.js"></script>
|
||||
<script src="js/scores.js"></script>
|
||||
<script src="js/messages.js?v=challenge-flow-20260531b"></script>
|
||||
<!--Navigation Script -->
|
||||
<script src="js/play.js"></script>
|
||||
<script src="js/navigation.js"></script>
|
||||
<script src="js/play.js?v=challenge-flow-20260531b"></script>
|
||||
<script src="js/navigation.js?v=challenge-flow-20260528"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
12
js/login.js
12
js/login.js
@ -137,6 +137,9 @@
|
||||
setFeedback("Login erfolgreich.", "success");
|
||||
updateSessionBox();
|
||||
updateHeaderUsername();
|
||||
if (typeof window.updateMessagesNavState === "function") {
|
||||
window.updateMessagesNavState();
|
||||
}
|
||||
setTimeout(function() {
|
||||
if (typeof window.loadPage === "function") {
|
||||
window.loadPage("home", "nav-home");
|
||||
@ -178,6 +181,9 @@
|
||||
saveAuth(createdName, createdPassword);
|
||||
updateSessionBox();
|
||||
updateHeaderUsername();
|
||||
if (typeof window.updateMessagesNavState === "function") {
|
||||
window.updateMessagesNavState();
|
||||
}
|
||||
|
||||
// Modal mit Daten füllen und anzeigen
|
||||
const modalUsername = document.getElementById("modal-username");
|
||||
@ -240,6 +246,9 @@
|
||||
setFeedback("Du wurdest ausgeloggt.", "info");
|
||||
updateSessionBox();
|
||||
updateHeaderUsername();
|
||||
if (typeof window.updateMessagesNavState === "function") {
|
||||
window.updateMessagesNavState();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteAccount() {
|
||||
@ -267,6 +276,9 @@
|
||||
clearAuth();
|
||||
updateSessionBox();
|
||||
updateHeaderUsername();
|
||||
if (typeof window.updateMessagesNavState === "function") {
|
||||
window.updateMessagesNavState();
|
||||
}
|
||||
setFeedback("Account wurde gelöscht.", "success");
|
||||
return;
|
||||
}
|
||||
|
||||
970
js/messages.js
Normal file
970
js/messages.js
Normal file
@ -0,0 +1,970 @@
|
||||
(function () {
|
||||
const MESSAGE_TYPE_CHALLENGE = "challenge";
|
||||
const MESSAGE_TYPE_CHALLENGE_RESULT = "challenge-result";
|
||||
const MESSAGE_POLL_INTERVAL_MS = 30000;
|
||||
const ACTIVE_CHALLENGE_STORAGE_KEY = "loremIpsumActiveChallenge";
|
||||
const CHALLENGE_DATA_PREFIX = "[[loremIpsumChallenge:";
|
||||
const CHALLENGE_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 currentMessages = [];
|
||||
let currentUsers = [];
|
||||
let messagePollingInterval = null;
|
||||
|
||||
function getRandomChallengeTextPart(items) {
|
||||
return items[Math.floor(Math.random() * items.length)];
|
||||
}
|
||||
|
||||
function generateChallengeText() {
|
||||
const firstSentence =
|
||||
[
|
||||
getRandomChallengeTextPart(CHALLENGE_TEXT_PARTS.subjects),
|
||||
getRandomChallengeTextPart(CHALLENGE_TEXT_PARTS.actions),
|
||||
getRandomChallengeTextPart(CHALLENGE_TEXT_PARTS.objects),
|
||||
getRandomChallengeTextPart(CHALLENGE_TEXT_PARTS.places),
|
||||
].join(" ") + ".";
|
||||
|
||||
const secondSentence =
|
||||
[
|
||||
getRandomChallengeTextPart(CHALLENGE_TEXT_PARTS.subjects),
|
||||
getRandomChallengeTextPart(CHALLENGE_TEXT_PARTS.actions),
|
||||
getRandomChallengeTextPart(CHALLENGE_TEXT_PARTS.objects),
|
||||
getRandomChallengeTextPart(CHALLENGE_TEXT_PARTS.places),
|
||||
].join(" ") + ".";
|
||||
|
||||
return firstSentence +
|
||||
" " +
|
||||
secondSentence +
|
||||
" " +
|
||||
getRandomChallengeTextPart(CHALLENGE_TEXT_PARTS.endings);
|
||||
}
|
||||
|
||||
function buildEmbeddedChallengeText(challengeData, displayText) {
|
||||
return CHALLENGE_DATA_PREFIX +
|
||||
JSON.stringify(challengeData) +
|
||||
"]]" +
|
||||
"\n" +
|
||||
displayText;
|
||||
}
|
||||
|
||||
function getAuth() {
|
||||
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 normalizeUsername(username) {
|
||||
return String(username || "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
function getMessageService() {
|
||||
if (!window.config || !window.MessageService) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new window.MessageService(window.config);
|
||||
}
|
||||
|
||||
function getUserService() {
|
||||
if (!window.config || !window.UserService) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new window.UserService(window.config);
|
||||
}
|
||||
|
||||
function getChallengeService() {
|
||||
if (!window.config || !window.ChallengeService) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new window.ChallengeService(window.config);
|
||||
}
|
||||
|
||||
function normalizeMessage(message) {
|
||||
// Das Backend und unser Nachrichten-Fallback liefern Challenge-Daten leicht unterschiedlich.
|
||||
// Hier werden beide Formen in ein einheitliches Message-Objekt gebracht.
|
||||
const textCandidates = [
|
||||
message.text,
|
||||
message.content,
|
||||
message.message,
|
||||
message.challenge?.text,
|
||||
message.result?.text,
|
||||
];
|
||||
const rawText = textCandidates.find((value) => String(value ?? "").includes(CHALLENGE_DATA_PREFIX))
|
||||
?? textCandidates.find((value) => value !== null && value !== undefined)
|
||||
?? "";
|
||||
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);
|
||||
const challenge = embeddedChallenge.challenge
|
||||
? { ...(backendChallenge ?? {}), ...embeddedChallenge.challenge }
|
||||
: backendChallenge;
|
||||
|
||||
return {
|
||||
id: message.id,
|
||||
sender: message.sender ?? message.from ?? "",
|
||||
recipient: message.recipient ?? message.to ?? "",
|
||||
type: type,
|
||||
text: embeddedChallenge.text,
|
||||
read: Boolean(message.read),
|
||||
createdAt: message.createdAt ?? message.time ?? message.date ?? "",
|
||||
challenge: challenge,
|
||||
};
|
||||
}
|
||||
|
||||
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) {
|
||||
return {
|
||||
text: rawText,
|
||||
challenge: null,
|
||||
};
|
||||
}
|
||||
|
||||
const endIndex = rawText.indexOf("]]", startIndex);
|
||||
if (endIndex === -1) {
|
||||
return {
|
||||
text: rawText,
|
||||
challenge: null,
|
||||
};
|
||||
}
|
||||
|
||||
const json = rawText.slice(startIndex + CHALLENGE_DATA_PREFIX.length, endIndex);
|
||||
const displayText = (
|
||||
rawText.slice(0, startIndex) +
|
||||
rawText.slice(endIndex + 2)
|
||||
).trim();
|
||||
|
||||
try {
|
||||
return {
|
||||
text: displayText,
|
||||
challenge: JSON.parse(json),
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
text: rawText,
|
||||
challenge: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getChallengeId(challenge) {
|
||||
return challenge?.challengeId ?? challenge?.challenge_id ?? challenge?.id ?? null;
|
||||
}
|
||||
|
||||
function getChallengeChallenger(challenge, fallbackName) {
|
||||
return challenge?.challenger
|
||||
?? challenge?.challengerName
|
||||
?? challenge?.sender
|
||||
?? challenge?.from
|
||||
?? fallbackName
|
||||
?? "";
|
||||
}
|
||||
|
||||
function getChallengeOpponent(challenge, fallbackName) {
|
||||
return challenge?.opponent
|
||||
?? challenge?.opponentName
|
||||
?? challenge?.challengedUser
|
||||
?? challenge?.recipient
|
||||
?? challenge?.to
|
||||
?? fallbackName
|
||||
?? "";
|
||||
}
|
||||
|
||||
function getOpponentScore(challenge) {
|
||||
return challenge?.opponentScore
|
||||
?? challenge?.challengedScore
|
||||
?? challenge?.challengedUserScore
|
||||
?? null;
|
||||
}
|
||||
|
||||
function getChallengeText(challenge) {
|
||||
return challenge?.challengeText
|
||||
?? challenge?.roundText
|
||||
?? challenge?.textToRemember
|
||||
?? null;
|
||||
}
|
||||
|
||||
function hasScore(value) {
|
||||
return value !== null && value !== undefined && value !== "";
|
||||
}
|
||||
|
||||
function getChallengeRole(message) {
|
||||
const auth = getAuth();
|
||||
const challenge = message.challenge;
|
||||
if (!auth || !challenge || getChallengeId(challenge) === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ownName = normalizeUsername(auth.username);
|
||||
const challenger = normalizeUsername(getChallengeChallenger(challenge, message.sender));
|
||||
const opponent = normalizeUsername(getChallengeOpponent(challenge, message.recipient));
|
||||
|
||||
if (ownName === opponent) {
|
||||
return "opponent";
|
||||
}
|
||||
|
||||
if (ownName === challenger) {
|
||||
return "challenger";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function canOpponentAcceptChallenge(message) {
|
||||
if (message.type !== MESSAGE_TYPE_CHALLENGE || !message.challenge) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const role = getChallengeRole(message);
|
||||
const challenge = message.challenge;
|
||||
const opponentScore = getOpponentScore(challenge);
|
||||
|
||||
return role === "opponent" && !hasScore(opponentScore) && !hasScore(challenge.challengerScore);
|
||||
}
|
||||
|
||||
function canChallengerPlayChallenge(message) {
|
||||
const role = getChallengeRole(message);
|
||||
const challenge = message.challenge;
|
||||
|
||||
return role === "challenger"
|
||||
&& hasScore(getOpponentScore(challenge))
|
||||
&& !hasScore(challenge.challengerScore);
|
||||
}
|
||||
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const opponentScore = getOpponentScore(challenge);
|
||||
const challengerHasScore = hasScore(challenge.challengerScore);
|
||||
const opponentHasScore = hasScore(opponentScore);
|
||||
|
||||
if (challengerHasScore && opponentHasScore) {
|
||||
return {
|
||||
disabled: true,
|
||||
label: "Challenge erledigt",
|
||||
};
|
||||
}
|
||||
|
||||
if (role === "opponent") {
|
||||
if (!opponentHasScore && !challengerHasScore) {
|
||||
return {
|
||||
disabled: false,
|
||||
label: "Challenge annehmen",
|
||||
role: "opponent",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
disabled: true,
|
||||
label: "Schon gespielt",
|
||||
};
|
||||
}
|
||||
|
||||
if (role === "challenger") {
|
||||
if (opponentHasScore && !challengerHasScore) {
|
||||
return {
|
||||
disabled: false,
|
||||
label: "Challenge spielen",
|
||||
role: "challenger",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
disabled: true,
|
||||
label: "Warte auf Gegner",
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
sessionStorage.setItem(
|
||||
ACTIVE_CHALLENGE_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
id: getChallengeId(challenge),
|
||||
role: role,
|
||||
challenger: challenger,
|
||||
opponent: otherUser,
|
||||
opponentScore: role === "challenger" ? getOpponentScore(challenge) : null,
|
||||
challengeText: getChallengeText(challenge),
|
||||
ownUsername: auth?.username ?? "",
|
||||
}),
|
||||
);
|
||||
|
||||
if (typeof window.loadPage === "function") {
|
||||
window.loadPage("play", "nav-play");
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeUser(user) {
|
||||
if (typeof user === "string") {
|
||||
return user;
|
||||
}
|
||||
|
||||
return user?.name ?? user?.username ?? "";
|
||||
}
|
||||
|
||||
function formatMessageTime(value) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return date.toLocaleString("de-CH", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function setFeedback(message, type) {
|
||||
const feedback = document.getElementById("messages-feedback");
|
||||
if (!feedback) {
|
||||
return;
|
||||
}
|
||||
|
||||
feedback.className = "alert alert-" + type + " mt-3 mb-0";
|
||||
feedback.textContent = message;
|
||||
feedback.classList.remove("d-none");
|
||||
}
|
||||
|
||||
function setFormEnabled(enabled) {
|
||||
const formElements = document.querySelectorAll(
|
||||
"#challenge-form button, #challenge-form select, #challenge-form textarea, #mark-read-button, #refresh-messages-button",
|
||||
);
|
||||
|
||||
formElements.forEach((element) => {
|
||||
element.disabled = !enabled;
|
||||
});
|
||||
}
|
||||
|
||||
function updateMessagesNavState(messages = currentMessages) {
|
||||
const navLink = document.getElementById("navbar-messages");
|
||||
if (!navLink) {
|
||||
return;
|
||||
}
|
||||
|
||||
const auth = getAuth();
|
||||
const ownName = normalizeUsername(auth?.username);
|
||||
const hasUnreadMessages = messages.some((message) => {
|
||||
const isIncoming = normalizeUsername(message.recipient) === ownName
|
||||
|| normalizeUsername(message.sender) !== ownName;
|
||||
return isIncoming && !message.read;
|
||||
});
|
||||
navLink.classList.toggle("has-unread-messages", hasUnreadMessages);
|
||||
}
|
||||
|
||||
async function markMessageReadOnClick(message, item) {
|
||||
const auth = getAuth();
|
||||
const messageService = getMessageService();
|
||||
if (!auth || !messageService || !message.id || message.read) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isIncoming = normalizeUsername(message.recipient) === normalizeUsername(auth.username)
|
||||
|| normalizeUsername(message.sender) !== normalizeUsername(auth.username);
|
||||
if (!isIncoming) {
|
||||
return;
|
||||
}
|
||||
|
||||
message.read = true;
|
||||
item.classList.remove("message-item-unread");
|
||||
updateMessagesNavState(currentMessages);
|
||||
|
||||
const result = await messageService.markMessageAsRead(
|
||||
auth.username,
|
||||
auth.password,
|
||||
message.id,
|
||||
);
|
||||
|
||||
if (!result.ok) {
|
||||
message.read = false;
|
||||
item.classList.add("message-item-unread");
|
||||
updateMessagesNavState(currentMessages);
|
||||
setFeedback("Nachricht konnte nicht als gelesen markiert werden.", "warning");
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const unreadIncomingMessages = messages.filter((message) => {
|
||||
const isIncoming = normalizeUsername(message.recipient) === normalizeUsername(auth.username)
|
||||
|| normalizeUsername(message.sender) !== normalizeUsername(auth.username);
|
||||
return isIncoming && !message.read && message.id;
|
||||
});
|
||||
|
||||
if (unreadIncomingMessages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
unreadIncomingMessages.forEach((message) => {
|
||||
message.read = true;
|
||||
});
|
||||
item.classList.remove("message-item-unread");
|
||||
updateMessagesNavState(currentMessages);
|
||||
|
||||
const results = await Promise.all(
|
||||
unreadIncomingMessages.map((message) =>
|
||||
messageService.markMessageAsRead(auth.username, auth.password, message.id),
|
||||
),
|
||||
);
|
||||
|
||||
if (results.some((result) => !result.ok)) {
|
||||
unreadIncomingMessages.forEach((message) => {
|
||||
message.read = false;
|
||||
});
|
||||
item.classList.add("message-item-unread");
|
||||
updateMessagesNavState(currentMessages);
|
||||
setFeedback("Challenge-Nachrichten konnten nicht als gelesen markiert werden.", "warning");
|
||||
}
|
||||
}
|
||||
|
||||
function getMessageTimeValue(message) {
|
||||
const time = new Date(message.createdAt).getTime();
|
||||
return Number.isNaN(time) ? 0 : time;
|
||||
}
|
||||
|
||||
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()
|
||||
.sort((a, b) => getMessageTimeValue(a) - getMessageTimeValue(b))
|
||||
.reduce((merged, message) => {
|
||||
if (!message.challenge) {
|
||||
return merged;
|
||||
}
|
||||
|
||||
Object.keys(message.challenge).forEach((key) => {
|
||||
const value = message.challenge[key];
|
||||
if (value !== null && value !== undefined && value !== "") {
|
||||
merged[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return merged;
|
||||
}, {});
|
||||
}
|
||||
|
||||
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);
|
||||
if (challengeId === null || challengeId === undefined) {
|
||||
standaloneGroups.push({
|
||||
kind: "message",
|
||||
latest: message,
|
||||
messages: [message],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const key = String(challengeId);
|
||||
if (!groupsByChallenge.has(key)) {
|
||||
groupsByChallenge.set(key, []);
|
||||
}
|
||||
groupsByChallenge.get(key).push(message);
|
||||
});
|
||||
|
||||
const challengeGroups = Array.from(groupsByChallenge.values()).map((groupMessages) => {
|
||||
const sortedMessages = groupMessages
|
||||
.slice()
|
||||
.sort((a, b) => getMessageTimeValue(b) - getMessageTimeValue(a));
|
||||
const latest = { ...sortedMessages[0] };
|
||||
latest.challenge = mergeChallengeData(sortedMessages);
|
||||
|
||||
return {
|
||||
kind: "challenge",
|
||||
latest: latest,
|
||||
messages: sortedMessages,
|
||||
};
|
||||
});
|
||||
|
||||
return standaloneGroups
|
||||
.concat(challengeGroups)
|
||||
.sort((a, b) => getMessageTimeValue(b.latest) - getMessageTimeValue(a.latest));
|
||||
}
|
||||
|
||||
function renderUserList(users) {
|
||||
const userList = document.getElementById("messages-user-list");
|
||||
const recipientSelect = document.getElementById("challenge-recipient");
|
||||
if (!userList || !recipientSelect) {
|
||||
return;
|
||||
}
|
||||
|
||||
const auth = getAuth();
|
||||
const ownName = normalizeUsername(auth?.username);
|
||||
const uniqueUsers = Array.from(new Set(users.map(normalizeUser)))
|
||||
.filter(Boolean)
|
||||
.filter((username) => normalizeUsername(username) !== ownName)
|
||||
.sort((a, b) => a.localeCompare(b, "de-CH"));
|
||||
|
||||
currentUsers = uniqueUsers;
|
||||
userList.innerHTML = "";
|
||||
recipientSelect.innerHTML = "";
|
||||
|
||||
if (uniqueUsers.length === 0) {
|
||||
const emptyMessage = document.createElement("p");
|
||||
emptyMessage.className = "messages-empty";
|
||||
emptyMessage.textContent = "Keine anderen User gefunden.";
|
||||
userList.appendChild(emptyMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
uniqueUsers.forEach((username) => {
|
||||
const userButton = document.createElement("button");
|
||||
userButton.type = "button";
|
||||
userButton.className = "messages-user-button";
|
||||
userButton.textContent = username;
|
||||
userButton.addEventListener("click", () => {
|
||||
recipientSelect.value = username;
|
||||
document.getElementById("challenge-text")?.focus();
|
||||
});
|
||||
userList.appendChild(userButton);
|
||||
|
||||
const option = document.createElement("option");
|
||||
option.value = username;
|
||||
option.textContent = username;
|
||||
recipientSelect.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
function renderMessages(messages = currentMessages) {
|
||||
// Rendert normale Nachrichten einzeln und Challenge-Nachrichten als gruppierte Karten.
|
||||
const messageList = document.getElementById("message-list");
|
||||
if (!messageList) {
|
||||
return;
|
||||
}
|
||||
|
||||
const auth = getAuth();
|
||||
const ownName = normalizeUsername(auth?.username);
|
||||
messageList.innerHTML = "";
|
||||
|
||||
if (messages.length === 0) {
|
||||
const emptyMessage = document.createElement("p");
|
||||
emptyMessage.className = "messages-empty";
|
||||
emptyMessage.textContent = "Noch keine Nachrichten vorhanden.";
|
||||
messageList.appendChild(emptyMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
groupMessagesByChallenge(messages)
|
||||
.forEach((messageGroup) => {
|
||||
const message = messageGroup.latest;
|
||||
const item = document.createElement("article");
|
||||
const isOutgoing = normalizeUsername(message.sender) === ownName;
|
||||
const hasUnreadIncoming = messageGroup.messages.some((groupMessage) => {
|
||||
const isIncoming = normalizeUsername(groupMessage.recipient) === ownName
|
||||
|| normalizeUsername(groupMessage.sender) !== ownName;
|
||||
return isIncoming && !groupMessage.read;
|
||||
});
|
||||
|
||||
item.className = "message-item";
|
||||
if (messageGroup.kind === "challenge") {
|
||||
item.classList.add("challenge-message-group");
|
||||
}
|
||||
if (hasUnreadIncoming) {
|
||||
item.classList.add("message-item-unread");
|
||||
item.addEventListener("click", () => markMessageGroupReadOnClick(messageGroup.messages, item));
|
||||
}
|
||||
|
||||
const fromToText = messageGroup.kind === "challenge"
|
||||
? "Challenge"
|
||||
: isOutgoing
|
||||
? "An " + message.recipient
|
||||
: "Von " + message.sender;
|
||||
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "message-meta";
|
||||
|
||||
const sender = document.createElement("strong");
|
||||
sender.textContent = fromToText;
|
||||
|
||||
const time = document.createElement("span");
|
||||
time.textContent = formatMessageTime(message.createdAt);
|
||||
|
||||
const text = document.createElement("p");
|
||||
text.textContent = message.text;
|
||||
|
||||
meta.append(sender, time);
|
||||
item.append(meta, text);
|
||||
|
||||
if (messageGroup.kind === "challenge" && messageGroup.messages.length > 1) {
|
||||
const toggleButton = document.createElement("button");
|
||||
toggleButton.type = "button";
|
||||
toggleButton.className = "challenge-thread-toggle";
|
||||
toggleButton.textContent = "Verlauf anzeigen";
|
||||
|
||||
const thread = document.createElement("div");
|
||||
thread.className = "challenge-thread d-none";
|
||||
|
||||
messageGroup.messages.forEach((threadMessage) => {
|
||||
const threadItem = document.createElement("div");
|
||||
threadItem.className = "challenge-thread-item";
|
||||
|
||||
const threadMeta = document.createElement("span");
|
||||
threadMeta.textContent = (normalizeUsername(threadMessage.sender) === ownName
|
||||
? "Du"
|
||||
: threadMessage.sender || "System") + " · " + formatMessageTime(threadMessage.createdAt);
|
||||
|
||||
const threadText = document.createElement("p");
|
||||
threadText.textContent = threadMessage.text;
|
||||
|
||||
threadItem.append(threadMeta, threadText);
|
||||
thread.appendChild(threadItem);
|
||||
});
|
||||
|
||||
toggleButton.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
const isCollapsed = thread.classList.toggle("d-none");
|
||||
toggleButton.textContent = isCollapsed ? "Verlauf anzeigen" : "Verlauf ausblenden";
|
||||
});
|
||||
|
||||
item.appendChild(toggleButton);
|
||||
item.appendChild(thread);
|
||||
}
|
||||
|
||||
if (message.challenge && hasScore(message.challenge.challengerScore) && hasScore(message.challenge.opponentScore)) {
|
||||
item.appendChild(createChallengeResultGraphic(message.challenge));
|
||||
}
|
||||
|
||||
const challengeButtonState = getChallengeButtonState(message);
|
||||
if (challengeButtonState) {
|
||||
const challengeButton = document.createElement("button");
|
||||
challengeButton.type = "button";
|
||||
challengeButton.className = "btn btn-sm mt-3 challenge-status-button";
|
||||
challengeButton.textContent = challengeButtonState.label;
|
||||
challengeButton.disabled = challengeButtonState.disabled;
|
||||
if (!challengeButtonState.disabled) {
|
||||
challengeButton.addEventListener("click", () => startChallenge(message, challengeButtonState.role));
|
||||
}
|
||||
item.appendChild(challengeButton);
|
||||
}
|
||||
|
||||
messageList.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
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";
|
||||
|
||||
const winnerName = result.winner ?? "Unentschieden";
|
||||
const isDraw = result.winner === null || result.winner === "draw";
|
||||
const auth = getAuth();
|
||||
const isOwnWin = !isDraw && normalizeUsername(winnerName) === normalizeUsername(auth?.username);
|
||||
const outcome = isDraw ? "draw" : isOwnWin ? "win" : "loss";
|
||||
const outcomeText = outcome === "draw"
|
||||
? "Unentschieden"
|
||||
: outcome === "win"
|
||||
? "Gewonnen"
|
||||
: "Verloren";
|
||||
const outcomeImage = outcome === "draw"
|
||||
? "image/unentschieden.png"
|
||||
: outcome === "win"
|
||||
? "image/sieg.png"
|
||||
: "image/verloren.png";
|
||||
|
||||
const outcomeHeader = document.createElement("div");
|
||||
outcomeHeader.className = "challenge-outcome challenge-outcome-" + outcome;
|
||||
|
||||
const outcomeImg = document.createElement("img");
|
||||
outcomeImg.src = outcomeImage;
|
||||
outcomeImg.alt = outcomeText;
|
||||
|
||||
const outcomeLabel = document.createElement("strong");
|
||||
outcomeLabel.textContent = outcomeText;
|
||||
|
||||
outcomeHeader.append(outcomeImg, outcomeLabel);
|
||||
|
||||
const headline = document.createElement("div");
|
||||
headline.className = "challenge-result-headline " + (isDraw ? "challenge-result-draw" : "challenge-result-win");
|
||||
headline.textContent = isDraw ? "Unentschieden" : "Sieger: " + winnerName;
|
||||
|
||||
const scores = document.createElement("div");
|
||||
scores.className = "challenge-result-scores";
|
||||
|
||||
const challenger = document.createElement("div");
|
||||
challenger.className = "challenge-result-score";
|
||||
challenger.innerHTML = "<strong></strong><span></span>";
|
||||
challenger.querySelector("strong").textContent = result.challenger ?? "Herausforderer";
|
||||
challenger.querySelector("span").textContent = String(result.challengerScore ?? "-") + " Punkte";
|
||||
|
||||
const opponent = document.createElement("div");
|
||||
opponent.className = "challenge-result-score";
|
||||
opponent.innerHTML = "<strong></strong><span></span>";
|
||||
opponent.querySelector("strong").textContent = result.opponent ?? "Gegner";
|
||||
opponent.querySelector("span").textContent = String(result.opponentScore ?? "-") + " Punkte";
|
||||
|
||||
scores.append(challenger, opponent);
|
||||
graphic.append(outcomeHeader, headline, scores);
|
||||
|
||||
return graphic;
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
const auth = getAuth();
|
||||
const userService = getUserService();
|
||||
if (!auth || !userService || typeof userService.getUsers !== "function") {
|
||||
renderUserList([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await userService.getUsers(auth.username, auth.password);
|
||||
if (!result.ok || !Array.isArray(result.body)) {
|
||||
renderUserList([]);
|
||||
setFeedback("User konnten nicht geladen werden. Backend-Endpunkt GET /users fehlt eventuell noch.", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
renderUserList(result.body);
|
||||
}
|
||||
|
||||
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");
|
||||
|
||||
if (!auth) {
|
||||
if (loggedInDiv) loggedInDiv.classList.add("d-none");
|
||||
if (loggedOutDiv) loggedOutDiv.classList.remove("d-none");
|
||||
|
||||
currentMessages = [];
|
||||
renderMessages();
|
||||
updateMessagesNavState();
|
||||
setFormEnabled(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (loggedInDiv) loggedInDiv.classList.remove("d-none");
|
||||
if (loggedOutDiv) loggedOutDiv.classList.add("d-none");
|
||||
|
||||
if (!messageService) {
|
||||
setFormEnabled(false);
|
||||
setFeedback("Message-Service konnte nicht geladen werden.", "danger");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await messageService.getMessages(auth.username, auth.password);
|
||||
if (!result.ok || !Array.isArray(result.body)) {
|
||||
currentMessages = [];
|
||||
renderMessages();
|
||||
updateMessagesNavState();
|
||||
setFormEnabled(false);
|
||||
setFeedback("Nachrichten konnten nicht geladen werden. Backend-Endpunkt GET /messages fehlt eventuell noch.", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
currentMessages = result.body.map(normalizeMessage);
|
||||
renderMessages();
|
||||
updateMessagesNavState();
|
||||
setFormEnabled(true);
|
||||
|
||||
if (options.showFeedback) {
|
||||
setFeedback("Nachrichten wurden aktualisiert.", "success");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleChallengeSubmit(event) {
|
||||
// Erstellt eine Challenge. Der Gegner spielt zuerst; der weitere Ablauf wird ueber Nachrichten gesteuert.
|
||||
event.preventDefault();
|
||||
|
||||
const auth = getAuth();
|
||||
const challengeService = getChallengeService();
|
||||
const recipientSelect = document.getElementById("challenge-recipient");
|
||||
const textInput = document.getElementById("challenge-text");
|
||||
if (!auth || !challengeService || !recipientSelect || !textInput) {
|
||||
setFeedback("Bitte zuerst einloggen, um Challenges zu senden.", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
const recipient = recipientSelect.value;
|
||||
const text = textInput.value.trim();
|
||||
if (!recipient || !text) {
|
||||
setFeedback("Bitte Empfaenger und Nachricht eingeben.", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
const challengeText = generateChallengeText();
|
||||
const challengeMessage = buildEmbeddedChallengeText(
|
||||
{ challengeText: challengeText },
|
||||
text,
|
||||
);
|
||||
|
||||
const result = await challengeService.postChallenge(
|
||||
auth.username,
|
||||
auth.password,
|
||||
recipient,
|
||||
challengeMessage,
|
||||
);
|
||||
|
||||
if (!result.ok) {
|
||||
setFeedback("Challenge konnte nicht gesendet werden (Status " + result.status + ").", "danger");
|
||||
return;
|
||||
}
|
||||
|
||||
sessionStorage.removeItem(ACTIVE_CHALLENGE_STORAGE_KEY);
|
||||
setFeedback(
|
||||
"Challenge an " + recipient + " wurde gesendet. Der Gegner spielt zuerst; danach bekommst du sein Resultat.",
|
||||
"success",
|
||||
);
|
||||
await loadMessages({ showFeedback: false });
|
||||
}
|
||||
|
||||
async function handleMarkRead() {
|
||||
const auth = getAuth();
|
||||
const messageService = getMessageService();
|
||||
if (!auth || !messageService) {
|
||||
setFeedback("Bitte zuerst einloggen, um Nachrichten zu markieren.", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await messageService.markAllMessagesAsRead(auth.username, auth.password);
|
||||
if (!result.ok) {
|
||||
const unreadMessages = currentMessages.filter((message) => !message.read);
|
||||
const readResults = await Promise.all(
|
||||
unreadMessages.map((message) =>
|
||||
messageService.markMessageAsRead(auth.username, auth.password, message.id),
|
||||
),
|
||||
);
|
||||
|
||||
if (readResults.some((readResult) => !readResult.ok)) {
|
||||
setFeedback("Nachrichten konnten nicht als gelesen markiert werden.", "danger");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setFeedback("Alle Nachrichten wurden als gelesen markiert.", "info");
|
||||
await loadMessages({ showFeedback: false });
|
||||
}
|
||||
|
||||
async function initMessagesPage() {
|
||||
const challengeForm = document.getElementById("challenge-form");
|
||||
const markReadButton = document.getElementById("mark-read-button");
|
||||
const refreshMessagesButton = document.getElementById("refresh-messages-button");
|
||||
|
||||
if (challengeForm) {
|
||||
challengeForm.addEventListener("submit", handleChallengeSubmit);
|
||||
}
|
||||
if (markReadButton) {
|
||||
markReadButton.addEventListener("click", handleMarkRead);
|
||||
}
|
||||
if (refreshMessagesButton) {
|
||||
refreshMessagesButton.addEventListener("click", () => loadMessages({ showFeedback: true }));
|
||||
}
|
||||
|
||||
setFormEnabled(Boolean(getAuth()));
|
||||
await loadUsers();
|
||||
await loadMessages({ showFeedback: false });
|
||||
}
|
||||
|
||||
window.initMessagesPage = initMessagesPage;
|
||||
window.updateMessagesNavState = function () {
|
||||
loadMessages({ showFeedback: false }).catch((error) => {
|
||||
console.error("Nachrichtenstatus konnte nicht geladen werden:", error);
|
||||
updateMessagesNavState([]);
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
window.updateMessagesNavState();
|
||||
if (!messagePollingInterval) {
|
||||
messagePollingInterval = window.setInterval(
|
||||
window.updateMessagesNavState,
|
||||
MESSAGE_POLL_INTERVAL_MS,
|
||||
);
|
||||
}
|
||||
});
|
||||
})();
|
||||
@ -9,8 +9,11 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
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.
|
||||
window.loadPage = function loadPage(page, menuId) {
|
||||
fetch("pages/" + page + ".html")
|
||||
fetch("pages/" + page + ".html", { cache: "no-store" })
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error("HTTP " + response.status);
|
||||
@ -26,9 +29,15 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
if (page === "leaderboard" && typeof window.initLeaderboardPage === "function") {
|
||||
window.initLeaderboardPage();
|
||||
}
|
||||
if (page === "scores" && typeof window.initScoresPage === "function") {
|
||||
window.initScoresPage();
|
||||
}
|
||||
if (page === "play" && typeof window.initPlayPage === "function") {
|
||||
window.initPlayPage();
|
||||
}
|
||||
if (page === "messages" && typeof window.initMessagesPage === "function") {
|
||||
window.initMessagesPage();
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Fehler beim Laden von " + page + ":", error);
|
||||
@ -36,6 +45,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
});
|
||||
};
|
||||
|
||||
// Navigation wird zentral verdrahtet, damit alle Seiten ueber denselben Lade-Mechanismus laufen.
|
||||
const navHome = document.getElementById("nav-home");
|
||||
const navPlay = document.getElementById("nav-play");
|
||||
const navMyScores = document.getElementById("nav-my-scores");
|
||||
@ -49,6 +59,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
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"); };
|
||||
if (typeof window.updateMessagesNavState === "function") window.updateMessagesNavState();
|
||||
|
||||
//Startseite laden
|
||||
loadPage("home", "nav-home");
|
||||
|
||||
423
js/play.js
423
js/play.js
@ -1,6 +1,8 @@
|
||||
(function () {
|
||||
// --- Konfiguration ---
|
||||
const MEMORIZE_TIME_SECONDS = 15;
|
||||
const ACTIVE_CHALLENGE_STORAGE_KEY = "loremIpsumActiveChallenge";
|
||||
const CHALLENGE_DATA_PREFIX = "[[loremIpsumChallenge:";
|
||||
|
||||
// Bausteine fuer den zufaelligen Rundentext. Alles bleibt lokal, damit das Spiel ohne Backend starten kann.
|
||||
const TEXT_PARTS = {
|
||||
@ -56,6 +58,7 @@
|
||||
|
||||
let timerInterval;
|
||||
let currentTime = 0;
|
||||
let inputStartMs = null;
|
||||
|
||||
// Der aktuell angezeigte Text muss bis zur Auswertung stabil bleiben.
|
||||
let currentGameText = "";
|
||||
@ -75,6 +78,7 @@
|
||||
let gameStatus;
|
||||
let scoreSaveFeedback;
|
||||
let btnSubmitScore;
|
||||
let activeChallenge = null;
|
||||
|
||||
// --- Funktionen ---
|
||||
|
||||
@ -118,6 +122,8 @@
|
||||
function startGame() {
|
||||
if (!phaseStart || !phaseMemorize) return;
|
||||
|
||||
inputStartMs = null;
|
||||
|
||||
// Startansicht ausblenden und den neu generierten Text fuer die Lernphase anzeigen.
|
||||
phaseStart.classList.add("d-none");
|
||||
phaseMemorize.classList.remove("d-none");
|
||||
@ -129,7 +135,7 @@
|
||||
gameStatus.style.color = "#1b1b2f";
|
||||
}
|
||||
|
||||
currentGameText = generateGameText();
|
||||
currentGameText = getRoundText();
|
||||
if (targetTextDisplay) targetTextDisplay.textContent = currentGameText;
|
||||
|
||||
// Nach Ablauf des Timers wird automatisch zur Eingabe gewechselt.
|
||||
@ -166,11 +172,19 @@
|
||||
userTextInput.value = "";
|
||||
userTextInput.focus();
|
||||
}
|
||||
|
||||
inputStartMs = Date.now();
|
||||
}
|
||||
|
||||
// Entfernt alles, was beim Vergleichen nicht zaehlen soll.
|
||||
function normalizeWord(word) {
|
||||
return word.toLowerCase().replace(/[.,\/#!$%\^&\*;:{}=\-_`~()]/g, "");
|
||||
return word
|
||||
.toLowerCase()
|
||||
.replace(/ä/g, "ae")
|
||||
.replace(/ö/g, "oe")
|
||||
.replace(/ü/g, "ue")
|
||||
.replace(/ß/g, "ss")
|
||||
.replace(/[.,\/#!$%\^&\*;:{}=\-_`~()]/g, "");
|
||||
}
|
||||
|
||||
// Behält die sichtbaren Woerter separat, damit Satzzeichen in der Ergebnisanzeige erhalten bleiben.
|
||||
@ -251,6 +265,80 @@
|
||||
return new window.ScoreService(window.config);
|
||||
}
|
||||
|
||||
function getChallengeService() {
|
||||
if (!window.config || !window.ChallengeService) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new window.ChallengeService(window.config);
|
||||
}
|
||||
|
||||
function getMessageService() {
|
||||
if (!window.config || !window.MessageService) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new window.MessageService(window.config);
|
||||
}
|
||||
|
||||
function readActiveChallenge() {
|
||||
// Challenge-Runden werden aus der Nachrichten-Seite gestartet.
|
||||
// Der dafuer gespeicherte Kontext entscheidet, welcher API-/Nachrichten-Flow nach dem Spiel laeuft.
|
||||
const raw = sessionStorage.getItem(ACTIVE_CHALLENGE_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const challenge = JSON.parse(raw);
|
||||
if (!challenge || challenge.id === undefined || challenge.id === null || !challenge.opponent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return challenge;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function hasActiveChallenge() {
|
||||
return Boolean(activeChallenge && activeChallenge.id !== undefined && activeChallenge.id !== null);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function isChallengeFirstRound() {
|
||||
// In der ersten Runde spielt der herausgeforderte User und sendet sein Resultat an den Herausforderer.
|
||||
return hasActiveChallenge() && activeChallenge.role === "opponent";
|
||||
}
|
||||
|
||||
function writeActiveChallenge(challenge) {
|
||||
sessionStorage.setItem(ACTIVE_CHALLENGE_STORAGE_KEY, JSON.stringify(challenge));
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
if (typeof activeChallenge.challengeText === "string" && activeChallenge.challengeText.trim()) {
|
||||
return activeChallenge.challengeText;
|
||||
}
|
||||
|
||||
const generatedText = generateGameText();
|
||||
activeChallenge.challengeText = generatedText;
|
||||
writeActiveChallenge(activeChallenge);
|
||||
return generatedText;
|
||||
}
|
||||
|
||||
function showScoreSaveFeedback(message, type) {
|
||||
if (!scoreSaveFeedback) return;
|
||||
|
||||
@ -260,6 +348,8 @@
|
||||
}
|
||||
|
||||
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(
|
||||
@ -316,7 +406,289 @@
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const opponentScore = Number(activeChallenge.opponentScore);
|
||||
if (Number.isNaN(opponentScore)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const auth = getAuth();
|
||||
const ownName = auth?.username || "Du";
|
||||
const opponentName = activeChallenge.opponent;
|
||||
const result = score > opponentScore
|
||||
? "winner"
|
||||
: score < opponentScore
|
||||
? "loser"
|
||||
: "draw";
|
||||
|
||||
const headline = result === "winner"
|
||||
? "Du gewinnst die Challenge"
|
||||
: result === "loser"
|
||||
? opponentName + " gewinnt die Challenge"
|
||||
: "Unentschieden";
|
||||
const outcomeText = result === "winner"
|
||||
? "Gewonnen"
|
||||
: result === "loser"
|
||||
? "Verloren"
|
||||
: "Unentschieden";
|
||||
const outcomeImage = result === "winner"
|
||||
? "image/sieg.png"
|
||||
: result === "loser"
|
||||
? "image/verloren.png"
|
||||
: "image/unentschieden.png";
|
||||
|
||||
const graphic = document.createElement("div");
|
||||
graphic.className = "play-challenge-result play-challenge-result-" + result;
|
||||
|
||||
const outcomeHeader = document.createElement("div");
|
||||
outcomeHeader.className = "challenge-outcome challenge-outcome-" + (result === "winner" ? "win" : result === "loser" ? "loss" : "draw");
|
||||
|
||||
const outcomeImg = document.createElement("img");
|
||||
outcomeImg.src = outcomeImage;
|
||||
outcomeImg.alt = outcomeText;
|
||||
|
||||
const outcomeLabel = document.createElement("strong");
|
||||
outcomeLabel.textContent = outcomeText;
|
||||
|
||||
outcomeHeader.append(outcomeImg, outcomeLabel);
|
||||
|
||||
const title = document.createElement("div");
|
||||
title.className = "play-challenge-result-title";
|
||||
title.textContent = headline;
|
||||
|
||||
const scores = document.createElement("div");
|
||||
scores.className = "play-challenge-result-scores";
|
||||
|
||||
const ownScore = document.createElement("div");
|
||||
ownScore.className = "play-challenge-result-score";
|
||||
ownScore.innerHTML = "<strong></strong><span></span>";
|
||||
ownScore.querySelector("strong").textContent = ownName;
|
||||
ownScore.querySelector("span").textContent = score + " Punkte";
|
||||
|
||||
const otherScore = document.createElement("div");
|
||||
otherScore.className = "play-challenge-result-score";
|
||||
otherScore.innerHTML = "<strong></strong><span></span>";
|
||||
otherScore.querySelector("strong").textContent = opponentName;
|
||||
otherScore.querySelector("span").textContent = opponentScore + " Punkte";
|
||||
|
||||
scores.append(ownScore, otherScore);
|
||||
graphic.append(outcomeHeader, title, scores);
|
||||
scoreSaveFeedback.insertAdjacentElement("afterend", graphic);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const challengeService = getChallengeService();
|
||||
if (!challengeService) {
|
||||
showScoreSaveFeedback(
|
||||
"Score gespeichert, aber der Challenge-Service konnte nicht geladen werden.",
|
||||
"warning",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await challengeService.completeChallenge(
|
||||
auth.username,
|
||||
auth.password,
|
||||
activeChallenge.id,
|
||||
scoreData.score,
|
||||
scoreData.time,
|
||||
scoreData.text,
|
||||
scoreData.userWrittenText,
|
||||
);
|
||||
|
||||
if (!result.ok) {
|
||||
console.warn("Challenge-Abschluss nicht moeglich, nutze Nachrichten-Fallback. Status:", result.status);
|
||||
return false;
|
||||
}
|
||||
|
||||
sessionStorage.removeItem(ACTIVE_CHALLENGE_STORAGE_KEY);
|
||||
activeChallenge = null;
|
||||
showScoreSaveFeedback(
|
||||
"Challenge abgeschlossen. Beide User erhalten eine Ergebnisnachricht.",
|
||||
"success",
|
||||
);
|
||||
|
||||
if (typeof window.updateMessagesNavState === "function") {
|
||||
window.updateMessagesNavState();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function finishFirstChallengeRound(scoreData) {
|
||||
// Der Gegner speichert zuerst seinen Score und informiert danach den Herausforderer.
|
||||
await saveScore(scoreData);
|
||||
await notifyChallenger(scoreData);
|
||||
|
||||
sessionStorage.removeItem(ACTIVE_CHALLENGE_STORAGE_KEY);
|
||||
activeChallenge = null;
|
||||
showScoreSaveFeedback("Resultat wurde an den Herausforderer gesendet.", "success");
|
||||
|
||||
if (typeof window.updateMessagesNavState === "function") {
|
||||
window.updateMessagesNavState();
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const challengeData = {
|
||||
id: activeChallenge.id,
|
||||
challenger: activeChallenge.challenger,
|
||||
opponent: auth.username,
|
||||
opponentScore: scoreData.score,
|
||||
challengeText: scoreData.text,
|
||||
};
|
||||
|
||||
const messageText =
|
||||
CHALLENGE_DATA_PREFIX +
|
||||
JSON.stringify(challengeData) +
|
||||
"]]" +
|
||||
"\n" +
|
||||
auth.username +
|
||||
" hat gespielt und " +
|
||||
scoreData.score +
|
||||
" Punkte erreicht. Jetzt bist du dran.";
|
||||
|
||||
const result = await messageService.postMessage(
|
||||
auth.username,
|
||||
auth.password,
|
||||
activeChallenge.challenger,
|
||||
"challenge",
|
||||
messageText,
|
||||
);
|
||||
|
||||
if (!result.ok) {
|
||||
showScoreSaveFeedback(
|
||||
"Challenge wurde gespeichert, aber die Nachricht an den Herausforderer konnte nicht gesendet werden.",
|
||||
"warning",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getChallengeWinner(challengerScore, opponentScore) {
|
||||
if (challengerScore > opponentScore) {
|
||||
return activeChallenge.challenger || getAuth()?.username || "Herausforderer";
|
||||
}
|
||||
|
||||
if (challengerScore < opponentScore) {
|
||||
return activeChallenge.opponent || "Gegner";
|
||||
}
|
||||
|
||||
return "draw";
|
||||
}
|
||||
|
||||
async function sendChallengeResultMessage(recipient, resultData, text) {
|
||||
const auth = getAuth();
|
||||
const messageService = getMessageService();
|
||||
if (!auth || !messageService || !recipient) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const messageText =
|
||||
CHALLENGE_DATA_PREFIX +
|
||||
JSON.stringify(resultData) +
|
||||
"]]" +
|
||||
"\n" +
|
||||
text;
|
||||
|
||||
const result = await messageService.postMessage(
|
||||
auth.username,
|
||||
auth.password,
|
||||
recipient,
|
||||
"challenge",
|
||||
messageText,
|
||||
);
|
||||
|
||||
return result.ok;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
await saveScore(scoreData);
|
||||
|
||||
const opponentScore = Number(activeChallenge.opponentScore);
|
||||
const winner = getChallengeWinner(scoreData.score, opponentScore);
|
||||
const resultData = {
|
||||
id: activeChallenge.id,
|
||||
challenger: activeChallenge.challenger || auth.username,
|
||||
opponent: activeChallenge.opponent,
|
||||
challengerScore: scoreData.score,
|
||||
opponentScore: opponentScore,
|
||||
challengeText: scoreData.text,
|
||||
winner: winner,
|
||||
};
|
||||
|
||||
const winnerText = winner === "draw"
|
||||
? "Unentschieden"
|
||||
: "Gewinner: " + winner;
|
||||
const resultText =
|
||||
"Challenge abgeschlossen. " +
|
||||
resultData.challenger +
|
||||
": " +
|
||||
resultData.challengerScore +
|
||||
" Punkte, " +
|
||||
resultData.opponent +
|
||||
": " +
|
||||
resultData.opponentScore +
|
||||
" Punkte. " +
|
||||
winnerText +
|
||||
".";
|
||||
|
||||
const opponentMessageSent = await sendChallengeResultMessage(
|
||||
activeChallenge.opponent,
|
||||
resultData,
|
||||
resultText,
|
||||
);
|
||||
|
||||
sessionStorage.removeItem(ACTIVE_CHALLENGE_STORAGE_KEY);
|
||||
activeChallenge = null;
|
||||
|
||||
if (!opponentMessageSent) {
|
||||
showScoreSaveFeedback(
|
||||
"Score gespeichert, aber die Ergebnisnachricht an den Gegner konnte nicht gesendet werden.",
|
||||
"warning",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
showScoreSaveFeedback(
|
||||
"Challenge abgeschlossen. Ergebnisnachricht wurde an den Gegner gesendet.",
|
||||
"success",
|
||||
);
|
||||
|
||||
if (typeof window.updateMessagesNavState === "function") {
|
||||
window.updateMessagesNavState();
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
@ -345,19 +717,39 @@
|
||||
|
||||
if (resultScore) resultScore.textContent = score;
|
||||
renderWordComparison(currentGameText, userInput);
|
||||
renderChallengeResult(score);
|
||||
|
||||
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: MEMORIZE_TIME_SECONDS,
|
||||
time: inputDurationSeconds,
|
||||
text: currentGameText,
|
||||
userWrittenText: userInput,
|
||||
};
|
||||
|
||||
console.log("Score bereit zum Senden:", scoreData);
|
||||
|
||||
try {
|
||||
await saveScore(scoreData);
|
||||
if (isChallengeFirstRound()) {
|
||||
showScoreSaveFeedback("Resultat wird an den Herausforderer gesendet...", "info");
|
||||
await finishFirstChallengeRound(scoreData);
|
||||
} else if (hasActiveChallenge()) {
|
||||
showScoreSaveFeedback("Challenge-Resultat wird gesendet...", "info");
|
||||
let completed = false;
|
||||
try {
|
||||
completed = await completeChallenge(scoreData);
|
||||
} catch (error) {
|
||||
console.warn("Challenge-Abschluss fehlgeschlagen, nutze Nachrichten-Fallback.", error);
|
||||
}
|
||||
if (!completed) {
|
||||
showScoreSaveFeedback("Backend-Abschluss nicht moeglich. Ergebnis wird ueber Nachrichten gesendet...", "info");
|
||||
await finishFinalChallengeWithMessages(scoreData);
|
||||
}
|
||||
} else {
|
||||
await saveScore(scoreData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Fehler beim Speichern des Scores:", error);
|
||||
showScoreSaveFeedback(
|
||||
@ -373,7 +765,9 @@
|
||||
}
|
||||
|
||||
window.initPlayPage = function initPlayPage() {
|
||||
// Die Spielseite wird dynamisch geladen; daher werden DOM-Elemente und Events hier initialisiert.
|
||||
clearInterval(timerInterval);
|
||||
activeChallenge = readActiveChallenge();
|
||||
|
||||
// Die Navigation laedt play.html per fetch; deshalb werden die Elemente erst hier gesucht.
|
||||
phaseStart = document.getElementById("phaseStart");
|
||||
@ -395,6 +789,18 @@
|
||||
const btnRestart = document.getElementById("btnRestart");
|
||||
const btnLeaderboard = document.getElementById("btnLeaderboard");
|
||||
|
||||
const challengeHint = document.getElementById("challengeHint");
|
||||
if (challengeHint && activeChallenge) {
|
||||
const opponentScore = activeChallenge.opponentScore !== null && activeChallenge.opponentScore !== undefined
|
||||
? " Score: " + activeChallenge.opponentScore + "."
|
||||
: "";
|
||||
const hintPrefix = activeChallenge.role === "opponent"
|
||||
? "Du spielst zuerst gegen "
|
||||
: "Finale Runde gegen ";
|
||||
challengeHint.textContent = hintPrefix + activeChallenge.opponent + "." + opponentScore;
|
||||
challengeHint.classList.remove("d-none");
|
||||
}
|
||||
|
||||
if (btnStart) btnStart.addEventListener("click", startGame);
|
||||
if (btnSubmitScore) btnSubmitScore.addEventListener("click", submitScore);
|
||||
if (btnRestart)
|
||||
@ -426,6 +832,11 @@
|
||||
userTextInput.addEventListener("paste", (e) => e.preventDefault());
|
||||
userTextInput.addEventListener("copy", (e) => e.preventDefault());
|
||||
userTextInput.addEventListener("cut", (e) => e.preventDefault());
|
||||
userTextInput.addEventListener("input", () => {
|
||||
if (!inputStartMs) {
|
||||
inputStartMs = Date.now();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (targetTextDisplay) {
|
||||
|
||||
151
js/scores.js
Normal file
151
js/scores.js
Normal file
@ -0,0 +1,151 @@
|
||||
(function () {
|
||||
function formatTime(seconds) {
|
||||
if (typeof seconds !== "number" || Number.isNaN(seconds)) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = Math.floor(seconds % 60);
|
||||
return `${minutes}:${String(remainingSeconds).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function normalizeUsername(username) {
|
||||
return String(username ?? "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
function getLoggedInAuth() {
|
||||
if (!window.AppAuth || typeof window.AppAuth.getAuth !== "function") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const auth = window.AppAuth.getAuth();
|
||||
if (!auth || !auth.username || !auth.password) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return auth;
|
||||
}
|
||||
|
||||
function getScoreService() {
|
||||
if (!window.config || !window.ScoreService) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new window.ScoreService(window.config);
|
||||
}
|
||||
|
||||
function getDisplayedRank(entry, index) {
|
||||
const place = Number(entry?.place);
|
||||
if (!Number.isNaN(place) && place > 0) {
|
||||
return place;
|
||||
}
|
||||
|
||||
return index + 1;
|
||||
}
|
||||
|
||||
function setFeedback(message, type) {
|
||||
const feedback = document.getElementById("scores-feedback");
|
||||
if (!feedback) {
|
||||
return;
|
||||
}
|
||||
|
||||
feedback.className = "alert alert-" + type + " mb-4";
|
||||
feedback.textContent = message;
|
||||
feedback.classList.remove("d-none");
|
||||
}
|
||||
|
||||
function renderScores(entries) {
|
||||
const tableBody = document.getElementById("scores-body");
|
||||
if (!tableBody) {
|
||||
return;
|
||||
}
|
||||
|
||||
tableBody.innerHTML = "";
|
||||
|
||||
if (!Array.isArray(entries) || entries.length === 0) {
|
||||
tableBody.innerHTML = '<tr><td colspan="4" class="text-center text-muted py-4">Keine eigenen Scores gefunden.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
const sortedEntries = entries.slice().sort((a, b) => {
|
||||
const placeA = Number(a?.place);
|
||||
const placeB = Number(b?.place);
|
||||
if (!Number.isNaN(placeA) && !Number.isNaN(placeB) && placeA !== placeB) {
|
||||
return placeA - placeB;
|
||||
}
|
||||
|
||||
const scoreA = Number(a?.score ?? 0);
|
||||
const scoreB = Number(b?.score ?? 0);
|
||||
if (scoreB !== scoreA) {
|
||||
return scoreB - scoreA;
|
||||
}
|
||||
|
||||
const timeA = Number(a?.time ?? Number.MAX_SAFE_INTEGER);
|
||||
const timeB = Number(b?.time ?? Number.MAX_SAFE_INTEGER);
|
||||
return timeA - timeB;
|
||||
});
|
||||
|
||||
sortedEntries.forEach((entry, index) => {
|
||||
const row = document.createElement("tr");
|
||||
row.innerHTML = `
|
||||
<td>${getDisplayedRank(entry, index)}</td>
|
||||
<td>${entry.username ?? "-"}</td>
|
||||
<td>${formatTime(entry.time)} min</td>
|
||||
<td>${entry.score ?? "-"}</td>
|
||||
`;
|
||||
tableBody.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadMyScores() {
|
||||
const auth = getLoggedInAuth();
|
||||
if (!auth || !auth.username) {
|
||||
setFeedback("Bitte logge dich ein, um deine Scores zu sehen.", "warning");
|
||||
renderScores([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const scoreService = getScoreService();
|
||||
if (!scoreService) {
|
||||
setFeedback("Score-Service konnte nicht geladen werden.", "danger");
|
||||
renderScores([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await scoreService.getScoreByName(auth.username);
|
||||
|
||||
if (!result.ok || !Array.isArray(result.body)) {
|
||||
setFeedback("Deine Scores konnten nicht geladen werden.", "danger");
|
||||
renderScores([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const ownScores = result.body.filter((entry) => {
|
||||
if (!entry || !entry.username) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return normalizeUsername(entry.username) === normalizeUsername(auth.username);
|
||||
});
|
||||
|
||||
if (ownScores.length === 0) {
|
||||
setFeedback("Für deinen Account wurden noch keine Scores gefunden.", "info");
|
||||
}
|
||||
|
||||
renderScores(ownScores);
|
||||
} catch (error) {
|
||||
console.error("Fehler beim Laden der eigenen Scores:", error);
|
||||
setFeedback("Deine Scores konnten nicht geladen werden.", "danger");
|
||||
renderScores([]);
|
||||
}
|
||||
}
|
||||
|
||||
window.initScoresPage = function initScoresPage() {
|
||||
loadMyScores().catch((error) => {
|
||||
console.error("Fehler beim Initialisieren der Scores-Seite:", error);
|
||||
setFeedback("Deine Scores konnten nicht geladen werden.", "danger");
|
||||
renderScores([]);
|
||||
});
|
||||
};
|
||||
})();
|
||||
@ -1,7 +1,8 @@
|
||||
<div class="card">
|
||||
<h4 class="card-title mb-3">Willkommen beim Lorem Ipsum Game</h2>
|
||||
<p class="card-text text-uted fs-6 mb-4">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!</p>
|
||||
<p class="card-text fs-6"> Wähle eine Option aus der Navigation, um zu starten.</p>
|
||||
<img id="logo_img" src="image/Logo_loremIpsum.png" alt="Lorem Ipsum Game"class="img-fluid mt-3 d-block mx-auto">
|
||||
<div class="card mb-4 text-start">
|
||||
<div class="mb-4">
|
||||
<h2 class="fw-bold mb-1">Willkommen beim Lorem Ipsum Game</h2>
|
||||
<p class="text-muted mb-0 fs-6">Teste deine Fähigkeiten im Umgang mit Lorem Ipsum Texten! Je schneller und genauer du bist, desto höher ist dein Score.</p>
|
||||
</div>
|
||||
<p class="card-text fs-6">Wähle eine Option aus der Navigation, um zu starten. Viel Spaß beim Spielen!</p>
|
||||
<img id="logo_img" src="image/Logo_loremIpsum.png" alt="Lorem Ipsum Game" class="img-fluid mt-3 d-block mx-auto">
|
||||
</div>
|
||||
@ -1,7 +1,10 @@
|
||||
<!-- Hauptbereich der Rangliste -->
|
||||
<div class="card">
|
||||
<h2 class="mb-3">Leaderboard</h2>
|
||||
|
||||
<div class="card mb-4 text-start">
|
||||
<div class="mb-4">
|
||||
<h2 class="fw-bold mb-1">Leaderboard</h2>
|
||||
<p class="text-muted mb-0 fs-6">Hier siehst du ein Leaderboard mit den 10 besten Usern.</p>
|
||||
</div>
|
||||
|
||||
<!-- Tabellarische Darstellung der besten Eintraege -->
|
||||
<table class="leaderboard-table">
|
||||
<thead>
|
||||
|
||||
@ -1,13 +1,16 @@
|
||||
<!-- Login Seite -->
|
||||
<div class="card">
|
||||
<h2 class="mb-3">Account</h2>
|
||||
<div class="card mb-4 text-start">
|
||||
<div class="mb-4">
|
||||
<h2 class="fw-bold mb-1">Account</h2>
|
||||
<p class="text-muted mb-0 fs-6">Verwalte deine Sitzung und Account-Details.</p>
|
||||
</div>
|
||||
|
||||
<div id="auth-feedback" class="alert d-none" role="status" aria-live="polite"></div>
|
||||
|
||||
<!-- Logout und Account löschen -->
|
||||
<div class="card bg-light mb-3" id="current-session-box">
|
||||
<h3 class="h5 mb-2">Aktuelle Sitzung</h3>
|
||||
<p class="mb-2" id="current-session-text">Nicht eingeloggt.</p>
|
||||
<h3 class="mb-2">Aktuelle Sitzung</h3>
|
||||
<p class="h5 mb-2" id="current-session-text">Nicht eingeloggt.</p>
|
||||
<div class="d-flex gap-2">
|
||||
<button id="logout-button" type="button" class="btn btn-outline-secondary btn-sm" disabled>Logout</button>
|
||||
<button id="delete-account-button" type="button" class="btn btn-danger btn-sm" disabled>Account löschen</button>
|
||||
@ -18,13 +21,13 @@
|
||||
<div class="row g-3" id="auth-forms-row">
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card h-100">
|
||||
<h3 class="h5 mb-3">Login</h3>
|
||||
<h3 class="mb-3">Login</h3>
|
||||
<form id="login-form">
|
||||
<div class="mb-3">
|
||||
<div class="h5 mb-3">
|
||||
<label for="login-username" class="form-label">Username</label>
|
||||
<input id="login-username" class="form-control" type="text" required maxlength="40" autocomplete="username" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="h5 mb-3">
|
||||
<label for="login-password" class="form-label">Passwort</label>
|
||||
<input id="login-password" class="form-control" type="password" required maxlength="100" autocomplete="current-password" />
|
||||
</div>
|
||||
@ -35,15 +38,15 @@
|
||||
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card h-100">
|
||||
<h3 class="h5 mb-3">Neuen Account erstellen</h3>
|
||||
<h3 class="mb-3">Neuen Account erstellen</h3>
|
||||
<form id="register-form">
|
||||
<div class="mb-3">
|
||||
<div class="h5 mb-3">
|
||||
<label for="register-username" class="form-label">Gewünschter Username</label>
|
||||
<input id="register-username" class="form-control" type="text" required maxlength="40" autocomplete="username" />
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success">Account anlegen</button>
|
||||
</form>
|
||||
<p class="small mt-3 mb-0 text-muted">Hinweis: Das Passwort wird vom Backend erstellt und bei Erfolg angezeigt.</p>
|
||||
<p class="text-muted mb-0 fs-6">Hinweis: Das Passwort wird vom Backend erstellt und bei Erfolg angezeigt.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,6 +1,49 @@
|
||||
<div class="card">
|
||||
<h2 class="mb-3">Nachrichten</h2>
|
||||
<div class="card messages-card mb-4 text-start">
|
||||
<div class="messages-header mb-4">
|
||||
<div>
|
||||
<h2 class="fw-bold mb-1">Nachrichten</h2>
|
||||
<p class="text-muted mb-0 fs-6">Fordere andere User heraus und lies eingehende Antworten.</p>
|
||||
</div>
|
||||
<button class="btn btn-sm" id="refresh-messages-button" type="button">Aktualisieren</button>
|
||||
</div>
|
||||
|
||||
<p class="mt-2">Hier kannst du deine Nachrichten ansehen und verwalten.</p>
|
||||
<!-- Hier könnte eine Nachrichtenliste oder ähnliches eingebunden werden -->
|
||||
</div>
|
||||
<div id="messages-login-placeholder" class="alert alert-warning mt-3 d-none">
|
||||
Melde dich zuerst an, bevor du eine Nachricht verschicken kannst.
|
||||
</div>
|
||||
|
||||
<div id="messages-content" class="d-none">
|
||||
<div id="messages-feedback" class="alert d-none mt-3 mb-0" role="alert"></div>
|
||||
|
||||
<div class="row g-4 mt-2">
|
||||
<div class="col-lg-4">
|
||||
<section class="messages-panel">
|
||||
<h3>User</h3>
|
||||
<div id="messages-user-list" class="messages-user-list"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-8">
|
||||
<section class="messages-panel mb-4">
|
||||
<h3>Challenge senden</h3>
|
||||
<form id="challenge-form" class="message-form">
|
||||
<label for="challenge-recipient" class="form-label">Empfaenger</label>
|
||||
<select id="challenge-recipient" class="form-select mb-3"></select>
|
||||
|
||||
<label for="challenge-text" class="form-label">Nachricht</label>
|
||||
<textarea id="challenge-text" class="form-control mb-3" rows="3">Kannst du mich schlagen? Spiel eine Runde, dann bin ich dran.</textarea>
|
||||
|
||||
<button class="btn" type="submit">Challenge senden</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="messages-panel">
|
||||
<div class="messages-inbox-title">
|
||||
<h3>Posteingang</h3>
|
||||
<button class="btn btn-sm" id="mark-read-button" type="button">Als gelesen markieren</button>
|
||||
</div>
|
||||
<div id="message-list" class="message-list"></div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,13 +1,14 @@
|
||||
<!-- Spielseite: Die vier Phasen werden per play.js ein- und ausgeblendet. -->
|
||||
<div class="game-container">
|
||||
<div class="game-container text-start">
|
||||
|
||||
<!-- Status-Badge zeigt die aktuelle Spielphase: Bereit, Lernphase, Eingabe, Abgeschlossen. -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h2 class="fw-bold mb-0">Lorem Ipsum - Challenge you brain</h2>
|
||||
<p class="text-muted mb-0">Merken Sie sich den Text so gut wie möglich.</p>
|
||||
<div class="d-flex justify-content-between align-items-md-center flex-column flex-md-row mb-4">
|
||||
<div class="mb-3 mb-md-0">
|
||||
<h2 class="fw-bold mb-1">Lorem Ipsum - Challenge your brain</h2>
|
||||
<div id="challengeHint" class="challenge-hint d-none"></div>
|
||||
<p class="text-muted mb-0 fs-6">Merken Sie sich den Text so gut wie möglich.</p>
|
||||
</div>
|
||||
<div id="gameStatus" class="badge bg-secondary fs-6 px-3 py-2">
|
||||
<div id="gameStatus" class="badge bg-secondary fs-6 px-3 py-2" style="width: fit-content;">
|
||||
Bereit
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,6 +1,26 @@
|
||||
<div class="card">
|
||||
<h2 class="mb-3">Meine Scores</h2>
|
||||
<div class="card mb-4 text-start">
|
||||
<div class="mb-4">
|
||||
<h2 class="fw-bold mb-1">Meine Scores</h2>
|
||||
<p class="text-muted mb-0 fs-6">Hier siehst du alle gespeicherten Scores deines Accounts.</p>
|
||||
</div>
|
||||
|
||||
<p class="mt-2">Hier kannst du deine bisherigen Scores einsehen.</p>
|
||||
<!-- Hier könnte eine Score-Tabelle oder ähnliches eingebunden werden -->
|
||||
<div id="scores-feedback" class="alert d-none mb-4" role="alert"></div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table leaderboard-table mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Rang</th>
|
||||
<th scope="col">User</th>
|
||||
<th scope="col">time</th>
|
||||
<th scope="col">Score</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="scores-body">
|
||||
<tr>
|
||||
<td colspan="4" class="text-center text-muted py-4">Lade Scores ...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
Loading…
x
Reference in New Issue
Block a user