This commit is contained in:
2025-09-23 14:13:24 +02:00
commit 58b5e6b074
103 changed files with 44000 additions and 0 deletions

1
.dockerignore Normal file
View File

@@ -0,0 +1 @@
.env

107
.gitignore vendored Normal file
View File

@@ -0,0 +1,107 @@
# Dependencies
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Logs
logs
*.log
server.log
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage/
# nyc test coverage
.nyc_output
# Dependency directories
jspm_packages/
# Optional npm cache directory
.npm
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# next.js build output
.next
# nuxt.js build output
.nuxt
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# IDE files
.vscode/
.idea/
*.swp
*.swo
*~
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# MCP Servers (separate repositories)
gitea-mcp/
gcp-mcp/
# SSL certificates
*.pem
*.key
*.crt
# Database files
*.db
*.sqlite
# Temporary files
tmp/
temp/
.pgpass

217
ACHIEVEMENTS.md Normal file
View File

@@ -0,0 +1,217 @@
# 🏆 Ninja Cross Parkour Achievement System
Ein umfassendes Achievement-System für das Ninja Cross Parkour im Schwimmbad.
## 📊 System-Übersicht
Das Achievement-System besteht aus:
- **32 verschiedene Achievements** in 4 Kategorien
- **Automatische tägliche Vergabe** am Ende des Tages
- **REST API Endpoints** für Frontend-Integration
- **PostgreSQL Funktionen** für effiziente Verarbeitung
## 🎯 Achievement-Kategorien
### 1. Konsistenz-basierte Achievements
- **Erste Schritte** 👶 - Erste Zeit aufgezeichnet (5 Punkte)
- **Durchhalter** 💪 - 3 Versuche an einem Tag (10 Punkte)
- **Fleißig** 🔥 - 5 Versuche an einem Tag (15 Punkte)
- **Besessen** 😤 - 10 Versuche an einem Tag (25 Punkte)
- **Regelmäßig** 📅 - 5 verschiedene Tage gespielt (20 Punkte)
- **Stammgast** ⭐ - 10 verschiedene Tage gespielt (30 Punkte)
- **Treue** 💎 - 20 verschiedene Tage gespielt (50 Punkte)
- **Veteran** 🏆 - 50 verschiedene Tage gespielt (100 Punkte)
### 2. Verbesserungs-basierte Achievements
- **Fortschritt** 📈 - Persönliche Bestzeit um 5 Sekunden verbessert (15 Punkte)
- **Durchbruch** ⚡ - Persönliche Bestzeit um 10 Sekunden verbessert (25 Punkte)
- **Transformation** 🔄 - Persönliche Bestzeit um 15 Sekunden verbessert (40 Punkte)
- **Perfektionist** ✨ - Persönliche Bestzeit um 20 Sekunden verbessert (60 Punkte)
### 3. Saisonale Achievements
- **Wochenend-Krieger** 🏁 - Am Wochenende gespielt (10 Punkte)
- **Nachmittags-Sportler** ☀️ - Zwischen 14-18 Uhr gespielt (10 Punkte)
- **Frühaufsteher** 🌅 - Vor 10 Uhr gespielt (15 Punkte)
- **Abend-Sportler** 🌙 - Nach 18 Uhr gespielt (10 Punkte)
### 4. Monatliche Achievements
- **Januar-Krieger** ❄️ bis **Dezember-Dynamo** 🎄 (je 20 Punkte)
### 5. Jahreszeiten-Achievements
- **Frühjahrs-Fighter** 🌱 - Im Frühling gespielt (30 Punkte)
- **Sommer-Sportler** ☀️ - Im Sommer gespielt (30 Punkte)
- **Herbst-Held** 🍂 - Im Herbst gespielt (30 Punkte)
- **Winter-Warrior** ❄️ - Im Winter gespielt (30 Punkte)
## 🗄️ Datenbank-Schema
### Tabelle: `achievements`
```sql
- id (uuid, PK)
- name (varchar) - Achievement-Name
- description (text) - Beschreibung
- category (varchar) - Kategorie
- condition_type (varchar) - Bedingungstyp
- condition_value (integer) - Bedingungswert
- icon (varchar) - Emoji-Icon
- points (integer) - Punkte
- is_active (boolean) - Aktiv
- created_at (timestamp)
```
### Tabelle: `player_achievements`
```sql
- id (uuid, PK)
- player_id (uuid, FK) - Verweis auf players.id
- achievement_id (uuid, FK) - Verweis auf achievements.id
- earned_at (timestamp) - Wann erreicht
- progress (integer) - Fortschritt
- is_completed (boolean) - Abgeschlossen
- created_at (timestamp)
```
## 🔧 PostgreSQL Funktionen
### `check_consistency_achievements(player_uuid)`
Überprüft alle Konsistenz-basierten Achievements für einen Spieler.
### `check_improvement_achievements(player_uuid)`
Überprüft alle Verbesserungs-basierten Achievements für einen Spieler.
### `check_seasonal_achievements(player_uuid)`
Überprüft alle saisonalen und monatlichen Achievements für einen Spieler.
### `check_all_achievements(player_uuid)`
Führt alle Achievement-Überprüfungen für einen Spieler aus.
## 🚀 API Endpoints
### GET `/api/achievements`
Alle verfügbaren Achievements abrufen.
### GET `/api/achievements/player/:playerId`
Achievements eines bestimmten Spielers abrufen.
### GET `/api/achievements/player/:playerId/stats`
Achievement-Statistiken eines Spielers abrufen.
### POST `/api/achievements/check/:playerId`
Achievements für einen Spieler manuell überprüfen.
### POST `/api/achievements/daily-check`
Tägliche Achievement-Überprüfung für alle Spieler ausführen.
### GET `/api/achievements/leaderboard?limit=10`
Bestenliste der Spieler nach Achievement-Punkten.
## 📅 Automatisierung
### Tägliches Script
```bash
# Manuell ausführen
node scripts/daily_achievements.js
# Cron-Job einrichten
node scripts/setup_cron.js setup
# Cron-Job Status prüfen
node scripts/setup_cron.js status
# Cron-Job entfernen
node scripts/setup_cron.js remove
```
### Cron-Schedule
- **Zeit**: Täglich um 23:59 Uhr
- **Log**: `/var/log/ninjaserver_achievements.log`
## 🎮 Frontend-Integration
### Beispiel: Achievement-Liste laden
```javascript
fetch('/api/achievements/player/PLAYER_ID')
.then(response => response.json())
.then(data => {
data.data.forEach(achievement => {
console.log(`${achievement.icon} ${achievement.name}: ${achievement.is_completed ? '✅' : '❌'}`);
});
});
```
### Beispiel: Statistiken anzeigen
```javascript
fetch('/api/achievements/player/PLAYER_ID/stats')
.then(response => response.json())
.then(data => {
console.log(`Punkte: ${data.data.total_points}`);
console.log(`Abgeschlossen: ${data.data.completed_achievements}/${data.data.total_achievements}`);
});
```
## 🔍 Monitoring
### Logs überwachen
```bash
# Live-Logs anzeigen
tail -f /var/log/ninjaserver_achievements.log
# Letzte Ausführung prüfen
grep "Daily achievement check completed" /var/log/ninjaserver_achievements.log | tail -1
```
### Datenbank-Status prüfen
```sql
-- Achievement-Statistiken
SELECT
COUNT(*) as total_achievements,
COUNT(CASE WHEN is_active = true THEN 1 END) as active_achievements
FROM achievements;
-- Spieler-Statistiken
SELECT
COUNT(DISTINCT player_id) as players_with_achievements,
COUNT(*) as total_earned_achievements
FROM player_achievements
WHERE is_completed = true;
```
## 🛠️ Wartung
### Neue Achievements hinzufügen
1. Achievement in `achievements` Tabelle einfügen
2. Logik in entsprechenden PostgreSQL Funktionen erweitern
3. API Endpoints testen
### Achievement deaktivieren
```sql
UPDATE achievements SET is_active = false WHERE name = 'Achievement-Name';
```
### Daten zurücksetzen
```sql
-- Alle Spieler-Achievements löschen
DELETE FROM player_achievements;
-- Achievement-Statistiken zurücksetzen
UPDATE achievements SET created_at = NOW();
```
## 📈 Performance
- **Indizierung**: Automatische Indizes auf `player_id` und `achievement_id`
- **Batch-Processing**: Effiziente Verarbeitung aller Spieler
- **Caching**: Achievements werden nur bei Änderungen neu berechnet
- **Timezone**: Korrekte Zeitzone-Behandlung (Europe/Berlin)
## 🔒 Sicherheit
- **API-Schutz**: Alle Endpoints über bestehende Authentifizierung
- **SQL-Injection**: Parametrisierte Queries
- **Datenvalidierung**: Eingabe-Validierung in allen Funktionen
- **Fehlerbehandlung**: Umfassende Error-Handling
---
**Erstellt am**: $(date)
**Version**: 1.0.0
**Autor**: Ninja Cross Parkour System

779
API.md Normal file
View File

@@ -0,0 +1,779 @@
# 🏃‍♂️ Ninja Cross Parkour API Dokumentation
**Version:** 1.0.0
**Base URL:** `https://ninja.reptilfpv.de/api`
**Beschreibung:** API für das Ninja Cross Parkour System im Schwimmbad
## 📋 Inhaltsverzeichnis
- [🔐 Authentifizierung](#-authentifizierung)
- [🌐 Public API](#-public-api)
- [🔒 Private API](#-private-api)
- [🖥️ Web API](#-web-api)
- [👑 Admin API](#-admin-api)
- [🏆 Achievements API](#-achievements-api)
- [📊 Datenmodelle](#-datenmodelle)
- [❌ Fehlerbehandlung](#-fehlerbehandlung)
---
## 🔐 Authentifizierung
### API-Key Authentifizierung
Für private Endpoints wird ein API-Key im Authorization Header benötigt:
```http
Authorization: Bearer YOUR_API_KEY_HERE
```
### Session-basierte Authentifizierung
Für Web-Endpoints wird eine Session-basierte Authentifizierung verwendet.
### Admin-Authentifizierung
Für Admin-Endpoints wird eine erweiterte Authentifizierung mit Admin-Rechten benötigt.
---
## 🌐 Public API
Öffentliche Endpoints ohne Authentifizierung.
### 🔑 Authentifizierung
#### Login
```http
POST /api/v1/public/login
Content-Type: application/json
{
"username": "admin",
"password": "admin123"
}
```
**Response:**
```json
{
"success": true,
"message": "Login erfolgreich",
"user": {
"id": 1,
"username": "admin",
"is_active": true
}
}
```
#### Logout
```http
POST /api/v1/public/logout
```
**Response:**
```json
{
"success": true,
"message": "Logout erfolgreich"
}
```
### 👥 Spieler Management
#### Spieler erstellen
```http
POST /api/v1/public/players
Content-Type: application/json
{
"firstname": "Max",
"lastname": "Mustermann",
"birthdate": "1990-01-01",
"rfiduid": "AA:BB:CC:DD"
}
```
#### Spieler verknüpfen
```http
POST /api/v1/public/link-player
Content-Type: application/json
{
"rfiduid": "AA:BB:CC:DD",
"supabase_user_id": "uuid-here"
}
```
#### Spieler per RFID verknüpfen
```http
POST /api/v1/public/link-by-rfid
Content-Type: application/json
{
"rfiduid": "AA:BB:CC:DD",
"supabase_user_id": "uuid-here"
}
```
### 📍 Standorte
#### Alle Standorte abrufen
```http
GET /api/v1/public/locations
```
**Response:**
```json
{
"success": true,
"data": [
{
"id": "uuid",
"name": "Standort 1",
"latitude": 48.1351,
"longitude": 11.5820,
"time_threshold": {
"seconds": 120
},
"created_at": "2024-01-01T00:00:00Z"
}
]
}
```
### ⏱️ Zeiten
#### Alle Zeiten abrufen
```http
GET /api/v1/public/times
```
#### Zeiten mit Details abrufen
```http
GET /api/v1/public/times-with-details
```
#### Beste Zeiten abrufen
```http
GET /api/v1/public/best-times
```
#### Benutzer-Zeiten abrufen
```http
GET /api/v1/public/user-times/{supabase_user_id}
```
#### Benutzer-Spieler abrufen
```http
GET /api/v1/public/user-player/{supabase_user_id}
```
### 📊 Statistiken
#### Seitenaufruf verfolgen
```http
POST /api/v1/public/track-page-view
Content-Type: application/json
{
"page": "/dashboard",
"user_agent": "Mozilla/5.0...",
"ip_address": "192.168.1.1"
}
```
### 🔔 Push Notifications
#### Push-Benachrichtigung abonnieren
```http
POST /api/v1/public/subscribe
Content-Type: application/json
{
"endpoint": "https://fcm.googleapis.com/fcm/send/...",
"keys": {
"p256dh": "key-here",
"auth": "auth-key-here"
}
}
```
#### Push-Benachrichtigung testen
```http
POST /api/v1/public/test-push
Content-Type: application/json
{
"title": "Test",
"body": "Test-Nachricht",
"icon": "/icon.png"
}
```
#### Push-Status abrufen
```http
GET /api/v1/public/push-status
```
---
## 🔒 Private API
Private Endpoints mit API-Key Authentifizierung.
### 🎫 Token Management
#### Token speichern
```http
POST /api/v1/private/save-token
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json
{
"token": "GENERATED_TOKEN",
"description": "Beschreibung",
"standorte": "München, Berlin"
}
```
#### Alle Token abrufen
```http
GET /api/v1/private/tokens
Authorization: Bearer YOUR_API_KEY
```
#### Token validieren
```http
POST /api/v1/private/validate-token
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json
{
"token": "TOKEN_TO_VALIDATE"
}
```
### 📍 Standort Management
#### Standort erstellen
```http
POST /api/v1/private/create-location
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json
{
"name": "München",
"latitude": 48.1351,
"longitude": 11.5820
}
```
#### Alle Standorte abrufen
```http
GET /api/v1/private/locations
Authorization: Bearer YOUR_API_KEY
```
#### Standort-Schwelle aktualisieren
```http
PUT /api/v1/private/locations/{id}/threshold
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json
{
"threshold_seconds": 120
}
```
#### Standorte abrufen (Alternative)
```http
GET /api/v1/private/get-locations
Authorization: Bearer YOUR_API_KEY
```
### 👥 Spieler Management
#### Spieler erstellen
```http
POST /api/v1/private/create-player
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json
{
"firstname": "Max",
"lastname": "Mustermann",
"birthdate": "1990-01-01",
"rfiduid": "AA:BB:CC:DD"
}
```
#### Benutzer suchen
```http
POST /api/v1/private/users/find
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json
{
"search_term": "Max"
}
```
### ⏱️ Zeit Management
#### Zeit erstellen
```http
POST /api/v1/private/create-time
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json
{
"player_id": "RFIDUID",
"location_id": "Name",
"recorded_time": "01:23.456"
}
```
---
## 🖥️ Web API
Web-API-Endpoints für das Frontend.
### 🔑 API-Key generieren
```http
POST /api/v1/web/generate-api-key
Content-Type: application/json
{
"description": "Mein API Key",
"standorte": "München, Berlin"
}
```
### 📍 Standort erstellen
```http
POST /api/v1/web/create-location
Content-Type: application/json
{
"name": "München",
"latitude": 48.1351,
"longitude": 11.5820
}
```
### 🎫 Token speichern
```http
POST /api/v1/web/save-token
Content-Type: application/json
{
"token": "GENERATED_TOKEN",
"description": "Beschreibung",
"standorte": "München, Berlin"
}
```
### 🔍 Session prüfen
```http
GET /api/v1/web/check-session
```
---
## 👑 Admin API
Admin-API-Endpoints für Verwaltung.
### 📊 Statistiken
#### Admin-Statistiken
```http
GET /api/v1/admin/stats
Authorization: Bearer ADMIN_TOKEN
```
#### Seiten-Statistiken
```http
GET /api/v1/admin/page-stats
Authorization: Bearer ADMIN_TOKEN
```
### 👥 Spieler Verwaltung
#### Alle Spieler abrufen
```http
GET /api/v1/admin/players
Authorization: Bearer ADMIN_TOKEN
```
#### Spieler erstellen
```http
POST /api/v1/admin/players
Authorization: Bearer ADMIN_TOKEN
Content-Type: application/json
{
"firstname": "Max",
"lastname": "Mustermann",
"birthdate": "1990-01-01",
"rfiduid": "AA:BB:CC:DD"
}
```
#### Spieler aktualisieren
```http
PUT /api/v1/admin/players/{id}
Authorization: Bearer ADMIN_TOKEN
Content-Type: application/json
{
"firstname": "Max",
"lastname": "Mustermann",
"birthdate": "1990-01-01",
"rfiduid": "AA:BB:CC:DD"
}
```
#### Spieler löschen
```http
DELETE /api/v1/admin/players/{id}
Authorization: Bearer ADMIN_TOKEN
```
### 🏃‍♂️ Läufe Verwaltung
#### Alle Läufe abrufen
```http
GET /api/v1/admin/runs
Authorization: Bearer ADMIN_TOKEN
```
#### Lauf abrufen
```http
GET /api/v1/admin/runs/{id}
Authorization: Bearer ADMIN_TOKEN
```
#### Lauf erstellen
```http
POST /api/v1/admin/runs
Authorization: Bearer ADMIN_TOKEN
Content-Type: application/json
{
"player_id": "uuid",
"location_id": "uuid",
"recorded_time": "01:23.456"
}
```
#### Lauf aktualisieren
```http
PUT /api/v1/admin/runs/{id}
Authorization: Bearer ADMIN_TOKEN
Content-Type: application/json
{
"recorded_time": "01:20.000"
}
```
#### Lauf löschen
```http
DELETE /api/v1/admin/runs/{id}
Authorization: Bearer ADMIN_TOKEN
```
### 📍 Standort Verwaltung
#### Alle Standorte abrufen
```http
GET /api/v1/admin/locations
Authorization: Bearer ADMIN_TOKEN
```
#### Standort erstellen
```http
POST /api/v1/admin/locations
Authorization: Bearer ADMIN_TOKEN
Content-Type: application/json
{
"name": "München",
"latitude": 48.1351,
"longitude": 11.5820,
"time_threshold": 120
}
```
#### Standort aktualisieren
```http
PUT /api/v1/admin/locations/{id}
Authorization: Bearer ADMIN_TOKEN
Content-Type: application/json
{
"name": "München Updated",
"latitude": 48.1351,
"longitude": 11.5820,
"time_threshold": 120
}
```
#### Standort löschen
```http
DELETE /api/v1/admin/locations/{id}
Authorization: Bearer ADMIN_TOKEN
```
### 👤 Admin-Benutzer Verwaltung
#### Alle Admin-Benutzer abrufen
```http
GET /api/v1/admin/adminusers
Authorization: Bearer ADMIN_TOKEN
```
#### Admin-Benutzer erstellen
```http
POST /api/v1/admin/adminusers
Authorization: Bearer ADMIN_TOKEN
Content-Type: application/json
{
"username": "newadmin",
"password": "securepassword"
}
```
#### Admin-Benutzer aktualisieren
```http
PUT /api/v1/admin/adminusers/{id}
Authorization: Bearer ADMIN_TOKEN
Content-Type: application/json
{
"username": "updatedadmin",
"is_active": true
}
```
#### Admin-Benutzer löschen
```http
DELETE /api/v1/admin/adminusers/{id}
Authorization: Bearer ADMIN_TOKEN
```
---
## 🏆 Achievements API
Achievement-System Endpoints.
### 🏆 Achievements abrufen
```http
GET /api/achievements
```
**Response:**
```json
{
"success": true,
"data": [
{
"id": "uuid",
"name": "Erster Lauf",
"description": "Absolviere deinen ersten Lauf",
"category": "consistency",
"condition_type": "runs_count",
"condition_value": 1,
"icon": "🏃‍♂️",
"points": 10,
"is_active": true
}
]
}
```
### 👤 Spieler-Achievements
```http
GET /api/achievements/player/{playerId}
```
### 📊 Spieler-Statistiken
```http
GET /api/achievements/player/{playerId}/stats
```
### ✅ Achievement prüfen
```http
POST /api/achievements/check/{playerId}
Content-Type: application/json
{
"achievement_id": "uuid"
}
```
### 📅 Tägliche Prüfung
```http
POST /api/achievements/daily-check
```
### 🏃‍♂️ Beste Zeit prüfen
```http
POST /api/achievements/best-time-check
```
### 🏅 Leaderboard
```http
GET /api/achievements/leaderboard
```
---
## 📊 Datenmodelle
### Player
```json
{
"id": "string (uuid)",
"firstname": "string",
"lastname": "string",
"birthdate": "string (date)",
"rfiduid": "string (XX:XX:XX:XX)",
"supabase_user_id": "string (uuid)",
"created_at": "string (date-time)"
}
```
### Time
```json
{
"id": "string (uuid)",
"player_id": "string (uuid)",
"location_id": "string (uuid)",
"recorded_time": {
"seconds": "number",
"minutes": "number",
"milliseconds": "number"
},
"created_at": "string (date-time)"
}
```
### Location
```json
{
"id": "string (uuid)",
"name": "string",
"latitude": "number (float)",
"longitude": "number (float)",
"time_threshold": {
"seconds": "number",
"minutes": "number"
},
"created_at": "string (date-time)"
}
```
### Achievement
```json
{
"id": "string (uuid)",
"name": "string",
"description": "string",
"category": "string (consistency|improvement|seasonal|monthly)",
"condition_type": "string",
"condition_value": "integer",
"icon": "string (emoji)",
"points": "integer",
"is_active": "boolean"
}
```
### PlayerAchievement
```json
{
"id": "string (uuid)",
"player_id": "string (uuid)",
"achievement_id": "string (uuid)",
"progress": "integer",
"is_completed": "boolean",
"earned_at": "string (date-time)"
}
```
---
## ❌ Fehlerbehandlung
### Standard-Fehlerantwort
```json
{
"success": false,
"message": "Fehlermeldung",
"error": "DETAILED_ERROR_INFO"
}
```
### HTTP-Status-Codes
- **200 OK** - Erfolgreiche Anfrage
- **201 Created** - Ressource erfolgreich erstellt
- **400 Bad Request** - Ungültige Anfrage
- **401 Unauthorized** - Nicht authentifiziert
- **403 Forbidden** - Keine Berechtigung
- **404 Not Found** - Ressource nicht gefunden
- **500 Internal Server Error** - Serverfehler
### Häufige Fehlermeldungen
- `"API-Key erforderlich"` - Fehlender oder ungültiger API-Key
- `"Ungültige Anmeldedaten"` - Falsche Login-Daten
- `"Ressource nicht gefunden"` - Angeforderte Ressource existiert nicht
- `"Ungültige Daten"` - Validierungsfehler bei Eingabedaten
- `"Keine Berechtigung"` - Unzureichende Rechte für die Aktion
---
## 🔧 Entwicklung
### Lokale Entwicklung
```bash
# Server starten
npm run dev
# API-Dokumentation anzeigen
# Swagger UI verfügbar unter: http://localhost:3000/api-docs
```
### Produktionsumgebung
```bash
# Server starten
npm start
# API-Dokumentation: https://ninja.reptilfpv.de/api-docs
```
---
## 📝 Changelog
### Version 1.0.0
- Initiale API-Implementierung
- Public, Private, Web und Admin Endpoints
- Achievement-System
- Push-Notification Support
- Swagger-Dokumentation
---
## 👨‍💻 Autor
**Carsten Graf**
*Ninja Cross Parkour System*
---
**⚠️ Wichtig:** Ändern Sie die Standardpasswörter in der Produktionsumgebung!

222
README.md Normal file
View File

@@ -0,0 +1,222 @@
# 🔐 Lizenzgenerator mit PostgreSQL Integration
Ein sicherer Lizenzgenerator mit PostgreSQL-Datenbank, interaktiver Karte und API-Key Authentifizierung.
## ✨ Features
- **🔑 Sichere Lizenzgenerierung** mit HMAC-SHA256
- **🗄️ PostgreSQL Integration** für lokale Datenspeicherung
- **🗺️ Interaktive Karte** mit Leaflet.js und OpenStreetMap
- **🔍 Standortsuche** über Nominatim API
- **🔐 API-Key Authentifizierung** für alle API-Endpunkte
- **🌐 Web-Interface** mit Login-Schutz
- **📱 Responsive Design** für alle Geräte
## 🚀 Installation
### Voraussetzungen
- Node.js (v16 oder höher)
- PostgreSQL Datenbank
- npm oder yarn
### Setup
```bash
# Repository klonen
git clone <repository-url>
cd ninjaserver
# Abhängigkeiten installieren
npm install
# Umgebungsvariablen konfigurieren
cp .env.example .env
# .env-Datei mit Ihren Datenbankdaten bearbeiten
# Datenbank initialisieren
npm run init-db
# Server starten
npm start
```
## 🔐 Authentifizierung
### Web-Interface
- **Standardanmeldung**: `admin` / `admin123`
- **Benutzer erstellen**: `npm run create-user`
### API-Key Authentifizierung
Alle API-Endpunkte erfordern einen gültigen API-Key im `Authorization` Header:
```bash
Authorization: Bearer YOUR_API_KEY_HERE
```
#### API-Key generieren
```bash
curl -X POST http://localhost:3000/api/generate-api-key \
-H "Content-Type: application/json" \
-d '{"description": "Mein API Key", "standorte": "München, Berlin"}'
```
## 📡 API-Endpunkte
### Geschützte Endpunkte (API-Key erforderlich)
#### Standorte abrufen
```bash
curl -X GET http://localhost:3000/api/locations \
-H "Authorization: Bearer YOUR_API_KEY"
```
#### Neuen Standort erstellen
```bash
curl -X POST http://localhost:3000/api/create-location \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-d '{"name": "München", "lat": 48.1351, "lon": 11.5820}'
```
#### API-Tokens abrufen
```bash
curl -X GET http://localhost:3000/api/tokens \
-H "Authorization: Bearer YOUR_API_KEY"
```
#### Token validieren
```bash
curl -X POST http://localhost:3000/api/validate-token \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-d '{"token": "TOKEN_TO_VALIDATE"}'
```
#### Token speichern
```bash
curl -X POST http://localhost:3000/api/save-token \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-d '{"token": "GENERATED_TOKEN", "description": "Beschreibung", "standorte": "Standorte"}'
```
### Öffentliche Endpunkte (nur Web-Interface)
#### Login
```bash
curl -X POST http://localhost:3000/api/login \
-H "Content-Type: application/json" \
-d '{"username": "admin", "password": "admin123"}'
```
#### Logout
```bash
curl -X POST http://localhost:3000/api/logout
```
## 🗄️ Datenbankstruktur
### `adminusers` Tabelle
```sql
CREATE TABLE adminusers (
id SERIAL PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_login TIMESTAMP
);
```
### `api_tokens` Tabelle
```sql
CREATE TABLE api_tokens (
id SERIAL PRIMARY KEY,
token VARCHAR(255) UNIQUE NOT NULL,
description TEXT,
standorte TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP,
is_active BOOLEAN DEFAULT true
);
```
### `locations` Tabelle
```sql
CREATE TABLE locations (
id SERIAL PRIMARY KEY,
name VARCHAR(255) UNIQUE NOT NULL,
latitude DECIMAL(10, 8) NOT NULL,
longitude DECIMAL(11, 8) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
## 🧪 API testen
### Test-Skript verwenden
```bash
# test-api.js bearbeiten und API_KEY setzen
node test-api.js
```
### Manueller Test
```bash
# 1. API-Key generieren
curl -X POST http://localhost:3000/api/generate-api-key \
-H "Content-Type: application/json" \
-d '{"description": "Test Key"}'
# 2. API mit generiertem Key testen
curl -X GET http://localhost:3000/api/locations \
-H "Authorization: Bearer GENERATED_API_KEY"
```
## 📁 Projektstruktur
```
ninjaserver/
├── server.js # Hauptserver-Datei
├── routes/
│ └── api.js # API-Routen mit Bearer Token Auth
├── scripts/
│ ├── init-db.js # Datenbankinitialisierung
│ └── create-user.js # Benutzer-Erstellung
├── public/
│ ├── index.html # Hauptanwendung
│ └── login.html # Login-Seite
├── test-api.js # API-Test-Skript
└── package.json
```
## 🚀 Deployment
### Lokale Entwicklung
```bash
npm run dev # Mit Nodemon für automatisches Neuladen
```
### Produktion
```bash
npm start # Produktionsserver
```
## 🔒 Sicherheit
- **API-Key Authentifizierung** für alle API-Endpunkte
- **Session-basierte Authentifizierung** für Web-Interface
- **Passwort-Hashing** mit bcrypt
- **HTTPS empfohlen** für Produktionsumgebung
- **Regelmäßige API-Key Rotation** empfohlen
## 📝 Lizenz
Proprietär - Alle Rechte vorbehalten
## 👨‍💻 Autor
Carsten Graf
---
**⚠️ Wichtig**: Ändern Sie die Standardpasswörter in der Produktionsumgebung!

51
apache-ssl-config.conf Normal file
View File

@@ -0,0 +1,51 @@
# Apache SSL VirtualHost für NinjaCross
# Datei: /etc/apache2/sites-available/ninjaserver-ssl.conf
<VirtualHost *:443>
ServerName ninja.reptilfpv.de
DocumentRoot /var/www/html
# SSL Configuration
SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/ninja.reptilfpv.de/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/ninja.reptilfpv.de/privkey.pem
# Security Headers für Kamera-Zugriff
Header always set Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
Header always set X-Content-Type-Options nosniff
Header always set X-Frame-Options DENY
Header always set Referrer-Policy strict-origin-when-cross-origin
# Wichtig für Kamera-Zugriff
Header always set Permissions-Policy "camera=self, microphone=()"
# WebSocket Support - MUSS vor dem generellen Proxy stehen
RewriteEngine On
RewriteCond %{HTTP:Upgrade} websocket [NC]
RewriteCond %{HTTP:Connection} upgrade [NC]
RewriteRule ^/?(.*) "ws://localhost:3000/$1" [P,L]
# Socket.IO spezifische WebSocket-Routen
ProxyPass /socket.io/ ws://localhost:3000/socket.io/
ProxyPassReverse /socket.io/ ws://localhost:3000/socket.io/
# Standard HTTP/HTTPS Reverse Proxy zu Node.js
ProxyPreserveHost On
ProxyPass /socket.io/ !
ProxyPass / http://localhost:3000/
ProxyPassReverse / http://localhost:3000/
# WebSocket Proxy-Einstellungen
ProxyTimeout 3600
ProxyBadHeader Ignore
# Logging
ErrorLog ${APACHE_LOG_DIR}/ninjaserver_ssl_error.log
CustomLog ${APACHE_LOG_DIR}/ninjaserver_ssl_access.log combined
</VirtualHost>
# HTTP zu HTTPS Redirect
<VirtualHost *:80>
ServerName ninja.reptilfpv.de
Redirect permanent / https://ninja.reptilfpv.de/
</VirtualHost>

335
config/blacklist-db.js Normal file
View File

@@ -0,0 +1,335 @@
/**
* Datenbankbasierte Blacklist für unerwünschte Namen
* Lädt und verwaltet Blacklist-Einträge aus der Datenbank
*/
const { checkWithCategoryThreshold, checkWithTrigramIndex, TrigramIndex, THRESHOLDS } = require('./levenshtein');
// Datenbankverbindung direkt hier implementieren
const { Pool } = require('pg');
const pool = new Pool({
user: process.env.DB_USER || 'postgres',
host: process.env.DB_HOST || 'localhost',
database: process.env.DB_NAME || 'ninjaserver',
password: process.env.DB_PASSWORD || 'password',
port: process.env.DB_PORT || 5432,
});
// Trigram-Index für Performance-Optimierung
let trigramIndex = null;
let blacklistCache = null;
let lastCacheUpdate = 0;
const CACHE_TTL = 5 * 60 * 1000; // 5 Minuten
/**
* Lädt alle Blacklist-Einträge aus der Datenbank mit Caching
* @returns {Object} - Blacklist gruppiert nach Kategorien
*/
async function loadBlacklistFromDB() {
const now = Date.now();
// Verwende Cache falls verfügbar und nicht abgelaufen
if (blacklistCache && (now - lastCacheUpdate) < CACHE_TTL) {
return blacklistCache;
}
try {
const result = await pool.query(
'SELECT term, category FROM blacklist_terms ORDER BY category, term'
);
const blacklist = {
historical: [],
offensive: [],
titles: [],
brands: [],
inappropriate: [],
racial: [],
religious: [],
disability: [],
leetspeak: [],
cyberbullying: [],
drugs: [],
violence: []
};
// Erstelle neuen Trigram-Index
trigramIndex = new TrigramIndex();
result.rows.forEach(row => {
if (blacklist[row.category]) {
blacklist[row.category].push(row.term);
// Füge zum Trigram-Index hinzu
trigramIndex.addTerm(row.term, row.category);
}
});
// Cache aktualisieren
blacklistCache = blacklist;
lastCacheUpdate = now;
return blacklist;
} catch (error) {
console.error('Error loading blacklist from database:', error);
// Fallback zur statischen Blacklist
return getStaticBlacklist();
}
}
/**
* Statische Blacklist als Fallback
*/
function getStaticBlacklist() {
return {
historical: [
'adolf', 'hitler', 'adolf hitler', 'adolfhittler',
'mussolini', 'benito', 'benito mussolini',
'stalin', 'joseph stalin', 'mao', 'mao zedong'
],
offensive: [
'satan', 'luzifer', 'teufel', 'devil',
'hurensohn', 'wichser', 'fotze', 'arschloch',
'idiot', 'dummkopf', 'trottel', 'schwachsinnig',
'nazi', 'faschist', 'rassist'
],
titles: [
'lord', 'lady', 'sir', 'dame',
'prinz', 'prinzessin', 'prince', 'princess',
'könig', 'königin', 'king', 'queen',
'doktor', 'professor', 'dr', 'prof'
],
brands: [
'mcdonald', 'coca cola', 'cocacola', 'pepsi',
'nike', 'adidas', 'puma', 'reebok',
'bmw', 'mercedes', 'audi', 'volkswagen'
],
inappropriate: [
'sex', 'porn', 'porno', 'fuck', 'shit',
'bitch', 'whore', 'prostitute',
'drug', 'cocaine', 'heroin', 'marijuana'
],
racial: [],
religious: [],
disability: [],
leetspeak: [],
cyberbullying: [],
drugs: [],
violence: []
};
}
/**
* Prüft ob ein Name in der Blacklist steht (exakte Übereinstimmung)
* @param {string} firstname - Vorname
* @param {string} lastname - Nachname
* @returns {Object} - {isBlocked: boolean, reason: string, category: string}
*/
async function checkNameAgainstBlacklist(firstname, lastname) {
if (!firstname || !lastname) {
return { isBlocked: false, reason: '', category: '' };
}
try {
const blacklist = await loadBlacklistFromDB();
const fullName = `${firstname.toLowerCase()} ${lastname.toLowerCase()}`;
const firstNameOnly = firstname.toLowerCase();
const lastNameOnly = lastname.toLowerCase();
// Alle Blacklist-Einträge in einem Array sammeln
const allBlacklistEntries = [];
Object.entries(blacklist).forEach(([category, entries]) => {
entries.forEach(entry => {
allBlacklistEntries.push({
term: entry,
category: category,
reason: getCategoryReason(category)
});
});
});
// 1. Exakte Übereinstimmung prüfen
for (const entry of allBlacklistEntries) {
const term = entry.term.toLowerCase();
// Vollständiger Name
if (fullName.includes(term) || term.includes(fullName)) {
return {
isBlocked: true,
reason: entry.reason,
category: entry.category,
matchedTerm: entry.term,
matchType: 'exact'
};
}
// Vorname allein
if (firstNameOnly.includes(term) || term.includes(firstNameOnly)) {
return {
isBlocked: true,
reason: entry.reason,
category: entry.category,
matchedTerm: entry.term,
matchType: 'exact'
};
}
// Nachname allein
if (lastNameOnly.includes(term) || term.includes(lastNameOnly)) {
return {
isBlocked: true,
reason: entry.reason,
category: entry.category,
matchedTerm: entry.term,
matchType: 'exact'
};
}
}
// 2. Levenshtein-Distanz prüfen (Fuzzy-Matching)
// Verwende Trigram-Index für bessere Performance bei großen Blacklists
let levenshteinResult;
if (trigramIndex && Object.values(blacklist).flat().length > 100) {
// Performance-optimierte Version für große Blacklists
levenshteinResult = checkWithTrigramIndex(firstname, lastname, blacklist, trigramIndex);
} else {
// Standard-Version für kleine Blacklists
for (const [category, entries] of Object.entries(blacklist)) {
const categoryResult = checkWithCategoryThreshold(firstname, lastname, entries, category);
if (categoryResult.hasSimilarTerms) {
levenshteinResult = categoryResult;
break; // Frühe Beendigung bei erstem Match
}
}
}
if (levenshteinResult && levenshteinResult.hasSimilarTerms) {
const bestMatch = levenshteinResult.bestMatch;
return {
isBlocked: true,
reason: `${getCategoryReason(bestMatch.category || 'unknown')} (ähnlich)`,
category: bestMatch.category || 'unknown',
matchedTerm: bestMatch.term,
matchType: 'similar',
similarity: bestMatch.distance,
levenshteinDistance: bestMatch.levenshteinDistance
};
}
return { isBlocked: false, reason: '', category: '' };
} catch (error) {
console.error('Error checking name against blacklist:', error);
return { isBlocked: false, reason: '', category: '' };
}
}
/**
* Gibt eine benutzerfreundliche Begründung für die Kategorie zurück
*/
function getCategoryReason(category) {
const reasons = {
historical: 'Historisch belasteter Name',
offensive: 'Beleidigender oder anstößiger Begriff',
titles: 'Titel oder Berufsbezeichnung',
brands: 'Markenname',
inappropriate: 'Unpassender Begriff',
racial: 'Rassistischer oder ethnisch beleidigender Begriff',
religious: 'Religiös beleidigender oder blasphemischer Begriff',
disability: 'Beleidigender Begriff bezüglich Behinderungen',
leetspeak: 'Verschleierter beleidigender Begriff',
cyberbullying: 'Cyberbullying oder Online-Belästigung',
drugs: 'Drogenbezogener Begriff',
violence: 'Gewalt- oder bedrohungsbezogener Begriff'
};
return reasons[category] || 'Unzulässiger Begriff';
}
/**
* Fügt einen neuen Begriff zur Blacklist hinzu
* @param {string} term - Der hinzuzufügende Begriff
* @param {string} category - Die Kategorie
* @param {string} createdBy - Wer hat den Begriff hinzugefügt
*/
async function addToBlacklist(term, category, createdBy = 'admin') {
try {
await pool.query(
'INSERT INTO blacklist_terms (term, category, created_by) VALUES ($1, $2, $3) ON CONFLICT (term, category) DO NOTHING',
[term.toLowerCase(), category, createdBy]
);
// Cache invalidieren
invalidateCache();
return true;
} catch (error) {
console.error('Error adding to blacklist:', error);
throw error;
}
}
/**
* Entfernt einen Begriff aus der Blacklist
* @param {string} term - Der zu entfernende Begriff
* @param {string} category - Die Kategorie
*/
async function removeFromBlacklist(term, category) {
try {
const result = await pool.query(
'DELETE FROM blacklist_terms WHERE term = $1 AND category = $2',
[term.toLowerCase(), category]
);
// Cache invalidieren
invalidateCache();
return result.rowCount > 0;
} catch (error) {
console.error('Error removing from blacklist:', error);
throw error;
}
}
/**
* Invalidiert den Blacklist-Cache
*/
function invalidateCache() {
blacklistCache = null;
trigramIndex = null;
lastCacheUpdate = 0;
}
/**
* Gibt die komplette Blacklist zurück (für Admin-Zwecke)
*/
async function getBlacklist() {
return await loadBlacklistFromDB();
}
/**
* Synchronisiert die statische Blacklist mit der Datenbank
* (Nur für Initial-Setup oder Migration)
*/
async function syncStaticBlacklist() {
const staticBlacklist = getStaticBlacklist();
for (const [category, terms] of Object.entries(staticBlacklist)) {
for (const term of terms) {
try {
await addToBlacklist(term, category, 'system');
} catch (error) {
console.error(`Error syncing term ${term} in category ${category}:`, error);
}
}
}
}
module.exports = {
checkNameAgainstBlacklist,
addToBlacklist,
removeFromBlacklist,
getBlacklist,
loadBlacklistFromDB,
syncStaticBlacklist
};

176
config/blacklist.js Normal file
View File

@@ -0,0 +1,176 @@
/**
* Blacklist für unerwünschte Namen
* Basierend auf deutschen Namensregeln und allgemeinen Richtlinien
*/
const BLACKLIST = {
// Historisch belastete Namen
historical: [
'adolf', 'hitler', 'adolf hitler', 'adolfhittler', 'adolfhittler',
'mussolini', 'benito', 'benito mussolini',
'stalin', 'joseph stalin',
'mao', 'mao zedong',
'pol pot', 'polpot',
'saddam', 'saddam hussein',
'osama', 'osama bin laden',
'kim jong', 'kim jong il', 'kim jong un'
],
// Beleidigende/anstößige Begriffe
offensive: [
'satan', 'luzifer', 'teufel', 'devil',
'hurensohn', 'wichser', 'fotze', 'arschloch',
'idiot', 'dummkopf', 'trottel', 'schwachsinnig',
'nazi', 'faschist', 'rassist',
'terrorist', 'mörder', 'killer'
],
// Titel und Berufsbezeichnungen
titles: [
'lord', 'lady', 'sir', 'dame',
'prinz', 'prinzessin', 'prince', 'princess',
'könig', 'königin', 'king', 'queen',
'kaiser', 'kaiserin', 'emperor', 'empress',
'doktor', 'professor', 'dr', 'prof',
'pastor', 'pfarrer', 'bischof', 'priester',
'richter', 'anwalt', 'notar'
],
// Markennamen (Beispiele)
brands: [
'mcdonald', 'coca cola', 'cocacola', 'pepsi',
'nike', 'adidas', 'puma', 'reebok',
'bmw', 'mercedes', 'audi', 'volkswagen',
'apple', 'microsoft', 'google', 'facebook',
'samsung', 'sony', 'panasonic'
],
// Unpassende Begriffe
inappropriate: [
'sex', 'porn', 'porno', 'fuck', 'shit',
'bitch', 'whore', 'prostitute',
'drug', 'cocaine', 'heroin', 'marijuana',
'bomb', 'explosive', 'weapon', 'gun'
]
};
/**
* Prüft ob ein Name in der Blacklist steht
* @param {string} firstname - Vorname
* @param {string} lastname - Nachname
* @returns {Object} - {isBlocked: boolean, reason: string, category: string}
*/
function checkNameAgainstBlacklist(firstname, lastname) {
if (!firstname || !lastname) {
return { isBlocked: false, reason: '', category: '' };
}
const fullName = `${firstname.toLowerCase()} ${lastname.toLowerCase()}`;
const firstNameOnly = firstname.toLowerCase();
const lastNameOnly = lastname.toLowerCase();
// Alle Blacklist-Einträge in einem Array sammeln
const allBlacklistEntries = [];
Object.entries(BLACKLIST).forEach(([category, entries]) => {
entries.forEach(entry => {
allBlacklistEntries.push({
term: entry,
category: category,
reason: getCategoryReason(category)
});
});
});
// Prüfung durchführen
for (const entry of allBlacklistEntries) {
const term = entry.term.toLowerCase();
// Vollständiger Name
if (fullName.includes(term) || term.includes(fullName)) {
return {
isBlocked: true,
reason: entry.reason,
category: entry.category,
matchedTerm: entry.term
};
}
// Vorname allein
if (firstNameOnly.includes(term) || term.includes(firstNameOnly)) {
return {
isBlocked: true,
reason: entry.reason,
category: entry.category,
matchedTerm: entry.term
};
}
// Nachname allein
if (lastNameOnly.includes(term) || term.includes(lastNameOnly)) {
return {
isBlocked: true,
reason: entry.reason,
category: entry.category,
matchedTerm: entry.term
};
}
}
return { isBlocked: false, reason: '', category: '' };
}
/**
* Gibt eine benutzerfreundliche Begründung für die Kategorie zurück
*/
function getCategoryReason(category) {
const reasons = {
historical: 'Historisch belasteter Name',
offensive: 'Beleidigender oder anstößiger Begriff',
titles: 'Titel oder Berufsbezeichnung',
brands: 'Markenname',
inappropriate: 'Unpassender Begriff'
};
return reasons[category] || 'Unzulässiger Begriff';
}
/**
* Fügt einen neuen Begriff zur Blacklist hinzu
* @param {string} term - Der hinzuzufügende Begriff
* @param {string} category - Die Kategorie
*/
function addToBlacklist(term, category) {
if (BLACKLIST[category] && !BLACKLIST[category].includes(term.toLowerCase())) {
BLACKLIST[category].push(term.toLowerCase());
}
}
/**
* Entfernt einen Begriff aus der Blacklist
* @param {string} term - Der zu entfernende Begriff
* @param {string} category - Die Kategorie
*/
function removeFromBlacklist(term, category) {
if (BLACKLIST[category]) {
const index = BLACKLIST[category].indexOf(term.toLowerCase());
if (index > -1) {
BLACKLIST[category].splice(index, 1);
}
}
}
/**
* Gibt die komplette Blacklist zurück (für Admin-Zwecke)
*/
function getBlacklist() {
return BLACKLIST;
}
module.exports = {
checkNameAgainstBlacklist,
addToBlacklist,
removeFromBlacklist,
getBlacklist,
BLACKLIST
};

336
config/levenshtein.js Normal file
View File

@@ -0,0 +1,336 @@
/**
* Levenshtein-Distanz Algorithmus für Fuzzy-Matching
* Erkennt Abwandlungen und Tippfehler von Blacklist-Begriffen
*/
/**
* Berechnet die Levenshtein-Distanz zwischen zwei Strings
* @param {string} str1 - Erster String
* @param {string} str2 - Zweiter String
* @returns {number} - Distanz (0 = identisch, höher = unterschiedlicher)
*/
function levenshteinDistance(str1, str2) {
const len1 = str1.length;
const len2 = str2.length;
// Erstelle Matrix
const matrix = Array(len2 + 1).fill(null).map(() => Array(len1 + 1).fill(null));
// Initialisiere erste Zeile und Spalte
for (let i = 0; i <= len1; i++) {
matrix[0][i] = i;
}
for (let j = 0; j <= len2; j++) {
matrix[j][0] = j;
}
// Fülle Matrix
for (let j = 1; j <= len2; j++) {
for (let i = 1; i <= len1; i++) {
const cost = str1[i - 1] === str2[j - 1] ? 0 : 1;
matrix[j][i] = Math.min(
matrix[j][i - 1] + 1, // Deletion
matrix[j - 1][i] + 1, // Insertion
matrix[j - 1][i - 1] + cost // Substitution
);
}
}
return matrix[len2][len1];
}
/**
* Berechnet die normalisierte Levenshtein-Distanz (0-1)
* @param {string} str1 - Erster String
* @param {string} str2 - Zweiter String
* @returns {number} - Normalisierte Distanz (0 = identisch, 1 = komplett unterschiedlich)
*/
function normalizedLevenshteinDistance(str1, str2) {
const distance = levenshteinDistance(str1, str2);
const maxLength = Math.max(str1.length, str2.length);
return maxLength === 0 ? 0 : distance / maxLength;
}
/**
* Prüft ob ein String ähnlich zu einem Blacklist-Begriff ist
* @param {string} input - Eingabe-String
* @param {string} blacklistTerm - Blacklist-Begriff
* @param {number} threshold - Schwellenwert (0-1, niedriger = strenger)
* @returns {boolean} - True wenn ähnlich genug
*/
function isSimilarToBlacklistTerm(input, blacklistTerm, threshold = 0.3) {
const normalizedDistance = normalizedLevenshteinDistance(input, blacklistTerm);
return normalizedDistance <= threshold;
}
/**
* Findet ähnliche Begriffe in einer Blacklist
* @param {string} input - Eingabe-String
* @param {Array} blacklistTerms - Array von Blacklist-Begriffen
* @param {number} threshold - Schwellenwert (0-1)
* @returns {Array} - Array von ähnlichen Begriffen mit Distanz
*/
function findSimilarTerms(input, blacklistTerms, threshold = 0.3) {
const similarTerms = [];
const normalizedInput = input.toLowerCase().trim();
// Performance-Optimierung: Frühe Beendigung bei sehr kurzen Strings
if (normalizedInput.length < 2) {
return similarTerms;
}
for (const term of blacklistTerms) {
const normalizedTerm = term.toLowerCase().trim();
// Performance-Optimierung: Skip bei zu großer Längendifferenz
const lengthDiff = Math.abs(normalizedInput.length - normalizedTerm.length);
const maxLengthDiff = Math.ceil(normalizedInput.length * threshold);
if (lengthDiff > maxLengthDiff) {
continue;
}
const distance = normalizedLevenshteinDistance(normalizedInput, normalizedTerm);
if (distance <= threshold) {
similarTerms.push({
term: term,
distance: distance,
levenshteinDistance: levenshteinDistance(normalizedInput, normalizedTerm)
});
}
}
// Sortiere nach Distanz (niedrigste zuerst)
return similarTerms.sort((a, b) => a.distance - b.distance);
}
/**
* Erweiterte Blacklist-Prüfung mit Levenshtein-Distanz und Teilstring-Matching
* @param {string} firstname - Vorname
* @param {string} lastname - Nachname
* @param {Array} blacklistTerms - Array von Blacklist-Begriffen
* @param {number} threshold - Schwellenwert für Ähnlichkeit (0-1)
* @returns {Object} - Prüfungsergebnis mit ähnlichen Begriffen
*/
function checkWithLevenshtein(firstname, lastname, blacklistTerms, threshold = 0.3) {
const fullName = `${firstname.toLowerCase().trim()} ${lastname.toLowerCase().trim()}`;
const firstNameOnly = firstname.toLowerCase().trim();
const lastNameOnly = lastname.toLowerCase().trim();
// Prüfe alle Varianten
const variants = [fullName, firstNameOnly, lastNameOnly];
const allSimilarTerms = [];
for (const variant of variants) {
// 1. Direkte Levenshtein-Prüfung
const similarTerms = findSimilarTerms(variant, blacklistTerms, threshold);
allSimilarTerms.push(...similarTerms);
// 2. Teilstring-Matching: Prüfe alle Wörter im Variant gegen Blacklist
const words = variant.split(/\s+/);
for (const word of words) {
if (word.length >= 2) { // Nur Wörter mit mindestens 2 Zeichen
const wordSimilarTerms = findSimilarTerms(word, blacklistTerms, threshold);
allSimilarTerms.push(...wordSimilarTerms);
}
}
// 3. Teilstring-Matching: Prüfe Blacklist-Begriffe gegen Variant
for (const blacklistTerm of blacklistTerms) {
const normalizedTerm = blacklistTerm.toLowerCase().trim();
if (normalizedTerm.length >= 2) {
// Prüfe ob Blacklist-Begriff als Teilstring im Variant vorkommt
if (variant.includes(normalizedTerm)) {
allSimilarTerms.push({
term: blacklistTerm,
distance: 0, // Exakte Teilstring-Übereinstimmung
levenshteinDistance: 0,
matchType: 'substring'
});
} else {
// Prüfe Levenshtein für Teilstrings
const words = variant.split(/\s+/);
for (const word of words) {
if (word.length >= 2) {
const distance = normalizedLevenshteinDistance(word, normalizedTerm);
if (distance <= threshold) {
allSimilarTerms.push({
term: blacklistTerm,
distance: distance,
levenshteinDistance: levenshteinDistance(word, normalizedTerm),
matchType: 'substring-similar'
});
}
}
}
}
}
}
}
// Entferne Duplikate und sortiere nach Distanz
const uniqueSimilarTerms = allSimilarTerms.reduce((acc, current) => {
const existing = acc.find(item => item.term === current.term);
if (!existing || current.distance < existing.distance) {
return acc.filter(item => item.term !== current.term).concat(current);
}
return acc;
}, []);
return {
hasSimilarTerms: uniqueSimilarTerms.length > 0,
similarTerms: uniqueSimilarTerms.sort((a, b) => a.distance - b.distance),
bestMatch: uniqueSimilarTerms.length > 0 ? uniqueSimilarTerms[0] : null
};
}
/**
* Konfigurierbare Schwellenwerte für verschiedene Kategorien
*/
const THRESHOLDS = {
historical: 0.2, // Sehr streng für historische Begriffe
offensive: 0.25, // Streng für beleidigende Begriffe
titles: 0.3, // Normal für Titel
brands: 0.35, // Etwas lockerer für Marken
inappropriate: 0.3 // Normal für unpassende Begriffe
};
/**
* Performance-optimierte Version für große Blacklists
* Verwendet Trigram-Index für bessere Performance
*/
class TrigramIndex {
constructor() {
this.index = new Map();
}
/**
* Erstellt Trigramme aus einem String
* @param {string} str - Eingabe-String
* @returns {Array} - Array von Trigrammen
*/
createTrigrams(str) {
const normalized = str.toLowerCase().trim();
const trigrams = [];
for (let i = 0; i < normalized.length - 2; i++) {
trigrams.push(normalized.substring(i, i + 3));
}
return trigrams;
}
/**
* Fügt einen Begriff zum Index hinzu
* @param {string} term - Begriff
* @param {string} category - Kategorie
*/
addTerm(term, category) {
const trigrams = this.createTrigrams(term);
for (const trigram of trigrams) {
if (!this.index.has(trigram)) {
this.index.set(trigram, []);
}
this.index.get(trigram).push({ term, category });
}
}
/**
* Findet Kandidaten basierend auf Trigram-Übereinstimmung
* @param {string} input - Eingabe-String
* @param {number} minTrigrams - Mindestanzahl übereinstimmender Trigramme
* @returns {Array} - Array von Kandidaten
*/
findCandidates(input, minTrigrams = 1) {
const inputTrigrams = this.createTrigrams(input);
const candidateCount = new Map();
for (const trigram of inputTrigrams) {
if (this.index.has(trigram)) {
for (const candidate of this.index.get(trigram)) {
const key = `${candidate.term}|${candidate.category}`;
candidateCount.set(key, (candidateCount.get(key) || 0) + 1);
}
}
}
// Filtere Kandidaten mit mindestens minTrigrams Übereinstimmungen
const candidates = [];
for (const [key, count] of candidateCount) {
if (count >= minTrigrams) {
const [term, category] = key.split('|');
candidates.push({ term, category });
}
}
return candidates;
}
}
/**
* Performance-optimierte Blacklist-Prüfung mit Trigram-Index
* @param {string} firstname - Vorname
* @param {string} lastname - Nachname
* @param {Object} blacklist - Blacklist gruppiert nach Kategorien
* @param {TrigramIndex} trigramIndex - Trigram-Index
* @returns {Object} - Prüfungsergebnis
*/
function checkWithTrigramIndex(firstname, lastname, blacklist, trigramIndex) {
const fullName = `${firstname.toLowerCase().trim()} ${lastname.toLowerCase().trim()}`;
const firstNameOnly = firstname.toLowerCase().trim();
const lastNameOnly = lastname.toLowerCase().trim();
const variants = [fullName, firstNameOnly, lastNameOnly];
const allSimilarTerms = [];
for (const variant of variants) {
// Finde Kandidaten mit Trigram-Index
const candidates = trigramIndex.findCandidates(variant, 1);
// Prüfe nur Kandidaten mit Levenshtein
for (const candidate of candidates) {
const categoryTerms = blacklist[candidate.category] || [];
const similarTerms = findSimilarTerms(variant, categoryTerms, THRESHOLDS[candidate.category] || 0.3);
allSimilarTerms.push(...similarTerms);
}
}
// Entferne Duplikate und sortiere
const uniqueSimilarTerms = allSimilarTerms.reduce((acc, current) => {
const existing = acc.find(item => item.term === current.term);
if (!existing || current.distance < existing.distance) {
return acc.filter(item => item.term !== current.term).concat(current);
}
return acc;
}, []);
return {
hasSimilarTerms: uniqueSimilarTerms.length > 0,
similarTerms: uniqueSimilarTerms.sort((a, b) => a.distance - b.distance),
bestMatch: uniqueSimilarTerms.length > 0 ? uniqueSimilarTerms[0] : null
};
}
/**
* Kategorie-spezifische Levenshtein-Prüfung
* @param {string} firstname - Vorname
* @param {string} lastname - Nachname
* @param {Array} blacklistTerms - Array von Blacklist-Begriffen
* @param {string} category - Kategorie der Begriffe
* @returns {Object} - Prüfungsergebnis
*/
function checkWithCategoryThreshold(firstname, lastname, blacklistTerms, category) {
const threshold = THRESHOLDS[category] || 0.3;
return checkWithLevenshtein(firstname, lastname, blacklistTerms, threshold);
}
module.exports = {
levenshteinDistance,
normalizedLevenshteinDistance,
isSimilarToBlacklistTerm,
findSimilarTerms,
checkWithLevenshtein,
checkWithCategoryThreshold,
checkWithTrigramIndex,
TrigramIndex,
THRESHOLDS
};

20
docker-compose.yml Normal file
View File

@@ -0,0 +1,20 @@
version: '3.8'
services:
app:
build: .
environment:
DB_HOST: host.docker.internal
DB_PORT: 5432
DB_NAME: ninjacross
DB_USER: reptil1990
DB_PASSWORD: \!Delfine1\!\!\!
DB_SSL: "false"
PORT: 3000
LICENSE_SECRET: 542ff224606c61fb3024e22f76ef9ac8
ports:
- "3000:3000"
volumes:
- .:/app
restart: unless-stopped

18
dockerfile Normal file
View File

@@ -0,0 +1,18 @@
# Use official Node.js LTS image
FROM node:18
# Set working directory
WORKDIR /app
# Copy package files and install dependencies
COPY package.json package-lock.json* ./
RUN npm install --production
# Copy the rest of the app
COPY . .
# Expose the port (default 3000)
EXPOSE 3000
# Start the server
CMD ["npm", "start"]

880
lib/achievementSystem.js Normal file
View File

@@ -0,0 +1,880 @@
/**
* NinjaCross Achievement System
*
* JavaScript-basierte Achievement-Logik für das NinjaCross Parkour System.
* Liest Achievement-Definitionen aus der Datenbank und prüft/vergibt Achievements.
*
* @author NinjaCross Team
* @version 1.0.0
*/
const { Pool } = require('pg');
require('dotenv').config();
// Database connection
const pool = new Pool({
host: process.env.DB_HOST,
port: process.env.DB_PORT,
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
ssl: process.env.DB_SSL === 'true' ? { rejectUnauthorized: false } : false
});
class AchievementSystem {
constructor() {
this.achievements = new Map();
this.playerAchievements = new Map();
}
/**
* Lädt alle aktiven Achievements aus der Datenbank
*/
async loadAchievements() {
try {
const result = await pool.query(`
SELECT id, name, description, category, condition_type, condition_value, icon, points, can_be_earned_multiple_times
FROM achievements
WHERE is_active = true
ORDER BY category, condition_type
`);
this.achievements.clear();
result.rows.forEach(achievement => {
this.achievements.set(achievement.id, achievement);
});
console.log(`📋 ${this.achievements.size} Achievements geladen`);
return true;
} catch (error) {
console.error('❌ Fehler beim Laden der Achievements:', error);
return false;
}
}
/**
* Lädt alle Spieler-Achievements für einen Spieler
* Jetzt werden alle Completions geladen (nicht nur die neueste)
*/
async loadPlayerAchievements(playerId) {
try {
// Initialisiere immer eine leere Map für den Spieler
this.playerAchievements.set(playerId, new Map());
const result = await pool.query(`
SELECT pa.achievement_id, pa.progress, pa.is_completed, pa.earned_at
FROM player_achievements pa
WHERE pa.player_id = $1
ORDER BY pa.earned_at DESC
`, [playerId]);
// Gruppiere nach achievement_id und zähle Completions
const achievementCounts = new Map();
result.rows.forEach(pa => {
if (!achievementCounts.has(pa.achievement_id)) {
achievementCounts.set(pa.achievement_id, {
count: 0,
latest: pa
});
}
achievementCounts.get(pa.achievement_id).count++;
});
// Speichere die neueste Completion und die Anzahl
achievementCounts.forEach((data, achievementId) => {
this.playerAchievements.get(playerId).set(achievementId, {
...data.latest,
completion_count: data.count
});
});
// Count unique achievements and duplicates
const uniqueAchievements = new Set(result.rows.map(row => row.achievement_id));
const totalCount = result.rows.length;
const uniqueCount = uniqueAchievements.size;
const duplicateCount = totalCount - uniqueCount;
if (duplicateCount > 0) {
console.log(`📋 ${totalCount} Achievements für Spieler ${playerId} geladen (${uniqueCount} eindeutige, ${duplicateCount} doppelt)`);
} else {
console.log(`📋 ${totalCount} Achievements für Spieler ${playerId} geladen`);
}
return true;
} catch (error) {
console.error(`❌ Fehler beim Laden der Spieler-Achievements für ${playerId}:`, error);
return false;
}
}
/**
* Prüft alle Achievements für einen Spieler
*/
async checkAllAchievements(playerId) {
console.log(`🎯 Prüfe Achievements für Spieler ${playerId}...`);
// Lade Spieler-Achievements
await this.loadPlayerAchievements(playerId);
const newAchievements = [];
// Prüfe alle Achievement-Kategorien
await this.checkConsistencyAchievements(playerId, newAchievements);
await this.checkImprovementAchievements(playerId, newAchievements);
await this.checkSeasonalAchievements(playerId, newAchievements);
await this.checkBestTimeAchievements(playerId, newAchievements);
console.log(`${newAchievements.length} neue Achievements für Spieler ${playerId}`);
return newAchievements;
}
/**
* Prüft sofortige Achievements für einen Spieler (bei neuer Zeit)
* Diese Achievements werden sofort vergeben, nicht nur um 19:00 Uhr
*/
async checkImmediateAchievements(playerId) {
console.log(`⚡ Prüfe sofortige Achievements für Spieler ${playerId}...`);
// Lade alle Achievements (falls noch nicht geladen)
if (this.achievements.size === 0) {
console.log('📋 Lade alle Achievements...');
await this.loadAchievements();
}
// Lade Spieler-Achievements
await this.loadPlayerAchievements(playerId);
const newAchievements = [];
// Prüfe nur sofortige Achievement-Kategorien
await this.checkImmediateConsistencyAchievements(playerId, newAchievements);
await this.checkImprovementAchievements(playerId, newAchievements);
await this.checkSeasonalAchievements(playerId, newAchievements);
// Best-Time Achievements werden NICHT sofort geprüft (nur um 19:00 Uhr)
if (newAchievements.length > 0) {
console.log(`🏆 ${newAchievements.length} sofortige Achievements für Spieler ${playerId}:`);
newAchievements.forEach(achievement => {
console.log(` ${achievement.icon} ${achievement.name} (+${achievement.points} Punkte)`);
});
}
return newAchievements;
}
/**
* Prüft Konsistenz-basierte Achievements
*/
async checkConsistencyAchievements(playerId, newAchievements) {
// Erste Schritte
await this.checkFirstTimeAchievement(playerId, newAchievements);
// Versuche pro Tag
await this.checkAttemptsPerDayAchievements(playerId, newAchievements);
// Einzigartige Tage
await this.checkUniqueDaysAchievements(playerId, newAchievements);
}
/**
* Prüft nur sofortige Konsistenz-basierte Achievements
*/
async checkImmediateConsistencyAchievements(playerId, newAchievements) {
// Erste Schritte
await this.checkFirstTimeAchievement(playerId, newAchievements);
// Versuche pro Tag
await this.checkAttemptsPerDayAchievements(playerId, newAchievements);
// Einzigartige Tage werden NICHT sofort geprüft (nur um 19:00 Uhr)
}
/**
* Prüft "Erste Schritte" Achievement
*/
async checkFirstTimeAchievement(playerId, newAchievements) {
const achievement = Array.from(this.achievements.values())
.find(a => a.category === 'consistency' && a.condition_type === 'first_time');
if (!achievement) return;
// Prüfe ob bereits erreicht
if (this.isAchievementCompleted(playerId, achievement.id)) return;
// Zähle Gesamtversuche
const result = await pool.query(`
SELECT COUNT(*) as count
FROM times t
WHERE t.player_id = $1
`, [playerId]);
const totalAttempts = parseInt(result.rows[0].count);
if (totalAttempts >= achievement.condition_value) {
await this.awardAchievement(playerId, achievement, totalAttempts, newAchievements);
}
}
/**
* Prüft "Versuche pro Tag" Achievements
*/
async checkAttemptsPerDayAchievements(playerId, newAchievements) {
const achievements = Array.from(this.achievements.values())
.filter(a => a.category === 'consistency' && a.condition_type === 'attempts_per_day');
// Zähle Versuche heute
const result = await pool.query(`
SELECT COUNT(*) as count
FROM times t
WHERE t.player_id = $1
AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') = CURRENT_DATE
`, [playerId]);
const attemptsToday = parseInt(result.rows[0].count);
for (const achievement of achievements) {
if (this.isAchievementCompleted(playerId, achievement.id)) continue;
// Prüfe ob das Achievement heute bereits vergeben wurde
const alreadyEarnedToday = await pool.query(`
SELECT COUNT(*) as count
FROM player_achievements pa
WHERE pa.player_id = $1
AND pa.achievement_id = $2
AND DATE(pa.earned_at AT TIME ZONE 'Europe/Berlin') = CURRENT_DATE
`, [playerId, achievement.id]);
if (parseInt(alreadyEarnedToday.rows[0].count) > 0) continue;
if (attemptsToday >= achievement.condition_value) {
await this.awardAchievement(playerId, achievement, attemptsToday, newAchievements);
}
}
}
/**
* Prüft "Einzigartige Tage" Achievements
*/
async checkUniqueDaysAchievements(playerId, newAchievements) {
const achievements = Array.from(this.achievements.values())
.filter(a => a.category === 'consistency' && a.condition_type === 'unique_days');
// Zähle einzigartige Tage
const result = await pool.query(`
SELECT COUNT(DISTINCT DATE(t.created_at AT TIME ZONE 'Europe/Berlin')) as count
FROM times t
WHERE t.player_id = $1
`, [playerId]);
const uniqueDays = parseInt(result.rows[0].count);
for (const achievement of achievements) {
if (this.isAchievementCompleted(playerId, achievement.id)) continue;
if (uniqueDays >= achievement.condition_value) {
await this.awardAchievement(playerId, achievement, uniqueDays, newAchievements);
}
}
}
/**
* Prüft Verbesserungs-basierte Achievements
*/
async checkImprovementAchievements(playerId, newAchievements) {
const achievements = Array.from(this.achievements.values())
.filter(a => a.category === 'improvement' && a.condition_type === 'time_improvement');
// Hole beste und zweitbeste Zeit
const result = await pool.query(`
SELECT
MIN(recorded_time) as best_time,
(SELECT MIN(recorded_time)
FROM times t2
WHERE t2.player_id = $1
AND t2.recorded_time > (SELECT MIN(recorded_time) FROM times t3 WHERE t3.player_id = $1)
) as second_best_time
FROM times t
WHERE t.player_id = $1
`, [playerId]);
const { best_time, second_best_time } = result.rows[0];
if (!best_time || !second_best_time) return;
// Berechne Verbesserung in Sekunden
const improvementSeconds = Math.floor((new Date(second_best_time) - new Date(best_time)) / 1000);
for (const achievement of achievements) {
if (this.isAchievementCompleted(playerId, achievement.id)) continue;
if (improvementSeconds >= achievement.condition_value) {
await this.awardAchievement(playerId, achievement, improvementSeconds, newAchievements);
}
}
}
/**
* Prüft Saisonale Achievements
*/
async checkSeasonalAchievements(playerId, newAchievements) {
const now = new Date();
const currentHour = now.getHours();
const currentMonth = now.getMonth() + 1; // JavaScript months are 0-based
const currentDayOfWeek = now.getDay(); // 0 = Sunday
// Zeit-basierte Achievements
await this.checkTimeBasedAchievements(playerId, currentHour, currentDayOfWeek, newAchievements);
// Monatliche Achievements
await this.checkMonthlyAchievements(playerId, currentMonth, newAchievements);
// Jahreszeiten-Achievements
await this.checkSeasonalTimeAchievements(playerId, currentMonth, newAchievements);
}
/**
* Prüft zeit-basierte Achievements (Wochenende, Morgen, etc.)
*/
async checkTimeBasedAchievements(playerId, currentHour, currentDayOfWeek, newAchievements) {
const timeAchievements = [
{ condition: 'weekend_play', check: () => currentDayOfWeek === 0 || currentDayOfWeek === 6 },
{ condition: 'morning_play', check: () => currentHour < 10 },
{ condition: 'afternoon_play', check: () => currentHour >= 14 && currentHour < 18 },
{ condition: 'evening_play', check: () => currentHour >= 18 }
];
for (const timeAchievement of timeAchievements) {
if (!timeAchievement.check()) continue;
const achievements = Array.from(this.achievements.values())
.filter(a => a.category === 'seasonal' && a.condition_type === timeAchievement.condition);
for (const achievement of achievements) {
if (this.isAchievementCompleted(playerId, achievement.id)) continue;
// Prüfe ob das Achievement heute bereits vergeben wurde
const alreadyEarnedToday = await pool.query(`
SELECT COUNT(*) as count
FROM player_achievements pa
WHERE pa.player_id = $1
AND pa.achievement_id = $2
AND DATE(pa.earned_at AT TIME ZONE 'Europe/Berlin') = CURRENT_DATE
`, [playerId, achievement.id]);
if (parseInt(alreadyEarnedToday.rows[0].count) > 0) continue;
// Prüfe ob Spieler zu dieser Zeit gespielt hat
const hasPlayed = await this.checkTimeBasedPlay(playerId, timeAchievement.condition);
if (hasPlayed) {
await this.awardAchievement(playerId, achievement, 1, newAchievements);
}
}
}
}
/**
* Prüft ob Spieler zu einer bestimmten Zeit gespielt hat
*/
async checkTimeBasedPlay(playerId, conditionType) {
let query = '';
switch (conditionType) {
case 'weekend_play':
query = `
SELECT EXISTS(
SELECT 1 FROM times t
WHERE t.player_id = $1
AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') = CURRENT_DATE
AND EXTRACT(DOW FROM t.created_at AT TIME ZONE 'Europe/Berlin') IN (0, 6)
) as has_played
`;
break;
case 'morning_play':
query = `
SELECT EXISTS(
SELECT 1 FROM times t
WHERE t.player_id = $1
AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') = CURRENT_DATE
AND EXTRACT(HOUR FROM t.created_at AT TIME ZONE 'Europe/Berlin') < 10
) as has_played
`;
break;
case 'afternoon_play':
query = `
SELECT EXISTS(
SELECT 1 FROM times t
WHERE t.player_id = $1
AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') = CURRENT_DATE
AND EXTRACT(HOUR FROM t.created_at AT TIME ZONE 'Europe/Berlin') BETWEEN 14 AND 17
) as has_played
`;
break;
case 'evening_play':
query = `
SELECT EXISTS(
SELECT 1 FROM times t
WHERE t.player_id = $1
AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') = CURRENT_DATE
AND EXTRACT(HOUR FROM t.created_at AT TIME ZONE 'Europe/Berlin') >= 18
) as has_played
`;
break;
}
if (!query) return false;
const result = await pool.query(query, [playerId]);
return result.rows[0].has_played;
}
/**
* Prüft monatliche Achievements (einmal pro Jahr)
*/
async checkMonthlyAchievements(playerId, currentMonth, newAchievements) {
const monthNames = [
'january', 'february', 'march', 'april', 'may', 'june',
'july', 'august', 'september', 'october', 'november', 'december'
];
const currentMonthName = monthNames[currentMonth - 1];
const currentYear = new Date().getFullYear();
const achievements = Array.from(this.achievements.values())
.filter(a => a.category === 'monthly' && a.condition_type === currentMonthName);
for (const achievement of achievements) {
if (this.isAchievementCompleted(playerId, achievement.id)) continue;
// Prüfe ob Spieler in diesem Monat dieses Jahres gespielt hat
const result = await pool.query(`
SELECT EXISTS(
SELECT 1 FROM times t
WHERE t.player_id = $1
AND EXTRACT(MONTH FROM t.created_at AT TIME ZONE 'Europe/Berlin') = $2
AND EXTRACT(YEAR FROM t.created_at AT TIME ZONE 'Europe/Berlin') = $3
) as has_played
`, [playerId, currentMonth, currentYear]);
if (result.rows[0].has_played) {
// Prüfe ob Achievement bereits in diesem Jahr erreicht wurde
const alreadyEarnedThisYear = await pool.query(`
SELECT COUNT(*) as count
FROM player_achievements pa
WHERE pa.player_id = $1
AND pa.achievement_id = $2
AND EXTRACT(YEAR FROM pa.earned_at AT TIME ZONE 'Europe/Berlin') = $3
`, [playerId, achievement.id, currentYear]);
if (parseInt(alreadyEarnedThisYear.rows[0].count) === 0) {
await this.awardAchievement(playerId, achievement, 1, newAchievements);
}
}
}
}
/**
* Prüft Jahreszeiten-Achievements (einmal pro Jahr)
*/
async checkSeasonalTimeAchievements(playerId, currentMonth, newAchievements) {
const season = this.getSeason(currentMonth);
const currentYear = new Date().getFullYear();
const achievements = Array.from(this.achievements.values())
.filter(a => a.category === 'seasonal' && a.condition_type === season);
for (const achievement of achievements) {
if (this.isAchievementCompleted(playerId, achievement.id)) continue;
// Prüfe ob Spieler in dieser Jahreszeit dieses Jahres gespielt hat
const monthRanges = {
spring: [3, 4, 5],
summer: [6, 7, 8],
autumn: [9, 10, 11],
winter: [12, 1, 2]
};
const months = monthRanges[season];
const result = await pool.query(`
SELECT EXISTS(
SELECT 1 FROM times t
WHERE t.player_id = $1
AND EXTRACT(MONTH FROM t.created_at AT TIME ZONE 'Europe/Berlin') = ANY($2)
AND EXTRACT(YEAR FROM t.created_at AT TIME ZONE 'Europe/Berlin') = $3
) as has_played
`, [playerId, months, currentYear]);
if (result.rows[0].has_played) {
// Prüfe ob Achievement bereits in diesem Jahr erreicht wurde
const alreadyEarnedThisYear = await pool.query(`
SELECT COUNT(*) as count
FROM player_achievements pa
WHERE pa.player_id = $1
AND pa.achievement_id = $2
AND EXTRACT(YEAR FROM pa.earned_at AT TIME ZONE 'Europe/Berlin') = $3
`, [playerId, achievement.id, currentYear]);
if (parseInt(alreadyEarnedThisYear.rows[0].count) === 0) {
await this.awardAchievement(playerId, achievement, 1, newAchievements);
}
}
}
}
/**
* Prüft Best-Time Achievements (nur um 19:00 Uhr)
*/
async checkBestTimeAchievements(playerId, newAchievements) {
const now = new Date();
const currentHour = now.getHours();
const currentDayOfWeek = now.getDay();
const currentDate = now.toISOString().split('T')[0];
const isLastDayOfMonth = this.isLastDayOfMonth(now);
// Nur um 19:00 Uhr prüfen
if (currentHour !== 19) return;
// Tageskönig (jeden Tag um 19:00)
await this.checkDailyBest(playerId, currentDate, newAchievements);
// Wochenchampion (nur Sonntag um 19:00)
if (currentDayOfWeek === 0) {
await this.checkWeeklyBest(playerId, currentDate, newAchievements);
}
// Monatsmeister (nur am letzten Tag des Monats um 19:00)
if (isLastDayOfMonth) {
await this.checkMonthlyBest(playerId, currentDate, newAchievements);
}
}
/**
* Prüft Tageskönig Achievement pro Standort
*/
async checkDailyBest(playerId, currentDate, newAchievements) {
const achievement = Array.from(this.achievements.values())
.find(a => a.category === 'time' && a.condition_type === 'best_time_daily_location');
if (!achievement) return;
// Hole alle Standorte, an denen der Spieler heute gespielt hat
const locationsResult = await pool.query(`
SELECT DISTINCT t.location_id, l.name as location_name
FROM times t
INNER JOIN locations l ON t.location_id = l.id
WHERE t.player_id = $1
AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') = $2
`, [playerId, currentDate]);
for (const location of locationsResult.rows) {
// Prüfe ob das Achievement heute bereits für diesen Standort vergeben wurde
const alreadyEarnedToday = await pool.query(`
SELECT COUNT(*) as count
FROM player_achievements pa
WHERE pa.player_id = $1
AND pa.achievement_id = $2
AND pa.location_id = $3
AND DATE(pa.earned_at AT TIME ZONE 'Europe/Berlin') = CURRENT_DATE
`, [playerId, achievement.id, location.location_id]);
if (parseInt(alreadyEarnedToday.rows[0].count) > 0) continue;
// Hole beste Zeit des Spielers heute an diesem Standort
const playerResult = await pool.query(`
SELECT MIN(recorded_time) as best_time
FROM times t
WHERE t.player_id = $1
AND t.location_id = $2
AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') = $3
`, [playerId, location.location_id, currentDate]);
// Hole beste Zeit des Tages an diesem Standort
const dailyResult = await pool.query(`
SELECT MIN(recorded_time) as best_time
FROM times t
WHERE t.location_id = $1
AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') = $2
`, [location.location_id, currentDate]);
const playerBest = playerResult.rows[0].best_time;
const dailyBest = dailyResult.rows[0].best_time;
if (playerBest && dailyBest && playerBest === dailyBest) {
await this.awardAchievement(playerId, achievement, 1, newAchievements, location.location_id);
console.log(`🏆 Tageskönig Achievement vergeben für Standort: ${location.location_name}`);
}
}
}
/**
* Prüft Wochenchampion Achievement pro Standort
*/
async checkWeeklyBest(playerId, currentDate, newAchievements) {
const achievement = Array.from(this.achievements.values())
.find(a => a.category === 'time' && a.condition_type === 'best_time_weekly_location');
if (!achievement) return;
// Berechne Woche
const currentDateObj = new Date(currentDate);
const dayOfWeek = currentDateObj.getDay();
const weekStart = new Date(currentDateObj);
weekStart.setDate(currentDateObj.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1));
const weekStartStr = weekStart.toISOString().split('T')[0];
// Hole alle Standorte, an denen der Spieler diese Woche gespielt hat
const locationsResult = await pool.query(`
SELECT DISTINCT t.location_id, l.name as location_name
FROM times t
INNER JOIN locations l ON t.location_id = l.id
WHERE t.player_id = $1
AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') >= $2
AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') <= $3
`, [playerId, weekStartStr, currentDate]);
for (const location of locationsResult.rows) {
// Prüfe ob das Achievement diese Woche bereits für diesen Standort vergeben wurde
const alreadyEarnedThisWeek = await pool.query(`
SELECT COUNT(*) as count
FROM player_achievements pa
WHERE pa.player_id = $1
AND pa.achievement_id = $2
AND pa.location_id = $3
AND DATE(pa.earned_at AT TIME ZONE 'Europe/Berlin') >= $4
AND DATE(pa.earned_at AT TIME ZONE 'Europe/Berlin') <= $5
`, [playerId, achievement.id, location.location_id, weekStartStr, currentDate]);
if (parseInt(alreadyEarnedThisWeek.rows[0].count) > 0) continue;
// Hole beste Zeit des Spielers diese Woche an diesem Standort
const playerResult = await pool.query(`
SELECT MIN(recorded_time) as best_time
FROM times t
WHERE t.player_id = $1
AND t.location_id = $2
AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') >= $3
AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') <= $4
`, [playerId, location.location_id, weekStartStr, currentDate]);
// Hole beste Zeit der Woche an diesem Standort
const weeklyResult = await pool.query(`
SELECT MIN(recorded_time) as best_time
FROM times t
WHERE t.location_id = $1
AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') >= $2
AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') <= $3
`, [location.location_id, weekStartStr, currentDate]);
const playerBest = playerResult.rows[0].best_time;
const weeklyBest = weeklyResult.rows[0].best_time;
if (playerBest && weeklyBest && playerBest === weeklyBest) {
await this.awardAchievement(playerId, achievement, 1, newAchievements, location.location_id);
console.log(`🏆 Wochenchampion Achievement vergeben für Standort: ${location.location_name}`);
}
}
}
/**
* Prüft Monatsmeister Achievement pro Standort
*/
async checkMonthlyBest(playerId, currentDate, newAchievements) {
const achievement = Array.from(this.achievements.values())
.find(a => a.category === 'time' && a.condition_type === 'best_time_monthly_location');
if (!achievement) return;
// Berechne Monatsstart
const currentDateObj = new Date(currentDate);
const monthStart = new Date(currentDateObj.getFullYear(), currentDateObj.getMonth(), 1);
const monthStartStr = monthStart.toISOString().split('T')[0];
// Hole alle Standorte, an denen der Spieler diesen Monat gespielt hat
const locationsResult = await pool.query(`
SELECT DISTINCT t.location_id, l.name as location_name
FROM times t
INNER JOIN locations l ON t.location_id = l.id
WHERE t.player_id = $1
AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') >= $2
AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') <= $3
`, [playerId, monthStartStr, currentDate]);
for (const location of locationsResult.rows) {
// Prüfe ob das Achievement diesen Monat bereits für diesen Standort vergeben wurde
const alreadyEarnedThisMonth = await pool.query(`
SELECT COUNT(*) as count
FROM player_achievements pa
WHERE pa.player_id = $1
AND pa.achievement_id = $2
AND pa.location_id = $3
AND DATE(pa.earned_at AT TIME ZONE 'Europe/Berlin') >= $4
AND DATE(pa.earned_at AT TIME ZONE 'Europe/Berlin') <= $5
`, [playerId, achievement.id, location.location_id, monthStartStr, currentDate]);
if (parseInt(alreadyEarnedThisMonth.rows[0].count) > 0) continue;
// Hole beste Zeit des Spielers diesen Monat an diesem Standort
const playerResult = await pool.query(`
SELECT MIN(recorded_time) as best_time
FROM times t
WHERE t.player_id = $1
AND t.location_id = $2
AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') >= $3
AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') <= $4
`, [playerId, location.location_id, monthStartStr, currentDate]);
// Hole beste Zeit des Monats an diesem Standort
const monthlyResult = await pool.query(`
SELECT MIN(recorded_time) as best_time
FROM times t
WHERE t.location_id = $1
AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') >= $2
AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') <= $3
`, [location.location_id, monthStartStr, currentDate]);
const playerBest = playerResult.rows[0].best_time;
const monthlyBest = monthlyResult.rows[0].best_time;
if (playerBest && monthlyBest && playerBest === monthlyBest) {
await this.awardAchievement(playerId, achievement, 1, newAchievements, location.location_id);
console.log(`🏆 Monatsmeister Achievement vergeben für Standort: ${location.location_name}`);
}
}
}
/**
* Vergibt ein Achievement an einen Spieler
* Erstellt immer einen neuen Eintrag (keine Updates mehr)
*/
async awardAchievement(playerId, achievement, progress, newAchievements, locationId = null) {
try {
await pool.query(`
INSERT INTO player_achievements (player_id, achievement_id, progress, is_completed, earned_at, location_id)
VALUES ($1, $2, $3, true, NOW(), $4)
`, [playerId, achievement.id, progress, locationId]);
newAchievements.push({
id: achievement.id,
name: achievement.name,
description: achievement.description,
icon: achievement.icon,
points: achievement.points,
progress: progress,
locationId: locationId
});
const locationText = locationId ? ` (Standort: ${locationId})` : '';
console.log(`🏆 Achievement vergeben: ${achievement.icon} ${achievement.name} (+${achievement.points} Punkte)${locationText}`);
} catch (error) {
console.error(`❌ Fehler beim Vergeben des Achievements ${achievement.name}:`, error);
}
}
/**
* Prüft ob ein Achievement bereits erreicht wurde
* Berücksichtigt ob das Achievement mehrmals erreicht werden kann
*/
isAchievementCompleted(playerId, achievementId) {
const achievement = this.achievements.get(achievementId);
if (!achievement) return false;
// Wenn das Achievement mehrmals erreicht werden kann, ist es nie "abgeschlossen"
if (achievement.can_be_earned_multiple_times) {
return false;
}
// Für einmalige Achievements prüfen wir, ob sie bereits erreicht wurden
const playerAchievements = this.playerAchievements.get(playerId);
if (!playerAchievements) {
console.log(`⚠️ Player achievements not loaded for ${playerId}, assuming not completed`);
return false;
}
return playerAchievements.has(achievementId);
}
/**
* Hilfsfunktionen
*/
getSeason(month) {
if (month >= 3 && month <= 5) return 'spring';
if (month >= 6 && month <= 8) return 'summer';
if (month >= 9 && month <= 11) return 'autumn';
return 'winter';
}
isLastDayOfMonth(date) {
const tomorrow = new Date(date);
tomorrow.setDate(date.getDate() + 1);
return tomorrow.getMonth() !== date.getMonth();
}
/**
* Berechnet die Gesamtpunkte eines Spielers (inklusive aller Completions)
*/
async getPlayerTotalPoints(playerId) {
try {
const result = await pool.query(`
SELECT
SUM(a.points) as total_points,
COUNT(pa.id) as total_completions
FROM player_achievements pa
INNER JOIN achievements a ON pa.achievement_id = a.id
WHERE pa.player_id = $1 AND pa.is_completed = true
`, [playerId]);
return {
totalPoints: parseInt(result.rows[0].total_points) || 0,
totalCompletions: parseInt(result.rows[0].total_completions) || 0
};
} catch (error) {
console.error(`❌ Fehler beim Berechnen der Gesamtpunkte für ${playerId}:`, error);
return { totalPoints: 0, totalCompletions: 0 };
}
}
/**
* Führt tägliche Achievement-Prüfung für alle Spieler durch
*/
async runDailyAchievementCheck() {
console.log('🎯 Starte tägliche Achievement-Prüfung...');
// Lade Achievements
await this.loadAchievements();
// Hole alle Spieler, die heute gespielt haben
const playersResult = await pool.query(`
SELECT DISTINCT p.id, p.firstname, p.lastname
FROM players p
INNER JOIN times t ON p.id = t.player_id
WHERE DATE(t.created_at AT TIME ZONE 'Europe/Berlin') = CURRENT_DATE
`);
let totalNewAchievements = 0;
const allNewAchievements = [];
for (const player of playersResult.rows) {
console.log(`🔍 Prüfe Achievements für ${player.firstname} ${player.lastname}...`);
const newAchievements = await this.checkAllAchievements(player.id);
totalNewAchievements += newAchievements.length;
if (newAchievements.length > 0) {
allNewAchievements.push({
player: `${player.firstname} ${player.lastname}`,
achievements: newAchievements
});
}
}
console.log(`🎉 Tägliche Achievement-Prüfung abgeschlossen!`);
console.log(`📊 ${totalNewAchievements} neue Achievements vergeben`);
return {
totalNewAchievements,
playerAchievements: allNewAchievements
};
}
}
module.exports = AchievementSystem;

162
lib/push-service.js Normal file
View File

@@ -0,0 +1,162 @@
const webpush = require('web-push');
// VAPID Keys (sollten in Umgebungsvariablen gespeichert werden)
const vapidKeys = {
publicKey: 'BJmNVx0C3XeVxeKGTP9c-Z4HcuZNmdk6QdiLocZgCmb-miCS0ESFO3W2TvJlRhhNAShV63pWA5p36BTVSetyTds',
privateKey: 'HBdRCtmZUAzsWpVjZ2LDaoWliIPHldAb5ExAt8bvDeg'
};
// Configure web-push
webpush.setVapidDetails(
'mailto:admin@reptilfpv.de',
vapidKeys.publicKey,
vapidKeys.privateKey
);
class PushService {
constructor() {
this.subscriptions = new Map(); // Map<playerId, Set<subscription>>
}
// Subscribe user to push notifications
subscribe(userId, subscription) {
if (!this.subscriptions.has(userId)) {
this.subscriptions.set(userId, new Set());
}
this.subscriptions.get(userId).add(subscription);
console.log(`User ${userId} subscribed to push notifications (${this.subscriptions.get(userId).size} total subscriptions)`);
}
// Unsubscribe user from push notifications
unsubscribe(userId) {
this.subscriptions.delete(userId);
console.log(`User ${userId} unsubscribed from push notifications`);
}
// Unsubscribe specific device from push notifications
unsubscribeDevice(userId, endpoint) {
const subscriptions = this.subscriptions.get(userId);
if (!subscriptions) {
console.log(`No subscriptions found for user ${userId}`);
return false;
}
// Find and remove the specific subscription by endpoint
let removed = false;
for (const subscription of subscriptions) {
if (subscription.endpoint === endpoint) {
subscriptions.delete(subscription);
removed = true;
break;
}
}
// If no subscriptions left, remove the user entirely
if (subscriptions.size === 0) {
this.subscriptions.delete(userId);
console.log(`User ${userId} unsubscribed from push notifications (all devices)`);
} else {
console.log(`Device ${endpoint} unsubscribed from push notifications for user ${userId} (${subscriptions.size} devices remaining)`);
}
return removed;
}
// Send push notification to specific user
async sendToUser(userId, payload) {
const subscriptions = this.subscriptions.get(userId);
if (!subscriptions || subscriptions.size === 0) {
console.log(`No subscriptions found for user ${userId}`);
return false;
}
let successCount = 0;
let totalCount = subscriptions.size;
for (const subscription of subscriptions) {
try {
await webpush.sendNotification(subscription, JSON.stringify(payload));
successCount++;
} catch (error) {
console.error(`Error sending push notification to user ${userId}:`, error);
// If subscription is invalid, remove it
if (error.statusCode === 410) {
subscriptions.delete(subscription);
}
}
}
console.log(`Push notification sent to ${successCount}/${totalCount} subscriptions for user ${userId}`);
return successCount > 0;
}
// Send push notification to all subscribed users
async sendToAll(payload) {
const results = [];
for (const [userId, subscription] of this.subscriptions) {
const result = await this.sendToUser(userId, payload);
results.push({ userId, success: result });
}
return results;
}
// Send achievement notification
async sendAchievementNotification(userId, achievementName) {
const payload = {
title: '🏆 Neues Achievement!',
body: `Du hast "${achievementName}" erreicht!`,
icon: '/pictures/icon-192.png',
badge: '/pictures/icon-192.png',
data: {
type: 'achievement',
achievement: achievementName,
timestamp: Date.now()
},
actions: [
{
action: 'view',
title: 'Dashboard öffnen'
}
]
};
return await this.sendToUser(userId, payload);
}
// Send best time notification
async sendBestTimeNotification(userId, timeType, locationName) {
const payload = {
title: `🏁 ${timeType} Bestzeit!`,
body: `Du hast die beste Zeit in ${locationName} erreicht!`,
icon: '/pictures/icon-192.png',
badge: '/pictures/icon-192.png',
data: {
type: 'best_time',
timeType: timeType,
location: locationName,
timestamp: Date.now()
},
actions: [
{
action: 'view',
title: 'Dashboard öffnen'
}
]
};
return await this.sendToUser(userId, payload);
}
// Get subscription count
getSubscriptionCount() {
return this.subscriptions.size;
}
// Get all user IDs with subscriptions
getSubscribedUsers() {
return Array.from(this.subscriptions.keys());
}
}
module.exports = new PushService();

View File

@@ -0,0 +1,13 @@
{
"type": "service_account",
"project_id": "ninjacross",
"private_key_id": "8a77c8ef79a7a7784f022f276a9d3f26eac6c044",
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDItq0W+LPbz1Gg\nzXZQm3yiuqun9ZjYYO2G2S5jRm4DcWB+dStfg7fZCOjBI5ziN3mpwWMfn9dogbtG\nAak42Fz8j9sMggDxk98JWfU8eqofJVhB6ADWwPNdoajzBuUO4kVjhqIFrAAHH7mn\ntTgddVut5IxG8vlXxjx/tTgt/tXCWy9AO8J0nG581K8gVMD6jySsrRpVO7q25Lfp\nv8TO6Ke6+0E4DcER0+x1gckPgVPuVU2vluBYUV+JXvzafv9S78FRq9UzPiaNEDYz\nZMAOBmXHB5OE1Y53NaGa9PWHOrNU2PGcC+RcbU1+4fbVKapM8QOOR1Xo8uMe0Kx/\ntKzs7917AgMBAAECggEAGplC1PF+fFm8GpA/5qzEVphgWTDN4Zbuw1kSsatKtwAW\nZovGhLDi80sf9UBv8PajE+EB7tXy6PGQTNW5hEQABqRVxhGQaHNNMmMOgcZLtzbu\nvEUvn0YQuk7LIfG+9zr7MRZNcGz7z/XfV59HYXgE/0VzZY+bhYtKxy3P1SCZWVkZ\n3EgsUIEqDwDviTkcaPgRtbyWyIZdy+uSgvIa+CE1ra+Q0RWK9OJGVJkU3AG44r8i\nR28muIfHWydOy3OO5DrFOkbX+pDGKEAHEQ0pevAOcSMiYlJRBiD7eJyh0dqcgwen\nX8IZ36qBJJofxbPqzjw6a0iWB9jj1oLBrM9Dbg790QKBgQD257zYmde81/jZ8DBm\nTf37ME07KbXPOrj9SqUwsz8kmbTr5QiR9QFJZP443ijs45wIMtroygX4PyTbwjQI\nmNrgxrYP5hGUdmeOdKYtccqH1FBXqTe1UYXgBV16awWURaPH73zGhcvnZNO+oEZl\nuvsG1s3MT4CmPfhy3NUS8taECQKBgQDQG1x/TtTFcmzeaM400wx4VxQSx4GbXEvY\nYImPAHNPsn8PW7wNd7/xW1d0GXPZEjXHGz57jyoCqRdRy+jqh6/3iGS9oCgZNM6z\nF1A6Wxr14IMTQX6uioYtXGev+drp5SPv1G0cqo1y+s2RQk788FAbF2tZqJHf/L2w\n8Fal/mPeYwKBgE89dl0pmpRv19zR+iaLN0zrZo3rR/83AHHCCBwGGui7L9ZZThPR\nxtTwRaqomgfU0JnNAHafh6TxVvn0bNCphe2HuJyHoPK9wWR1yXNiRrarDBHmLAvU\nGxwXfLWyLTs10mdzU98+x37+2/oc5Br4FGJQhAHjLg2sa9UpTHTlXLVJAoGBAMi2\n6xKajJrXDuvAN8o5F9jlW6X1KCsY7MoH6gSzkYP3i5bbileO/OCjkYiXl+VwK9Aa\nlbwES6d/QM+SlNXHAtACi+9cjfApv4Z54NY30pv8607iJ3XegyUy74qJuDtI1s1U\nm9w/Hugbv7LvOlG40fofL1mtPOzEzoveciPtZJMzAoGBAMk++690xWQr8oWxxpDN\ntUkTpBwoeZ0VQx1E4w6B5XASjikmsblnpO+N1BwqDER+PsPw7B3gT9edhpUJf8TL\nlF4tin8Q9huqjjVdeUd2FxtVScNiyBO6UUtUgC1MtYKIox0saKfwX2DmAemqe9n+\nC0t9w6x0/XK4hZ5Vklp26/dS\n-----END PRIVATE KEY-----\n",
"client_email": "mcp-939@ninjacross.iam.gserviceaccount.com",
"client_id": "101546098649994814214",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/mcp-939%40ninjacross.iam.gserviceaccount.com",
"universe_domain": "googleapis.com"
}

7461
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

47
package.json Normal file
View File

@@ -0,0 +1,47 @@
{
"name": "lizenz-generator",
"version": "1.0.0",
"description": "Lizenzgenerator mit PostgreSQL Integration",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js",
"init-db": "node scripts/init-db.js",
"create-user": "node scripts/create-user.js",
"test-achievements": "node scripts/test-achievements.js",
"test-immediate-achievements": "node scripts/test-immediate-achievements.js",
"simulate-new-time": "node scripts/simulate-new-time.js",
"test-multiple-achievements": "node scripts/test-multiple-achievements.js"
},
"dependencies": {
"@hisma/server-puppeteer": "^0.6.5",
"bcrypt": "^5.1.1",
"discord-oauth2": "^2.12.1",
"dotenv": "^16.3.1",
"enhanced-postgres-mcp-server": "^1.0.1",
"express": "^4.18.2",
"express-session": "^1.17.3",
"https": "^1.0.0",
"node-cron": "^4.2.1",
"passport": "^0.7.0",
"passport-discord": "^0.1.4",
"pg": "^8.11.3",
"socket.io": "^4.8.1",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"uuid": "^12.0.0",
"web-push": "^3.6.7"
},
"devDependencies": {
"nodemon": "^3.0.1"
},
"keywords": [
"license",
"generator",
"postgresql",
"api",
"token"
],
"author": "Carsten Graf",
"license": "propriatary"
}

187
pentest/enumerate.py Normal file
View File

@@ -0,0 +1,187 @@
import requests
import uuid
import time
import json
from datetime import datetime
def enumerate_supabase_users():
base_url = "http://localhost:3000/api/v1/public/user-player"
found_users = []
total_requests = 0
print("🔍 STARTE USER ENUMERATION ÜBER SUPABASE USER IDS")
print("=" * 60)
print(f"Zeit: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print(f"Target: {base_url}")
print("=" * 60)
# Teste verschiedene UUID-Patterns
test_uuids = [
str(uuid.uuid4()) for _ in range(1000) # Zufällige UUIDs
]
print(f"📊 Teste {len(test_uuids)} UUIDs...")
print("-" * 60)
for i, uuid_str in enumerate(test_uuids, 1):
try:
response = requests.get(f"{base_url}/{uuid_str}", timeout=5)
total_requests += 1
if response.status_code == 200:
user_data = response.json()
if user_data.get("success"):
found_users.append(user_data["data"])
user = user_data["data"]
print(f"✅ [{i:4d}] USER GEFUNDEN!")
print(f" UUID: {uuid_str}")
print(f" Name: {user['firstname']} {user['lastname']}")
print(f" ID: {user['id']}")
print(f" RFID: {user['rfiduid']}")
print(f" Geburtsdatum: {user['birthdate']}")
print(f" Leaderboard: {user['show_in_leaderboard']}")
print("-" * 60)
else:
if i % 100 == 0: # Fortschritt alle 100 Requests
print(f"⏳ [{i:4d}] Kein User gefunden (Fortschritt: {i}/{len(test_uuids)})")
else:
if i % 100 == 0:
print(f"❌ [{i:4d}] HTTP {response.status_code} (Fortschritt: {i}/{len(test_uuids)})")
except requests.exceptions.RequestException as e:
print(f"🔥 [{i:4d}] Fehler bei UUID {uuid_str}: {e}")
continue
print("\n" + "=" * 60)
print("📈 ENUMERATION ABGESCHLOSSEN")
print("=" * 60)
print(f"Total Requests: {total_requests}")
print(f"Gefundene Users: {len(found_users)}")
print(f"Erfolgsrate: {(len(found_users)/total_requests*100):.2f}%" if total_requests > 0 else "0%")
if found_users:
print("\n🎯 GEFUNDENE USERS:")
print("-" * 60)
for i, user in enumerate(found_users, 1):
print(f"{i}. {user['firstname']} {user['lastname']}")
print(f" ID: {user['id']} | RFID: {user['rfiduid']} | Geburtstag: {user['birthdate']}")
print("-" * 60)
# Speichere Ergebnisse in Datei
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"enumerated_users_{timestamp}.json"
with open(filename, 'w', encoding='utf-8') as f:
json.dump(found_users, f, indent=2, ensure_ascii=False)
print(f"💾 Ergebnisse gespeichert in: {filename}")
else:
print("\n❌ Keine Users gefunden")
print(f"\n⏰ Abgeschlossen um: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
return found_users
def enumerate_rfid_uids(api_key, max_attempts=100):
"""RFID UID Enumeration (benötigt gültigen API-Key)"""
base_url = "http://localhost:3000/api/v1/private/users/find"
found_rfids = []
print("\n🔍 STARTE RFID UID ENUMERATION")
print("=" * 60)
print(f"Zeit: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print(f"Target: {base_url}")
print(f"API-Key: {api_key[:10]}...")
print("=" * 60)
# Generiere RFID UIDs zum Testen
for i in range(1, max_attempts + 1):
# Generiere RFID im Format AA:BB:CC:XX
rfid_uid = f"AA:BB:CC:{i:02X}"
try:
response = requests.post(
base_url,
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
},
json={"uid": rfid_uid},
timeout=5
)
if response.status_code == 200:
data = response.json()
if data.get("success") and data.get("data", {}).get("exists"):
found_rfids.append(data["data"])
user = data["data"]
print(f"✅ [{i:3d}] RFID GEFUNDEN!")
print(f" RFID: {rfid_uid}")
print(f" Name: {user['firstname']} {user['lastname']}")
print(f" Alter: {user['alter']}")
print("-" * 60)
else:
if i % 20 == 0: # Fortschritt alle 20 Requests
print(f"⏳ [{i:3d}] Kein User für RFID {rfid_uid}")
else:
print(f"❌ [{i:3d}] HTTP {response.status_code} für RFID {rfid_uid}")
except requests.exceptions.RequestException as e:
print(f"🔥 [{i:3d}] Fehler bei RFID {rfid_uid}: {e}")
continue
print("\n📈 RFID ENUMERATION ABGESCHLOSSEN")
print(f"Gefundene RFIDs: {len(found_rfids)}")
return found_rfids
def test_admin_login():
"""Teste Admin Login Enumeration"""
base_url = "http://localhost:3000/api/v1/public/login"
# Häufige Admin-Usernamen
admin_usernames = [
"admin", "administrator", "root", "user", "test", "demo",
"admin1", "admin2", "superuser", "manager", "operator"
]
print("\n🔍 TESTE ADMIN LOGIN ENUMERATION")
print("=" * 60)
for username in admin_usernames:
try:
start_time = time.time()
response = requests.post(
base_url,
json={"username": username, "password": "wrongpassword"},
timeout=5
)
end_time = time.time()
response_time = (end_time - start_time) * 1000 # in ms
print(f"👤 {username:12} | Status: {response.status_code:3d} | Zeit: {response_time:6.1f}ms")
if response.status_code == 200:
print(f" ⚠️ MÖGLICHERWEISE GÜLTIGER USERNAME!")
except Exception as e:
print(f"🔥 Fehler bei {username}: {e}")
# Führe Enumeration aus
if __name__ == "__main__":
print("🚨 NINJA SERVER SECURITY AUDIT - USER ENUMERATION")
print("⚠️ WARNUNG: Nur für autorisierte Sicherheitstests!")
print()
# 1. Supabase User ID Enumeration
found_users = enumerate_supabase_users()
# 2. Admin Login Test
test_admin_login()
# 3. RFID Enumeration (nur mit gültigem API-Key)
api_key = input("\n🔑 API-Key für RFID Enumeration eingeben (oder Enter zum Überspringen): ").strip()
if api_key:
enumerate_rfid_uids(api_key, 50) # Teste nur 50 RFIDs
else:
print("⏭️ RFID Enumeration übersprungen")
print("\n🏁 AUDIT ABGESCHLOSSEN")

View File

@@ -0,0 +1,312 @@
import requests
import time
import json
from datetime import datetime
import statistics
def test_admin_login_timing():
"""Detaillierte Timing-Analyse für Admin Login"""
base_url = "http://localhost:3000/api/v1/public/login"
# Erweiterte Liste von Admin-Usernamen
admin_usernames = [
"admin", "administrator", "root", "user", "test", "demo",
"admin1", "admin2", "superuser", "manager", "operator",
"ninja", "parkour", "system", "api", "service",
"backup", "support", "helpdesk", "it", "tech"
]
print("🔍 DETAILLIERTE ADMIN LOGIN TIMING-ANALYSE")
print("=" * 70)
print(f"Zeit: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print(f"Target: {base_url}")
print("=" * 70)
results = []
# Teste jeden Username mehrfach für bessere Statistik
for username in admin_usernames:
times = []
print(f"\n👤 Testing: {username}")
print("-" * 50)
for attempt in range(5): # 5 Versuche pro Username
try:
start_time = time.time()
response = requests.post(
base_url,
json={"username": username, "password": "wrongpassword123"},
timeout=10
)
end_time = time.time()
response_time = (end_time - start_time) * 1000 # in ms
times.append(response_time)
print(f" Attempt {attempt+1}: {response_time:6.1f}ms | Status: {response.status_code}")
# Kleine Pause zwischen Requests
time.sleep(0.1)
except Exception as e:
print(f" Attempt {attempt+1}: ERROR - {e}")
continue
if times:
avg_time = statistics.mean(times)
std_dev = statistics.stdev(times) if len(times) > 1 else 0
min_time = min(times)
max_time = max(times)
results.append({
'username': username,
'avg_time': avg_time,
'std_dev': std_dev,
'min_time': min_time,
'max_time': max_time,
'times': times,
'suspicious': avg_time > 50 # Verdächtig wenn > 50ms
})
print(f" 📊 Stats: Avg={avg_time:.1f}ms, Std={std_dev:.1f}ms, Range={min_time:.1f}-{max_time:.1f}ms")
if avg_time > 50:
print(f" ⚠️ SUSPEKT: Deutlich längere Response-Zeit!")
# Sortiere nach durchschnittlicher Response-Zeit
results.sort(key=lambda x: x['avg_time'], reverse=True)
print("\n" + "=" * 70)
print("📈 TIMING-ANALYSE ERGEBNISSE")
print("=" * 70)
print(f"{'Username':<15} {'Avg(ms)':<8} {'Std(ms)':<8} {'Min(ms)':<8} {'Max(ms)':<8} {'Status'}")
print("-" * 70)
for result in results:
status = "⚠️ SUSPEKT" if result['suspicious'] else "✅ Normal"
print(f"{result['username']:<15} {result['avg_time']:<8.1f} {result['std_dev']:<8.1f} "
f"{result['min_time']:<8.1f} {result['max_time']:<8.1f} {status}")
# Speichere detaillierte Ergebnisse
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"timing_analysis_{timestamp}.json"
with open(filename, 'w', encoding='utf-8') as f:
json.dump(results, f, indent=2, ensure_ascii=False)
print(f"\n💾 Detaillierte Ergebnisse gespeichert in: {filename}")
return results
def test_rfid_creation_enumeration():
"""Teste RFID Enumeration über Spieler-Erstellung"""
base_url = "http://localhost:3000/api/v1/public/players/create-with-rfid"
print("\n🔍 RFID ENUMERATION ÜBER SPIELER-ERSTELLUNG")
print("=" * 70)
print(f"Zeit: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print(f"Target: {base_url}")
print("=" * 70)
found_rfids = []
# Teste verschiedene RFID-Patterns
test_patterns = [
"AA:BB:CC:DD", "AA:BB:CC:DE", "AA:BB:CC:DF",
"11:22:33:44", "11:22:33:45", "11:22:33:46",
"FF:FF:FF:FF", "00:00:00:00", "12:34:56:78",
"AB:CD:EF:12", "DE:AD:BE:EF", "CA:FE:BA:BE"
]
for i, rfid in enumerate(test_patterns, 1):
try:
payload = {
"rfiduid": rfid,
"firstname": "Test",
"lastname": "User",
"birthdate": "1990-01-01"
}
response = requests.post(base_url, json=payload, timeout=5)
print(f"[{i:2d}] RFID: {rfid:<12} | Status: {response.status_code:3d}", end="")
if response.status_code == 400:
try:
data = response.json()
if "existiert bereits" in data.get("message", ""):
print(" | ✅ EXISTIERT!")
if "existingPlayer" in data.get("details", {}):
existing = data["details"]["existingPlayer"]
found_rfids.append({
"rfid": rfid,
"existing_player": existing
})
print(f" → Name: {existing.get('firstname')} {existing.get('lastname')}")
else:
print(" | ❌ Anderer Fehler")
except:
print(" | ❌ JSON Parse Error")
else:
print(" | ⚠️ Unexpected Status")
except Exception as e:
print(f"[{i:2d}] RFID: {rfid:<12} | ERROR: {e}")
print(f"\n📊 RFID Enumeration abgeschlossen: {len(found_rfids)} gefunden")
return found_rfids
def test_leaderboard_data_leak():
"""Teste Leaderboard auf sensible Daten"""
base_url = "http://localhost:3000/api/v1/public/times-with-details"
print("\n🔍 LEADERBOARD DATENLEAK-TEST")
print("=" * 70)
print(f"Zeit: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print(f"Target: {base_url}")
print("=" * 70)
try:
response = requests.get(base_url, timeout=10)
print(f"Status: {response.status_code}")
if response.status_code == 200:
data = response.json()
if isinstance(data, list) and len(data) > 0:
print(f"✅ Leaderboard-Daten gefunden: {len(data)} Einträge")
# Analysiere erste paar Einträge
for i, entry in enumerate(data[:3]):
print(f"\n📋 Eintrag {i+1}:")
if 'player' in entry:
player = entry['player']
print(f" Name: {player.get('firstname')} {player.get('lastname')}")
print(f" RFID: {player.get('rfiduid')}")
print(f" ID: {player.get('id')}")
if 'location' in entry:
location = entry['location']
print(f" Location: {location.get('name')}")
print(f" Koordinaten: {location.get('latitude')}, {location.get('longitude')}")
if 'recorded_time_seconds' in entry:
print(f" Zeit: {entry['recorded_time_seconds']} Sekunden")
# Speichere alle Daten
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"leaderboard_data_{timestamp}.json"
with open(filename, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2, ensure_ascii=False)
print(f"\n💾 Leaderboard-Daten gespeichert in: {filename}")
return data
else:
print("❌ Keine Leaderboard-Daten gefunden")
else:
print(f"❌ Fehler: HTTP {response.status_code}")
except Exception as e:
print(f"🔥 Fehler beim Abrufen der Leaderboard-Daten: {e}")
return None
def test_error_message_analysis():
"""Analysiere Error Messages auf Information Leakage"""
base_url = "http://localhost:3000/api/v1/public/user-player"
print("\n🔍 ERROR MESSAGE ANALYSE")
print("=" * 70)
test_uuids = [
"00000000-0000-0000-0000-000000000000", # Null UUID
"invalid-uuid-format", # Ungültiges Format
"12345678-1234-1234-1234-123456789012", # Gültiges Format, aber wahrscheinlich nicht existent
"../../../etc/passwd", # Path Traversal
"<script>alert('xss')</script>", # XSS Test
"'; DROP TABLE players; --" # SQL Injection Test
]
error_responses = {}
for i, test_input in enumerate(test_uuids, 1):
try:
response = requests.get(f"{base_url}/{test_input}", timeout=5)
status_code = response.status_code
print(f"[{i}] Input: {test_input:<30} | Status: {status_code}")
if status_code not in error_responses:
error_responses[status_code] = []
try:
json_data = response.json()
error_responses[status_code].append({
'input': test_input,
'response': json_data
})
except:
error_responses[status_code].append({
'input': test_input,
'response': response.text[:200] # Erste 200 Zeichen
})
except Exception as e:
print(f"[{i}] Input: {test_input:<30} | ERROR: {e}")
# Analysiere verschiedene Error-Messages
print(f"\n📊 Error-Message Analyse:")
print("-" * 50)
for status_code, responses in error_responses.items():
print(f"Status {status_code}: {len(responses)} Responses")
# Prüfe auf unterschiedliche Error-Messages
unique_messages = set()
for resp in responses:
if isinstance(resp['response'], dict):
message = resp['response'].get('message', 'No message')
else:
message = str(resp['response'])[:100]
unique_messages.add(message)
print(f" Unique messages: {len(unique_messages)}")
for msg in list(unique_messages)[:3]: # Zeige erste 3
print(f" - {msg}")
return error_responses
if __name__ == "__main__":
print("🚨 NINJA SERVER - REALISTISCHE SICHERHEITSTESTS")
print("⚠️ WARNUNG: Nur für autorisierte Sicherheitstests!")
print()
# 1. Admin Login Timing Analysis
timing_results = test_admin_login_timing()
# 2. RFID Enumeration über Spieler-Erstellung
rfid_results = test_rfid_creation_enumeration()
# 3. Leaderboard Datenleak-Test
leaderboard_data = test_leaderboard_data_leak()
# 4. Error Message Analysis
error_analysis = test_error_message_analysis()
print("\n" + "=" * 70)
print("🏁 REALISTISCHE SICHERHEITSTESTS ABGESCHLOSSEN")
print("=" * 70)
# Zusammenfassung
suspicious_users = [r for r in timing_results if r['suspicious']]
print(f"🔍 Timing-Suspicious Users: {len(suspicious_users)}")
print(f"🔍 Gefundene RFIDs: {len(rfid_results)}")
print(f"🔍 Leaderboard-Einträge: {len(leaderboard_data) if leaderboard_data else 0}")
if suspicious_users:
print(f"\n⚠️ SUSPEKTE USERNAMES (Timing):")
for user in suspicious_users:
print(f" - {user['username']}: {user['avg_time']:.1f}ms")
print(f"\n⏰ Abgeschlossen um: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")

336
public/404.html Normal file
View File

@@ -0,0 +1,336 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>404 - Seite nicht gefunden | NinjaCross</title>
<link rel="icon" type="image/x-icon" href="/pictures/favicon.ico">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
position: relative;
}
/* Animated background particles */
.particles {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow: hidden;
z-index: 1;
}
.particle {
position: absolute;
background: rgba(255, 255, 255, 0.1);
border-radius: 50%;
animation: float 6s ease-in-out infinite;
}
.particle:nth-child(1) { width: 4px; height: 4px; left: 10%; animation-delay: 0s; }
.particle:nth-child(2) { width: 6px; height: 6px; left: 20%; animation-delay: 1s; }
.particle:nth-child(3) { width: 3px; height: 3px; left: 30%; animation-delay: 2s; }
.particle:nth-child(4) { width: 5px; height: 5px; left: 40%; animation-delay: 3s; }
.particle:nth-child(5) { width: 4px; height: 4px; left: 50%; animation-delay: 4s; }
.particle:nth-child(6) { width: 7px; height: 7px; left: 60%; animation-delay: 5s; }
.particle:nth-child(7) { width: 3px; height: 3px; left: 70%; animation-delay: 0.5s; }
.particle:nth-child(8) { width: 5px; height: 5px; left: 80%; animation-delay: 1.5s; }
.particle:nth-child(9) { width: 4px; height: 4px; left: 90%; animation-delay: 2.5s; }
@keyframes float {
0%, 100% { transform: translateY(100vh) rotate(0deg); opacity: 0; }
10% { opacity: 1; }
90% { opacity: 1; }
50% { transform: translateY(-10vh) rotate(180deg); }
}
/* Main container */
.container {
text-align: center;
z-index: 10;
position: relative;
max-width: 600px;
padding: 2rem;
}
/* Ninja emoji with animation */
.ninja-emoji {
font-size: 8rem;
margin-bottom: 1rem;
display: inline-block;
animation: ninja-bounce 2s ease-in-out infinite;
filter: drop-shadow(0 0 20px rgba(0, 255, 255, 0.5));
}
@keyframes ninja-bounce {
0%, 100% { transform: translateY(0) rotate(0deg); }
25% { transform: translateY(-20px) rotate(-5deg); }
50% { transform: translateY(-10px) rotate(0deg); }
75% { transform: translateY(-15px) rotate(5deg); }
}
/* 404 number with glow effect */
.error-code {
font-size: 6rem;
font-weight: bold;
background: linear-gradient(45deg, #00ffff, #ff00ff, #ffff00, #00ffff);
background-size: 400% 400%;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
animation: gradient-shift 3s ease-in-out infinite;
margin-bottom: 1rem;
text-shadow: 0 0 30px rgba(0, 255, 255, 0.5);
}
@keyframes gradient-shift {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
/* Error message */
.error-message {
font-size: 1.5rem;
color: #ffffff;
margin-bottom: 2rem;
opacity: 0;
animation: fade-in-up 1s ease-out 0.5s forwards;
}
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Description */
.description {
font-size: 1.1rem;
color: #b0b0b0;
margin-bottom: 3rem;
line-height: 1.6;
opacity: 0;
animation: fade-in-up 1s ease-out 1s forwards;
}
/* Action buttons */
.actions {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
opacity: 0;
animation: fade-in-up 1s ease-out 1.5s forwards;
}
.btn {
padding: 12px 24px;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
text-decoration: none;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.btn-primary {
background: linear-gradient(135deg, #00ffff, #0080ff);
color: white;
box-shadow: 0 4px 15px rgba(0, 255, 255, 0.3);
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 255, 255, 0.5);
}
.btn-secondary {
background: transparent;
color: #00ffff;
border: 2px solid #00ffff;
}
.btn-secondary:hover {
background: #00ffff;
color: #1a1a2e;
transform: translateY(-2px);
}
/* Glitch effect for 404 */
.glitch {
position: relative;
display: inline-block;
}
.glitch::before,
.glitch::after {
content: '404';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(45deg, #00ffff, #ff00ff, #ffff00, #00ffff);
background-size: 400% 400%;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.glitch::before {
animation: glitch-1 0.5s infinite;
z-index: -1;
}
.glitch::after {
animation: glitch-2 0.5s infinite;
z-index: -2;
}
@keyframes glitch-1 {
0%, 100% { transform: translate(0); }
20% { transform: translate(-2px, 2px); }
40% { transform: translate(-2px, -2px); }
60% { transform: translate(2px, 2px); }
80% { transform: translate(2px, -2px); }
}
@keyframes glitch-2 {
0%, 100% { transform: translate(0); }
20% { transform: translate(2px, -2px); }
40% { transform: translate(2px, 2px); }
60% { transform: translate(-2px, -2px); }
80% { transform: translate(-2px, 2px); }
}
/* Responsive design */
@media (max-width: 768px) {
.ninja-emoji { font-size: 6rem; }
.error-code { font-size: 4rem; }
.error-message { font-size: 1.2rem; }
.description { font-size: 1rem; }
.actions { flex-direction: column; align-items: center; }
.btn { width: 200px; }
}
/* Loading animation for page load */
.container {
animation: page-load 1s ease-out;
}
@keyframes page-load {
from {
opacity: 0;
transform: scale(0.8);
}
to {
opacity: 1;
transform: scale(1);
}
}
</style>
</head>
<body>
<!-- Animated background particles -->
<div class="particles">
<div class="particle"></div>
<div class="particle"></div>
<div class="particle"></div>
<div class="particle"></div>
<div class="particle"></div>
<div class="particle"></div>
<div class="particle"></div>
<div class="particle"></div>
<div class="particle"></div>
</div>
<div class="container">
<!-- Animated ninja emoji -->
<div class="ninja-emoji">🥷</div>
<!-- Glitchy 404 number -->
<div class="error-code glitch">404</div>
<!-- Error message -->
<h1 class="error-message">Oops! Diese Seite ist im Ninja-Modus verschwunden!</h1>
<!-- Description -->
<p class="description">
Die Seite, die du suchst, hat sich wie ein echter Ninja versteckt.<br>
Vielleicht ist sie auf einer geheimen Mission oder hat sich in der Dunkelheit versteckt.
</p>
<!-- Action buttons -->
<div class="actions">
<a href="/" class="btn btn-primary">🏠 Zur Hauptseite</a>
<a href="/dashboard.html" class="btn btn-secondary">📊 Dashboard</a>
</div>
</div>
<script>
// Add some interactive effects
document.addEventListener('mousemove', (e) => {
const particles = document.querySelectorAll('.particle');
const x = e.clientX / window.innerWidth;
const y = e.clientY / window.innerHeight;
particles.forEach((particle, index) => {
const speed = (index + 1) * 0.5;
const xOffset = (x - 0.5) * speed * 20;
const yOffset = (y - 0.5) * speed * 20;
particle.style.transform = `translate(${xOffset}px, ${yOffset}px)`;
});
});
// Add click effect on ninja emoji
document.querySelector('.ninja-emoji').addEventListener('click', () => {
const ninja = document.querySelector('.ninja-emoji');
ninja.style.animation = 'none';
ninja.style.transform = 'scale(1.2) rotate(360deg)';
setTimeout(() => {
ninja.style.animation = 'ninja-bounce 2s ease-in-out infinite';
ninja.style.transform = '';
}, 500);
});
// Add some console easter egg
console.log(`
🥷 NINJA 404 CONSOLE EASTER EGG 🥷
Du hast die geheime Konsole gefunden!
Hier ist ein Ninja-Haiku für dich:
"Versteckte Seite
Wie ein Ninja in der Nacht
Kehrt bald zurück"
- Der NinjaCross Server
`);
</script>
</body>
</html>

254
public/admin-dashboard.html Normal file
View File

@@ -0,0 +1,254 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Dashboard - NinjaCross</title>
<link rel="icon" type="image/x-icon" href="/pictures/favicon.ico">
<link rel="stylesheet" href="/css/admin-dashboard.css">
</head>
<body>
<div class="header">
<h1>🛡️ Admin Dashboard</h1>
<div class="user-info">
<span id="username">Loading...</span>
<span id="accessBadge" class="access-badge">Level ?</span>
<button id="generatorBtn" class="btn btn-success" style="display: none;">
🔧 Lizenzgenerator
</button>
<button id="logoutBtn" class="btn btn-danger">Logout</button>
</div>
</div>
<div class="container">
<!-- Statistiken -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-number" id="totalPlayers">-</div>
<div class="stat-label">Spieler</div>
</div>
<div class="stat-card">
<div class="stat-number" id="totalRuns">-</div>
<div class="stat-label">Läufe</div>
</div>
<div class="stat-card">
<div class="stat-number" id="totalLocations">-</div>
<div class="stat-label">Standorte</div>
</div>
<div class="stat-card">
<div class="stat-number" id="totalAdminUsers">-</div>
<div class="stat-label">Admin-Benutzer</div>
</div>
</div>
<!-- Hauptseiten-Besuche -->
<div class="page-stats-section">
<h2>🏠 Hauptseiten-Besuche</h2>
<div class="page-stats-grid">
<div class="page-stat-card">
<h3>Heute</h3>
<div id="todayStats" class="page-stats-content">Lade...</div>
</div>
<div class="page-stat-card">
<h3>Diese Woche</h3>
<div id="weekStats" class="page-stats-content">Lade...</div>
</div>
<div class="page-stat-card">
<h3>Dieser Monat</h3>
<div id="monthStats" class="page-stats-content">Lade...</div>
</div>
<div class="page-stat-card">
<h3>Gesamt</h3>
<div id="totalStats" class="page-stats-content">Lade...</div>
</div>
</div>
<!-- Verlinkungs-Statistiken -->
<div class="link-stats-section">
<h3>🔗 Account-Verknüpfungen</h3>
<div class="link-stats-grid">
<div class="link-stat-card">
<div class="link-stat-number" id="totalPlayersCount">-</div>
<div class="link-stat-label">Gesamt Spieler</div>
</div>
<div class="link-stat-card">
<div class="link-stat-number" id="linkedPlayersCount">-</div>
<div class="link-stat-label">Verknüpfte Spieler</div>
</div>
<div class="link-stat-card">
<div class="link-stat-number" id="linkPercentage">-</div>
<div class="link-stat-label">Verknüpfungsrate</div>
</div>
</div>
</div>
</div>
<!-- Dashboard Cards -->
<div class="dashboard-grid">
<!-- Benutzer-Verwaltung -->
<div class="card">
<h3><span class="icon">👥</span> Benutzer-Verwaltung</h3>
<p>Verwalte Supabase-Benutzer und deren RFID-Verknüpfungen</p>
<button class="btn" onclick="showUserManagement()">Benutzer anzeigen</button>
</div>
<!-- Spieler-Verwaltung -->
<div class="card">
<h3><span class="icon">🏃</span> Spieler-Verwaltung</h3>
<p>Verwalte Spieler und deren RFID-UIDs</p>
<button class="btn" onclick="showPlayerManagement()">Spieler anzeigen</button>
</div>
<!-- Blacklist-Verwaltung -->
<div class="card">
<h3><span class="icon">🚫</span> Blacklist-Verwaltung</h3>
<p>Verwalte verbotene Namen und Begriffe</p>
<button class="btn" onclick="showBlacklistManagement()">Blacklist verwalten</button>
</div>
<!-- Läufe-Verwaltung -->
<div class="card">
<h3><span class="icon">⏱️</span> Läufe-Verwaltung</h3>
<p>Zeige und lösche Läufe</p>
<button class="btn" onclick="showRunManagement()">Läufe anzeigen</button>
</div>
<!-- Standort-Verwaltung -->
<div class="card">
<h3><span class="icon">📍</span> Standort-Verwaltung</h3>
<p>Verwalte Standorte und deren Koordinaten</p>
<button class="btn" onclick="showLocationManagement()">Standorte anzeigen</button>
</div>
<!-- Admin-Benutzer -->
<div class="card">
<h3><span class="icon">🔐</span> Admin-Benutzer</h3>
<p>Verwalte Admin-Benutzer und Zugriffsrechte</p>
<button class="btn" onclick="showAdminUserManagement()">Admins anzeigen</button>
</div>
<!-- Achievement-Verwaltung -->
<div class="card">
<h3><span class="icon">🏆</span> Achievement-Verwaltung</h3>
<p>Verwalte Achievements und Spieler-Achievements</p>
<button class="btn" onclick="showAchievementManagement()">Achievements verwalten</button>
</div>
<!-- System-Info -->
<div class="card">
<h3><span class="icon">📊</span> System-Informationen</h3>
<p>Server-Status und Systemdaten</p>
<button class="btn" onclick="showSystemInfo()">Info anzeigen</button>
</div>
</div>
<!-- Daten-Anzeige Bereich -->
<div id="dataSection" style="display: none;">
<div class="card">
<h3 id="dataTitle">Daten</h3>
<div class="search-container">
<input type="text" id="searchInput" class="search-input" placeholder="Suchen...">
<button class="btn" onclick="refreshData()">🔄 Aktualisieren</button>
<button class="btn btn-success" onclick="showAddModal()"> Hinzufügen</button>
</div>
<div id="dataContent">
<div class="loading">Lade Daten...</div>
</div>
</div>
</div>
</div>
<!-- Modals -->
<div id="addModal" class="modal">
<div class="modal-content">
<span class="close">&times;</span>
<h3 id="modalTitle">Element hinzufügen</h3>
<div class="message" id="modalMessage"></div>
<form id="addForm">
<div id="formFields"></div>
<button type="submit" class="btn">Speichern</button>
<button type="button" class="btn btn-danger" onclick="closeModal()">Abbrechen</button>
</form>
</div>
</div>
<div id="confirmModal" class="modal">
<div class="modal-content">
<span class="close">&times;</span>
<h3>Bestätigung</h3>
<p id="confirmMessage">Sind Sie sicher?</p>
<button id="confirmYes" class="btn btn-danger">Ja, löschen</button>
<button id="confirmNo" class="btn">Abbrechen</button>
</div>
</div>
<!-- Blacklist Management Modal -->
<div id="blacklistModal" class="modal">
<div class="modal-content" style="max-width: 800px;">
<span class="close" onclick="closeModal('blacklistModal')">&times;</span>
<h3>🚫 Blacklist-Verwaltung</h3>
<div class="message" id="blacklistMessage"></div>
<!-- Test Name Section -->
<div style="border: 1px solid #ddd; padding: 1rem; margin-bottom: 1rem; border-radius: 5px;">
<h4>Name testen</h4>
<div style="display: flex; gap: 1rem; margin-bottom: 1rem;">
<input type="text" id="testFirstname" placeholder="Vorname" style="flex: 1; padding: 0.5rem;">
<input type="text" id="testLastname" placeholder="Nachname" style="flex: 1; padding: 0.5rem;">
<button class="btn" onclick="testNameAgainstBlacklist()">Testen</button>
<button class="btn" onclick="testLevenshteinDetailed()" style="background: #9c27b0; color: white;">Levenshtein Details</button>
</div>
<div id="testResult" style="padding: 0.5rem; border-radius: 3px; display: none;"></div>
</div>
<!-- Add New Entry Section -->
<div style="border: 1px solid #ddd; padding: 1rem; margin-bottom: 1rem; border-radius: 5px;">
<h4>Neuen Eintrag hinzufügen</h4>
<div style="display: flex; gap: 1rem; margin-bottom: 1rem;">
<input type="text" id="newTerm" placeholder="Begriff" style="flex: 1; padding: 0.5rem;">
<select id="newCategory" style="flex: 1; padding: 0.5rem;">
<option value="historical">Historisch belastet</option>
<option value="offensive">Beleidigend/anstößig</option>
<option value="titles">Titel/Berufsbezeichnung</option>
<option value="brands">Markenname</option>
<option value="inappropriate">Unpassend</option>
<option value="racial">Rassistisch/ethnisch</option>
<option value="religious">Religiös beleidigend</option>
<option value="disability">Behinderungsbezogen</option>
<option value="leetspeak">Verschleiert</option>
<option value="cyberbullying">Cyberbullying</option>
<option value="drugs">Drogenbezogen</option>
<option value="violence">Gewalt/Bedrohung</option>
</select>
<button class="btn btn-success" onclick="addToBlacklist()">Hinzufügen</button>
</div>
</div>
<!-- Blacklist Content -->
<div id="blacklistContent">
<div class="loading">Lade Blacklist...</div>
</div>
</div>
</div>
<!-- Footer -->
<footer class="footer">
<div class="footer-content">
<div class="footer-links">
<a href="/impressum.html" class="footer-link">Impressum</a>
<a href="/datenschutz.html" class="footer-link">Datenschutz</a>
<button id="cookie-settings-footer" class="footer-link cookie-settings-btn">Cookie-Einstellungen</button>
</div>
<div class="footer-text">
<p>&copy; 2024 NinjaCross. Alle Rechte vorbehalten.</p>
</div>
</div>
</footer>
<!-- Application JavaScript -->
<script src="/js/cookie-consent.js"></script>
<script src="/js/admin-dashboard.js"></script>
</body>
</html>

55
public/adminlogin.html Normal file
View File

@@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - Lizenzgenerator</title>
<link rel="icon" type="image/x-icon" href="/pictures/favicon.ico">
<link rel="stylesheet" href="/css/adminlogin.css">
</head>
<body>
<div class="login-container">
<h1>🔐 Anmeldung</h1>
<form id="loginForm" onsubmit="handleLogin(event)">
<div class="form-group">
<label for="username">Benutzername</label>
<input type="text" id="username" name="username" placeholder="Ihr Benutzername" required>
</div>
<div class="form-group">
<label for="password">Passwort</label>
<input type="password" id="password" name="password" placeholder="Ihr Passwort" required>
</div>
<button type="submit" class="login-btn" id="loginBtn">
<span id="btn-text">Anmelden</span>
</button>
</form>
<div id="error" class="error"></div>
<div id="success" class="success"></div>
<div class="info-text">
Melden Sie sich an, um auf den Lizenzgenerator zuzugreifen.
</div>
</div>
<!-- Footer -->
<footer class="footer">
<div class="footer-content">
<div class="footer-links">
<a href="/impressum.html" class="footer-link">Impressum</a>
<a href="/datenschutz.html" class="footer-link">Datenschutz</a>
<button id="cookie-settings-footer" class="footer-link cookie-settings-btn">Cookie-Einstellungen</button>
</div>
<div class="footer-text">
<p>&copy; 2024 NinjaCross. Alle Rechte vorbehalten.</p>
</div>
</div>
</footer>
<script src="/js/cookie-consent.js"></script>
<script src="/js/adminlogin.js"></script>
</body>
</html>

244
public/agb.html Normal file
View File

@@ -0,0 +1,244 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Allgemeine Geschäftsbedingungen | NinjaCross</title>
<link rel="stylesheet" href="css/dashboard.css">
<style>
.agb-container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
background: #1e293b;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
margin-top: 20px;
color: #e2e8f0;
}
.agb-header {
text-align: center;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 2px solid #334155;
}
.agb-header h1 {
color: #00d4ff;
margin-bottom: 10px;
}
.agb-header .subtitle {
color: #94a3b8;
font-size: 16px;
}
.agb-content {
line-height: 1.6;
color: #e2e8f0;
}
.agb-section {
margin-bottom: 25px;
}
.agb-section h2 {
color: #00d4ff;
font-size: 20px;
margin-bottom: 15px;
padding-bottom: 5px;
border-bottom: 1px solid #334155;
}
.agb-section h3 {
color: #e2e8f0;
font-size: 16px;
margin-bottom: 10px;
margin-top: 20px;
}
.agb-section p {
margin-bottom: 12px;
}
.agb-section ul {
margin-left: 20px;
margin-bottom: 15px;
}
.agb-section li {
margin-bottom: 8px;
}
.highlight-box {
background: #0f172a;
border-left: 4px solid #00d4ff;
padding: 15px;
margin: 20px 0;
border-radius: 0 5px 5px 0;
}
.warning-box {
background: #451a03;
border-left: 4px solid #fbbf24;
padding: 15px;
margin: 20px 0;
border-radius: 0 5px 5px 0;
}
.back-button {
display: inline-block;
background: #00d4ff;
color: #0f172a;
padding: 12px 24px;
text-decoration: none;
border-radius: 5px;
margin-top: 30px;
transition: background-color 0.3s;
font-weight: bold;
}
.back-button:hover {
background: #0891b2;
}
.last-updated {
text-align: center;
color: #94a3b8;
font-style: italic;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #334155;
}
</style>
</head>
<body style="background: #0f172a; min-height: 100vh; padding: 20px;">
<div class="agb-container">
<div class="agb-header">
<h1>Allgemeine Geschäftsbedingungen</h1>
<p class="subtitle">NinjaCross Parkour System</p>
</div>
<div class="agb-content">
<div class="highlight-box">
<strong>Wichtig:</strong> Durch die Nutzung des NinjaCross Systems stimmen Sie zu, dass Ihre Daten
(Name, Zeiten, Standorte) im öffentlichen Leaderboard angezeigt werden können.
</div>
<div class="agb-section">
<h2>1. Geltungsbereich</h2>
<p>Diese Allgemeinen Geschäftsbedingungen (AGB) gelten für die Nutzung des NinjaCross Parkour Systems
im Schwimmbad. Mit der Registrierung und Nutzung des Systems erkennen Sie diese AGB als verbindlich an.</p>
</div>
<div class="agb-section">
<h2>2. Datenverarbeitung und Datenschutz</h2>
<h3>2.1 Erhebung von Daten</h3>
<p>Wir erheben folgende personenbezogene Daten:</p>
<ul>
<li>Vor- und Nachname</li>
<li>Geburtsdatum (zur Altersberechnung)</li>
<li>RFID-Kartennummer</li>
<li>Laufzeiten und Standortdaten</li>
<li>Zeitstempel der Aktivitäten</li>
</ul>
<h3>2.2 Verwendung der Daten</h3>
<p>Ihre Daten werden für folgende Zwecke verwendet:</p>
<ul>
<li><strong>Leaderboard-Anzeige:</strong> Name, Zeiten und Standorte werden im öffentlichen Leaderboard angezeigt</li>
<li><strong>Leistungsauswertung:</strong> Erfassung und Bewertung Ihrer Parkour-Zeiten</li>
<li><strong>System-Funktionalität:</strong> Zuordnung von Zeiten zu Ihrem Profil über RFID-Karten</li>
<li><strong>Statistiken:</strong> Anonymisierte Auswertungen für Systemverbesserungen</li>
</ul>
<div class="warning-box">
<strong>Wichtiger Hinweis:</strong> Durch die Annahme dieser AGB stimmen Sie ausdrücklich zu,
dass Ihr Name und Ihre Laufzeiten im öffentlichen Leaderboard sichtbar sind.
</div>
</div>
<div class="agb-section">
<h2>3. Leaderboard und Öffentlichkeit</h2>
<p>Das NinjaCross System verfügt über ein öffentlich zugängliches Leaderboard, das folgende Informationen anzeigt:</p>
<ul>
<li>Vollständiger Name der Teilnehmer</li>
<li>Erreichte Laufzeiten</li>
<li>Standort der Aktivität</li>
<li>Datum und Uhrzeit der Aktivität</li>
</ul>
<p><strong>Durch die Nutzung des Systems erklären Sie sich damit einverstanden, dass diese Daten öffentlich angezeigt werden.</strong></p>
</div>
<div class="agb-section">
<h2>4. Ihre Rechte</h2>
<h3>4.1 Recht auf Auskunft</h3>
<p>Sie haben das Recht, Auskunft über die zu Ihrer Person gespeicherten Daten zu verlangen.</p>
<h3>4.2 Recht auf Löschung</h3>
<p>Sie können jederzeit die Löschung Ihrer Daten und Ihres Profils beantragen.</p>
<h3>4.3 Recht auf Widerspruch</h3>
<p>Sie können der Verarbeitung Ihrer Daten für das Leaderboard widersprechen.
In diesem Fall werden Ihre Daten aus dem öffentlichen Leaderboard entfernt,
aber weiterhin für die Systemfunktionalität verwendet.</p>
</div>
<div class="agb-section">
<h2>5. Haftung und Verantwortung</h2>
<p>Die Teilnahme am NinjaCross System erfolgt auf eigene Gefahr. Wir haften nicht für:</p>
<ul>
<li>Verletzungen während der Nutzung der Parkour-Anlage</li>
<li>Verlust oder Diebstahl der RFID-Karte</li>
<li>Technische Ausfälle des Systems</li>
</ul>
</div>
<div class="agb-section">
<h2>6. Systemregeln</h2>
<p>Bei der Nutzung des Systems sind folgende Regeln zu beachten:</p>
<ul>
<li>Keine Manipulation der Zeiterfassung</li>
<li>Respektvoller Umgang mit anderen Teilnehmern</li>
<li>Beachtung der Sicherheitshinweise der Anlage</li>
<li>Keine Verwendung falscher Identitäten</li>
</ul>
</div>
<div class="agb-section">
<h2>7. Änderungen der AGB</h2>
<p>Wir behalten uns vor, diese AGB zu ändern. Wesentliche Änderungen werden Ihnen
mitgeteilt und erfordern Ihre erneute Zustimmung.</p>
</div>
<div class="agb-section">
<h2>8. Kontakt</h2>
<p>Bei Fragen zu diesen AGB oder zum Datenschutz wenden Sie sich an:</p>
<p>
<strong>NinjaCross Team</strong><br>
Schwimmbad Ulm<br>
E-Mail: info@ninjacross.de<br>
Telefon: 0731-123456
</p>
</div>
</div>
<div style="text-align: center;">
<a href="javascript:history.back()" class="back-button">Zurück</a>
</div>
<div class="last-updated">
<p>Stand: September 2024</p>
<p>Diese AGB sind Teil der Registrierung und gelten ab dem Zeitpunkt der Zustimmung.</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,753 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', sans-serif;
background: #0a0a0f;
color: #ffffff;
min-height: 100vh;
background-image:
radial-gradient(circle at 20% 80%, #1a1a2e 0%, transparent 50%),
radial-gradient(circle at 80% 20%, #16213e 0%, transparent 50%),
radial-gradient(circle at 40% 40%, #0f3460 0%, transparent 50%);
}
.header {
background: rgba(26, 26, 46, 0.95);
padding: 20px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.header h1 {
color: #ffffff;
font-size: 2em;
}
.user-info {
display: flex;
align-items: center;
gap: 15px;
}
.access-badge {
background: #4CAF50;
color: white;
padding: 5px 10px;
border-radius: 15px;
font-size: 0.8em;
font-weight: bold;
}
.access-badge.level-1 {
background: #ff9800;
}
.access-badge.level-2 {
background: #4CAF50;
}
.btn {
background: #2196F3;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
text-decoration: none;
display: inline-block;
font-size: 0.9em;
transition: all 0.3s ease;
}
.btn:hover {
background: #1976D2;
transform: translateY(-2px);
}
.btn-danger {
background: #f44336;
}
.btn-danger:hover {
background: #d32f2f;
}
.btn-warning {
background: #ff9800;
}
.btn-warning:hover {
background: #f57c00;
}
.btn-success {
background: #4CAF50;
}
.btn-success:hover {
background: #388E3C;
}
.container {
max-width: 1200px;
margin: 30px auto;
padding: 0 20px;
}
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.card {
background: rgba(26, 26, 46, 0.95);
border-radius: 10px;
padding: 20px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
transition: transform 0.3s ease;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.card:hover {
transform: translateY(-5px);
}
.card h3 {
color: #00d4ff;
margin-bottom: 15px;
display: flex;
align-items: center;
gap: 10px;
}
.icon {
font-size: 1.5em;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-bottom: 30px;
}
.stat-card {
background: rgba(26, 26, 46, 0.95);
padding: 20px;
border-radius: 10px;
text-align: center;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.stat-number {
font-size: 2.5em;
font-weight: bold;
color: #00d4ff;
margin-bottom: 5px;
}
.stat-label {
color: #8892b0;
font-size: 0.9em;
}
.data-table {
width: 100%;
border-collapse: collapse;
background: rgba(26, 26, 46, 0.95);
border-radius: 10px;
overflow: hidden;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.data-table th,
.data-table td {
padding: 12px;
text-align: left;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
color: #ffffff;
}
.data-table th {
background: rgba(255, 255, 255, 0.1);
font-weight: bold;
color: #ffffff;
}
.data-table tr:hover {
background: rgba(255, 255, 255, 0.1);
}
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
}
.modal-content {
background-color: rgba(26, 26, 46, 0.95);
margin: 5% auto;
padding: 20px;
border-radius: 10px;
width: 90%;
max-width: 500px;
max-height: 85vh;
overflow-y: auto;
position: relative;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.close {
color: #8892b0;
float: right;
font-size: 28px;
font-weight: bold;
cursor: pointer;
position: absolute;
right: 15px;
top: 10px;
}
.close:hover {
color: #ffffff;
}
/* Blacklist Modal specific styles */
#blacklistModal .modal-content {
max-width: 800px;
max-height: 90vh;
overflow-y: auto;
}
/* Smooth scrolling for modal content */
.modal-content {
scroll-behavior: smooth;
}
/* Custom scrollbar for modal content */
.modal-content::-webkit-scrollbar {
width: 8px;
}
.modal-content::-webkit-scrollbar-track {
background: rgba(30, 41, 59, 0.3);
border-radius: 4px;
}
.modal-content::-webkit-scrollbar-thumb {
background: rgba(100, 116, 139, 0.6);
border-radius: 4px;
}
.modal-content::-webkit-scrollbar-thumb:hover {
background: rgba(100, 116, 139, 0.8);
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: bold;
color: #333;
}
.form-group input,
.form-group select {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 5px;
font-size: 1em;
}
.message {
padding: 10px;
border-radius: 5px;
margin-bottom: 15px;
display: none;
}
.message.success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.message.error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.loading {
text-align: center;
padding: 20px;
color: #666;
}
.no-data {
text-align: center;
padding: 40px;
color: #999;
font-style: italic;
}
.action-buttons {
display: flex;
gap: 5px;
}
.btn-small {
padding: 5px 10px;
font-size: 0.8em;
}
.table-container {
max-height: 400px;
overflow-y: auto;
margin-top: 15px;
}
.search-container {
margin-bottom: 20px;
display: flex;
gap: 10px;
align-items: center;
}
.search-input {
flex: 1;
padding: 10px;
border: 1px solid #ddd;
border-radius: 5px;
}
@media (max-width: 768px) {
.header {
flex-direction: column;
gap: 15px;
text-align: center;
}
.dashboard-grid {
grid-template-columns: 1fr;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.search-container {
flex-direction: column;
align-items: stretch;
}
.action-buttons {
flex-direction: column;
}
.modal-content {
margin: 5% auto;
width: 95%;
}
.page-stats-grid {
grid-template-columns: 1fr;
}
.link-stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}
/* Page Statistics Styles */
.page-stats-section {
background: rgba(26, 26, 46, 0.95);
border-radius: 10px;
padding: 25px;
margin: 20px 0;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.page-stats-section h2 {
color: #ffffff;
margin-bottom: 20px;
font-size: 1.5em;
}
.page-stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.page-stat-card {
background: rgba(26, 26, 46, 0.95);
border-radius: 8px;
padding: 20px;
border-left: 4px solid #00d4ff;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.page-stat-card h3 {
color: #ffffff;
margin-bottom: 15px;
font-size: 1.2em;
}
.page-stats-content {
font-size: 0.9em;
line-height: 1.6;
background: rgba(26, 26, 46, 0.95);
border-radius: 8px;
padding: 15px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.page-stats-content .page-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 15px;
margin: 8px 0;
background: rgba(255, 255, 255, 0.05);
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.1);
transition: background-color 0.3s ease;
}
.page-stats-content .page-item:last-child {
border-bottom: none;
}
.page-stats-content .page-item:hover {
background: rgba(255, 255, 255, 0.1);
}
.page-stats-content .page-name {
font-weight: 500;
color: #ffffff;
}
.page-stats-content .page-count {
background: #00d4ff;
color: #00d4ff;
padding: 4px 8px;
border-radius: 12px;
font-size: 0.8em;
font-weight: bold;
}
/* Link Statistics Styles */
.link-stats-section {
background: #1a1a2f;
border-radius: 8px;
padding: 20px;
margin-top: 20px;
}
.link-stats-section h3 {
color: #00d4ff;
margin-bottom: 15px;
font-size: 1.3em;
}
.link-stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 20px;
}
.link-stat-card {
text-align: center;
background: rgba(26, 26, 46, 0.95);
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.link-stat-number {
font-size: 2em;
font-weight: bold;
color: #00d4ff;
margin-bottom: 8px;
}
.link-stat-label {
color: #8892b0;
font-size: 0.9em;
font-weight: 500;
}
@media (max-width: 480px) {
.stats-grid {
grid-template-columns: 1fr;
}
.container {
padding: 0 10px;
}
.card {
padding: 15px;
}
}
/* Footer Styles */
.footer {
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
border-top: 1px solid #2a2a3e;
margin-top: 3rem;
padding: 2rem 0;
}
.footer-content {
max-width: 1200px;
margin: 0 auto;
padding: 0 2rem;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
}
.footer-links {
display: flex;
gap: 2rem;
align-items: center;
}
.footer-link {
color: #8892b0;
text-decoration: none;
font-size: 0.9rem;
transition: color 0.3s ease;
background: none;
border: none;
cursor: pointer;
font-family: inherit;
}
.footer-link:hover {
color: #00d4ff;
}
.cookie-settings-btn {
background: none !important;
border: none !important;
padding: 0 !important;
font-size: 0.9rem !important;
}
.footer-text {
color: #6b7280;
font-size: 0.85rem;
}
.footer-text p {
margin: 0;
}
@media (max-width: 768px) {
.footer-content {
flex-direction: column;
text-align: center;
gap: 1.5rem;
}
.footer-links {
flex-wrap: wrap;
justify-content: center;
gap: 1.5rem;
}
}
/* Achievement Management Styles */
.achievement-controls {
display: flex;
gap: 10px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.status-badge {
padding: 4px 8px;
border-radius: 12px;
font-size: 0.8em;
font-weight: 500;
}
.status-badge.active {
background: #4ade80;
color: #000;
}
.status-badge.inactive {
background: #6b7280;
color: #fff;
}
.progress-bar {
position: relative;
background: rgba(255, 255, 255, 0.1);
border-radius: 10px;
height: 20px;
overflow: hidden;
min-width: 100px;
}
.progress-fill {
background: linear-gradient(90deg, #4ade80, #22c55e);
height: 100%;
transition: width 0.3s ease;
}
.progress-text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 0.8em;
font-weight: 500;
color: #fff;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
}
.player-achievements {
max-height: 70vh;
overflow-y: auto;
}
.achievement-stats {
display: flex;
gap: 20px;
margin-bottom: 20px;
padding: 15px;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.stat-item {
font-size: 0.9em;
}
.achievements-list {
display: grid;
gap: 15px;
}
.achievement-item {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 15px;
transition: all 0.3s ease;
}
.achievement-item.completed {
border-color: #4ade80;
background: rgba(74, 222, 128, 0.1);
}
.achievement-item.not-completed {
border-color: #6b7280;
background: rgba(107, 114, 128, 0.1);
}
.achievement-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.achievement-icon {
font-size: 1.5em;
}
.achievement-name {
font-weight: 600;
flex: 1;
}
.achievement-status {
font-size: 1.2em;
}
.achievement-details p {
margin-bottom: 10px;
color: rgba(255, 255, 255, 0.8);
line-height: 1.4;
}
.achievement-meta {
display: flex;
gap: 15px;
margin-bottom: 10px;
flex-wrap: wrap;
}
.achievement-meta span {
font-size: 0.8em;
color: rgba(255, 255, 255, 0.6);
background: rgba(255, 255, 255, 0.1);
padding: 2px 8px;
border-radius: 4px;
}
.achievement-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
}
.achievement-actions .btn {
padding: 6px 12px;
font-size: 0.8em;
}
@media (max-width: 768px) {
.achievement-controls {
flex-direction: column;
}
.achievement-stats {
flex-direction: column;
gap: 10px;
}
.achievement-meta {
flex-direction: column;
gap: 5px;
}
.achievement-actions {
justify-content: center;
}
}

294
public/css/adminlogin.css Normal file
View File

@@ -0,0 +1,294 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
height: 100%;
margin: 0;
padding: 0;
}
body {
font-family: 'Inter', sans-serif;
background: #0a0a0f;
color: #ffffff;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
background-image:
radial-gradient(circle at 20% 80%, #1a1a2e 0%, transparent 50%),
radial-gradient(circle at 80% 20%, #16213e 0%, transparent 50%),
radial-gradient(circle at 40% 40%, #0f3460 0%, transparent 50%);
}
.login-container {
background: rgba(26, 26, 46, 0.95);
backdrop-filter: blur(10px);
border-radius: 20px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
padding: 40px;
max-width: 400px;
width: 100%;
position: relative;
overflow: hidden;
flex-grow: 1;
display: flex;
flex-direction: column;
justify-content: center;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.login-container::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(90deg, #667eea, #764ba2, #f093fb, #f5576c);
background-size: 300% 100%;
animation: gradientShift 3s ease infinite;
}
@keyframes gradientShift {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
h1 {
text-align: center;
color: #ffffff;
margin-bottom: 30px;
font-size: 2em;
font-weight: 300;
letter-spacing: -1px;
}
.form-group {
margin-bottom: 25px;
position: relative;
}
label {
display: block;
margin-bottom: 8px;
color: #555;
font-weight: 500;
font-size: 0.95em;
}
input {
width: 100%;
padding: 15px 20px;
border: 2px solid #e0e0e0;
border-radius: 12px;
font-size: 1em;
transition: all 0.3s ease;
background: #fafafa;
font-family: inherit;
}
input:focus {
outline: none;
border-color: #667eea;
background: white;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
transform: translateY(-2px);
}
input:hover {
border-color: #ccc;
}
.login-btn {
width: 100%;
padding: 18px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 12px;
font-size: 1.1em;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
margin-top: 10px;
position: relative;
overflow: hidden;
}
.login-btn:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px rgba(102, 126, 234, 0.3);
}
.login-btn:active {
transform: translateY(0);
}
.login-btn:disabled {
opacity: 0.7;
cursor: not-allowed;
transform: none;
}
.error {
background: #ffebee;
color: #c62828;
padding: 15px;
border-radius: 8px;
margin-top: 15px;
border-left: 4px solid #f44336;
font-size: 0.9em;
opacity: 0;
transform: translateY(-10px);
transition: all 0.3s ease;
}
.error.show {
opacity: 1;
transform: translateY(0);
}
.success {
background: #e8f5e8;
color: #2e7d32;
padding: 15px;
border-radius: 8px;
margin-top: 15px;
border-left: 4px solid #4caf50;
font-size: 0.9em;
opacity: 0;
transform: translateY(-10px);
transition: all 0.3s ease;
}
.success.show {
opacity: 1;
transform: translateY(0);
}
.loading {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid rgba(255, 255, 255, .3);
border-radius: 50%;
border-top-color: #fff;
animation: spin 1s ease-in-out infinite;
margin-right: 10px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.info-text {
text-align: center;
color: #666;
font-size: 0.85em;
margin-top: 20px;
line-height: 1.5;
}
@media (max-width: 480px) {
.login-container {
padding: 30px 20px;
margin: 10px;
}
h1 {
font-size: 1.6em;
}
}
/* Footer Styles */
.footer {
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
border-top: 1px solid #2a2a3e;
margin-top: auto;
padding: 2rem 0;
position: relative;
bottom: 0;
width: 100%;
}
.footer-content {
max-width: 1200px;
margin: 0 auto;
padding: 0 2rem;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
}
.footer-links {
display: flex;
gap: 2rem;
align-items: center;
}
.footer-link {
color: #8892b0;
text-decoration: none;
font-size: 0.9rem;
transition: color 0.3s ease;
background: none;
border: none;
cursor: pointer;
font-family: inherit;
}
.footer-link:hover {
color: #00d4ff;
}
.cookie-settings-btn {
background: none !important;
border: none !important;
padding: 0 !important;
font-size: 0.9rem !important;
}
.footer-text {
color: #6b7280;
font-size: 0.85rem;
}
.footer-text p {
margin: 0;
}
@media (max-width: 768px) {
.footer-content {
flex-direction: column;
text-align: center;
gap: 1.5rem;
}
.footer-links {
flex-wrap: wrap;
justify-content: center;
gap: 1.5rem;
}
}

1981
public/css/dashboard.css Normal file

File diff suppressed because it is too large Load Diff

500
public/css/generator.css Normal file
View File

@@ -0,0 +1,500 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
height: 100%;
margin: 0;
padding: 0;
}
body {
font-family: 'Inter', sans-serif;
background: #0a0a0f;
color: #ffffff;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
padding: 20px;
background-image:
radial-gradient(circle at 20% 80%, #1a1a2e 0%, transparent 50%),
radial-gradient(circle at 80% 20%, #16213e 0%, transparent 50%),
radial-gradient(circle at 40% 40%, #0f3460 0%, transparent 50%);
}
.container {
background: rgba(26, 26, 46, 0.95);
backdrop-filter: blur(10px);
border-radius: 20px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
padding: 40px;
max-width: 700px;
width: 100%;
position: relative;
overflow: visible;
margin-bottom: 20px;
display: flex;
flex-direction: column;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.container::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(90deg, #667eea, #764ba2, #f093fb, #f5576c);
background-size: 300% 100%;
animation: gradientShift 3s ease infinite;
}
@keyframes gradientShift {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
h1 {
text-align: center;
color: #ffffff;
margin-bottom: 30px;
font-size: 2.2em;
font-weight: 300;
letter-spacing: -1px;
}
.form-group {
margin-bottom: 25px;
position: relative;
}
label {
display: block;
margin-bottom: 8px;
color: #555;
font-weight: 500;
font-size: 0.95em;
}
input,
textarea {
width: 100%;
padding: 15px 20px;
border: 2px solid #e0e0e0;
border-radius: 12px;
font-size: 1em;
transition: all 0.3s ease;
background: #fafafa;
font-family: inherit;
}
textarea {
resize: vertical;
min-height: 80px;
}
input:focus,
textarea:focus {
outline: none;
border-color: #667eea;
background: white;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
transform: translateY(-2px);
}
input:hover,
textarea:hover {
border-color: #ccc;
}
.db-config {
background: linear-gradient(135deg, #f8f9ff 0%, #e8f2ff 100%);
border: 2px solid #e3f2fd;
border-radius: 12px;
padding: 20px;
margin-bottom: 25px;
opacity: 0;
transform: translateY(-20px);
transition: all 0.4s ease;
max-height: 0;
overflow: hidden;
}
.db-config.show {
opacity: 1;
transform: translateY(0);
max-height: 1000px;
}
.db-config h3 {
color: #1565c0;
margin-bottom: 15px;
font-size: 1.1em;
}
.tier-notice {
background: linear-gradient(135deg, #fff3e0 0%, #ffe0b2 100%);
border: 2px solid #ffcc02;
border-radius: 8px;
padding: 12px;
margin-top: 10px;
font-size: 0.9em;
color: #f57c00;
text-align: center;
}
.generate-btn {
width: 100%;
padding: 18px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 12px;
font-size: 1.1em;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
margin-top: 10px;
position: relative;
overflow: hidden;
}
.generate-btn:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px rgba(102, 126, 234, 0.3);
}
.generate-btn:active {
transform: translateY(0);
}
.result-section {
margin-top: 30px;
opacity: 0;
transform: translateY(20px);
transition: all 0.4s ease;
}
.result-section.show {
opacity: 1;
transform: translateY(0);
}
.license-output {
background: linear-gradient(135deg, #f8f9ff 0%, #e8f2ff 100%);
border: 2px solid #e3f2fd;
border-radius: 12px;
padding: 20px;
font-family: 'Courier New', monospace;
font-size: 0.9em;
word-break: break-all;
color: #1565c0;
position: relative;
}
.license-label {
font-family: 'Segoe UI', sans-serif;
font-size: 0.85em;
color: #666;
margin-bottom: 8px;
font-weight: 500;
}
.copy-btn {
width: 100%;
padding: 12px;
background: #4caf50;
color: white;
border: none;
border-radius: 8px;
font-size: 0.95em;
font-weight: 500;
cursor: pointer;
margin-top: 15px;
transition: all 0.3s ease;
}
.copy-btn:hover {
background: #45a049;
transform: translateY(-1px);
}
.copy-btn.copied {
background: #2196f3;
animation: pulse 0.6s;
}
@keyframes pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
100% {
transform: scale(1);
}
}
.success {
background: #e8f5e8;
color: #2e7d32;
padding: 15px;
border-radius: 8px;
margin-top: 15px;
border-left: 4px solid #4caf50;
font-size: 0.9em;
opacity: 0;
transform: translateY(-10px);
transition: all 0.3s ease;
}
.success.show {
opacity: 1;
transform: translateY(0);
}
.error {
background: #ffebee;
color: #c62828;
padding: 15px;
border-radius: 8px;
margin-top: 15px;
border-left: 4px solid #f44336;
font-size: 0.9em;
opacity: 0;
transform: translateY(-10px);
transition: all 0.3s ease;
}
.error.show {
opacity: 1;
transform: translateY(0);
}
.info-text {
text-align: center;
color: #666;
font-size: 0.85em;
margin-top: 20px;
line-height: 1.5;
}
@media (max-width: 480px) {
.container {
padding: 30px 20px;
margin: 10px;
}
h1 {
font-size: 1.8em;
}
}
.loading {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid rgba(255, 255, 255, .3);
border-radius: 50%;
border-top-color: #fff;
animation: spin 1s ease-in-out infinite;
margin-right: 10px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Standortsuche Styles */
.coordinates-display {
animation: slideDown 0.4s ease;
}
.map-container {
animation: slideDown 0.4s ease;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
#mapFrame iframe {
border-radius: 10px;
}
.coordinates-display h4 {
display: flex;
align-items: center;
gap: 8px;
}
.coordinates-display strong {
color: #2e7d32;
}
/* Verbesserte Standortsuche Layouts */
.location-search-container {
display: flex;
gap: 10px;
align-items: stretch;
}
.location-search-container input {
flex: 1;
min-width: 0;
}
.location-search-container button {
white-space: nowrap;
min-width: 120px;
}
/* Responsive Design für Standortsuche */
@media (max-width: 600px) {
.location-search-container {
flex-direction: column;
gap: 15px;
}
.location-search-container button {
min-width: auto;
width: 100%;
}
.coordinates-display .flex-container {
flex-direction: column;
gap: 10px;
}
}
/* Interaktive Karte Styles */
#interactiveMap {
position: relative;
}
#map {
border-radius: 10px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.leaflet-container {
border-radius: 10px;
}
.leaflet-control-zoom {
border: none;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.leaflet-control-zoom a {
background: white;
color: #333;
border: 1px solid #ddd;
}
.leaflet-control-zoom a:hover {
background: #f8f9fa;
}
/* Footer Styles */
.footer {
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
border-top: 1px solid #2a2a3e;
margin-top: auto;
padding: 2rem 0;
position: relative;
bottom: 0;
width: 100%;
}
.footer-content {
max-width: 1200px;
margin: 0 auto;
padding: 0 2rem;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
}
.footer-links {
display: flex;
gap: 2rem;
align-items: center;
}
.footer-link {
color: #8892b0;
text-decoration: none;
font-size: 0.9rem;
transition: color 0.3s ease;
background: none;
border: none;
cursor: pointer;
font-family: inherit;
}
.footer-link:hover {
color: #00d4ff;
}
.cookie-settings-btn {
background: none !important;
border: none !important;
padding: 0 !important;
font-size: 0.9rem !important;
}
.footer-text {
color: #6b7280;
font-size: 0.85rem;
}
.footer-text p {
margin: 0;
}
@media (max-width: 768px) {
.footer-content {
flex-direction: column;
text-align: center;
gap: 1.5rem;
}
.footer-links {
flex-wrap: wrap;
justify-content: center;
gap: 1.5rem;
}
}

1289
public/css/index.css Normal file

File diff suppressed because it is too large Load Diff

486
public/css/login.css Normal file
View File

@@ -0,0 +1,486 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', sans-serif;
background: #0a0a0f;
color: #ffffff;
min-height: 100vh;
background-image:
radial-gradient(circle at 20% 80%, #1a1a2e 0%, transparent 50%),
radial-gradient(circle at 80% 20%, #16213e 0%, transparent 50%),
radial-gradient(circle at 40% 40%, #0f3460 0%, transparent 50%);
position: relative;
display: flex;
flex-direction: column;
}
.main-content {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem 0;
}
.back-button {
position: fixed;
top: 20px;
right: 20px;
background: rgba(30, 41, 59, 0.95);
backdrop-filter: blur(20px);
border: 1px solid rgba(51, 65, 85, 0.3);
border-radius: 12px;
padding: 12px 20px;
color: #00d4ff;
text-decoration: none;
font-weight: 600;
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 0.5px;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
z-index: 1000;
}
.back-button:hover {
background: #00d4ff;
color: #ffffff;
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 212, 255, 0.3);
}
.back-button::before {
content: "← ";
margin-right: 5px;
}
.container {
background: rgba(30, 41, 59, 0.95);
backdrop-filter: blur(20px);
border: 1px solid rgba(51, 65, 85, 0.3);
padding: 2.5rem;
border-radius: 1.5rem;
box-shadow:
0 25px 50px rgba(0, 0, 0, 0.3),
0 0 0 1px rgba(0, 212, 255, 0.1);
width: 100%;
max-width: 420px;
}
.logo {
text-align: center;
margin-bottom: 2rem;
}
.logo h1 {
font-size: 2.5rem;
font-weight: 700;
background: linear-gradient(135deg, #00d4ff, #ff6b35, #ffd700);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 0.5rem;
letter-spacing: -0.02em;
}
.logo p {
color: #94a3b8;
margin-top: 0.5rem;
font-size: 1rem;
font-weight: 400;
}
.form-container {
display: none;
}
.form-container.active {
display: block;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
color: #e2e8f0;
font-weight: 500;
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.form-group input {
width: 100%;
padding: 1rem;
background: #1e293b;
border: 2px solid #334155;
border-radius: 0.75rem;
color: #ffffff;
font-size: 1rem;
font-family: inherit;
transition: all 0.2s ease;
}
.form-group input:focus {
outline: none;
border-color: #00d4ff;
box-shadow: 0 0 0 3px rgba(0, 212, 255, 0.1);
}
.form-group input::placeholder {
color: #64748b;
}
.btn {
width: 100%;
padding: 1rem;
border: none;
border-radius: 0.75rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
margin-bottom: 1rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.btn-primary {
background: linear-gradient(135deg, #00d4ff, #0891b2);
color: white;
box-shadow: 0 4px 12px rgba(0, 212, 255, 0.3);
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 212, 255, 0.4);
}
.btn-secondary {
background: transparent;
color: #00d4ff;
border: 2px solid #00d4ff;
}
.btn-secondary:hover {
background: #00d4ff;
color: #0a0a0f;
}
.toggle-form {
text-align: center;
margin-top: 1rem;
}
.toggle-form button {
background: none;
border: none;
color: #00d4ff;
cursor: pointer;
text-decoration: underline;
font-size: 0.9rem;
transition: color 0.2s ease;
}
.toggle-form button:hover {
color: #0891b2;
}
.message {
padding: 1rem;
border-radius: 0.75rem;
margin-bottom: 1rem;
text-align: center;
font-weight: 500;
border: 1px solid;
}
.message.success {
background: rgba(34, 197, 94, 0.1);
color: #22c55e;
border-color: rgba(34, 197, 94, 0.3);
}
.message.error {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
border-color: rgba(239, 68, 68, 0.3);
}
.password-reset-container {
display: none;
margin-top: 1rem;
padding: 1rem;
background: rgba(239, 68, 68, 0.05);
border: 1px solid rgba(239, 68, 68, 0.2);
border-radius: 0.75rem;
text-align: center;
}
.password-reset-container.active {
display: block;
}
.password-reset-container p {
color: #ef4444;
margin-bottom: 1rem;
font-size: 0.9rem;
}
.btn-reset {
background: linear-gradient(135deg, #ef4444, #dc2626);
color: white;
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
}
.btn-reset:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(239, 68, 68, 0.4);
}
.loading {
display: none;
text-align: center;
color: #94a3b8;
}
.loading.active {
display: block;
}
.spinner {
border: 3px solid #334155;
border-top: 3px solid #00d4ff;
border-radius: 50%;
width: 30px;
height: 30px;
animation: spin 1s linear infinite;
margin: 0 auto 1rem;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Responsive Design */
@media (max-width: 768px) {
.container {
margin: 1rem;
padding: 2rem;
max-width: none;
}
.logo h1 {
font-size: 2rem;
}
.form-group input {
padding: 0.875rem;
}
.btn {
padding: 0.875rem;
}
}
@media (max-width: 480px) {
.container {
margin: 0.5rem;
padding: 1.5rem;
}
.logo h1 {
font-size: 1.75rem;
}
.logo p {
font-size: 0.875rem;
}
.back-button {
top: 15px;
right: 15px;
padding: 10px 15px;
font-size: 0.8rem;
}
}
/* OAuth Container */
.oauth-container {
margin-bottom: 20px;
}
.btn-google {
width: 100%;
background: white;
color: #333;
border: 1px solid #dadce0;
padding: 12px 16px;
border-radius: 8px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
font-size: 0.95rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.btn-google:hover {
background: #f8f9fa;
border-color: #c1c7cd;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.btn-google:active {
transform: translateY(0);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.btn-google svg {
flex-shrink: 0;
}
.btn-discord {
width: 100%;
background: #5865F2;
color: white;
border: 1px solid #4752C4;
padding: 12px 16px;
border-radius: 8px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
font-size: 0.95rem;
box-shadow: 0 1px 3px rgba(88, 101, 242, 0.3);
margin-top: 10px;
}
.btn-discord:hover {
background: #4752C4;
border-color: #3C45A5;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(88, 101, 242, 0.4);
}
.btn-discord:active {
transform: translateY(0);
box-shadow: 0 1px 3px rgba(88, 101, 242, 0.3);
}
.btn-discord svg {
flex-shrink: 0;
}
/* Divider */
.divider {
position: relative;
text-align: center;
margin: 20px 0;
color: #64748b;
font-size: 0.9rem;
}
.divider::before {
content: '';
position: absolute;
top: 50%;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(to right, transparent, #334155, transparent);
}
.divider span {
background: #0a0a0f;
padding: 0 16px;
position: relative;
z-index: 1;
}
/* Footer Styles */
.footer {
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
border-top: 1px solid #2a2a3e;
margin-top: 3rem;
padding: 2rem 0;
}
.footer-content {
max-width: 1200px;
margin: 0 auto;
padding: 0 2rem;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
}
.footer-links {
display: flex;
gap: 2rem;
align-items: center;
}
.footer-link {
color: #8892b0;
text-decoration: none;
font-size: 0.9rem;
transition: color 0.3s ease;
background: none;
border: none;
cursor: pointer;
font-family: inherit;
}
.footer-link:hover {
color: #00d4ff;
}
.cookie-settings-btn {
background: none !important;
border: none !important;
padding: 0 !important;
font-size: 0.9rem !important;
}
.footer-text {
color: #6b7280;
font-size: 0.85rem;
}
.footer-text p {
margin: 0;
}
@media (max-width: 768px) {
.footer-content {
flex-direction: column;
text-align: center;
gap: 1.5rem;
}
.footer-links {
flex-wrap: wrap;
justify-content: center;
gap: 1.5rem;
}
}

View File

@@ -0,0 +1,305 @@
/* Reset und Basis-Styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: radial-gradient(ellipse at top, #1e293b 0%, #0f172a 50%, #020617 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
color: #e2e8f0;
line-height: 1.6;
}
.container {
background: rgba(30, 41, 59, 0.95);
backdrop-filter: blur(20px);
border: 1px solid rgba(51, 65, 85, 0.3);
border-radius: 20px;
padding: 40px;
max-width: 500px;
width: 90%;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
text-align: center;
}
.logo {
font-size: 2.5rem;
font-weight: 900;
background: linear-gradient(135deg, #00d4ff, #0891b2);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 10px;
text-transform: uppercase;
letter-spacing: 2px;
}
.tagline {
color: #94a3b8;
font-size: 0.9rem;
margin-bottom: 30px;
text-transform: uppercase;
letter-spacing: 1px;
}
.title {
font-size: 1.8rem;
font-weight: 700;
color: #ffffff;
margin-bottom: 20px;
}
.subtitle {
color: #cbd5e1;
font-size: 1rem;
margin-bottom: 30px;
}
.form-group {
margin-bottom: 20px;
text-align: left;
}
.form-label {
display: block;
color: #e2e8f0;
font-weight: 600;
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
font-size: 0.9rem;
}
.form-input {
width: 100%;
padding: 15px 20px;
background: #1e293b;
border: 2px solid #334155;
border-radius: 12px;
color: #ffffff;
font-size: 1rem;
transition: all 0.3s ease;
}
.form-input:focus {
outline: none;
border-color: #00d4ff;
box-shadow: 0 0 0 3px rgba(0, 212, 255, 0.1);
}
.form-input::placeholder {
color: #64748b;
}
.btn {
width: 100%;
padding: 15px 30px;
border: none;
border-radius: 12px;
font-size: 1rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1px;
cursor: pointer;
transition: all 0.3s ease;
margin-bottom: 20px;
}
.btn-primary {
background: linear-gradient(135deg, #00d4ff, #0891b2);
color: #ffffff;
box-shadow: 0 4px 15px rgba(0, 212, 255, 0.3);
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 212, 255, 0.4);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.btn-secondary {
background: transparent;
color: #00d4ff;
border: 2px solid #00d4ff;
}
.btn-secondary:hover {
background: #00d4ff;
color: #ffffff;
}
.message {
padding: 15px 20px;
border-radius: 12px;
margin-bottom: 20px;
font-weight: 600;
text-align: center;
}
.message.success {
background: rgba(34, 197, 94, 0.1);
border: 1px solid #22c55e;
color: #22c55e;
}
.message.error {
background: rgba(239, 68, 68, 0.1);
border: 1px solid #ef4444;
color: #ef4444;
}
.message.info {
background: rgba(59, 130, 246, 0.1);
border: 1px solid #3b82f6;
color: #3b82f6;
}
.loading {
display: none;
text-align: center;
color: #94a3b8;
}
.spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid #334155;
border-radius: 50%;
border-top-color: #00d4ff;
animation: spin 1s ease-in-out infinite;
margin-right: 10px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.back-link {
color: #00d4ff;
text-decoration: none;
font-size: 0.9rem;
margin-top: 20px;
display: inline-block;
}
.back-link:hover {
color: #0891b2;
}
/* Responsive Design */
@media (max-width: 768px) {
.container {
margin: 20px;
padding: 30px 20px;
}
.logo {
font-size: 2rem;
}
.title {
font-size: 1.5rem;
}
.form-input, .btn {
padding: 12px 15px;
font-size: 0.9rem;
}
}
@media (max-width: 480px) {
.container {
margin: 10px;
padding: 20px 15px;
}
.logo {
font-size: 1.8rem;
}
.title {
font-size: 1.3rem;
}
}
/* Footer Styles */
.footer {
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
border-top: 1px solid #2a2a3e;
margin-top: 3rem;
padding: 2rem 0;
}
.footer-content {
max-width: 1200px;
margin: 0 auto;
padding: 0 2rem;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
}
.footer-links {
display: flex;
gap: 2rem;
align-items: center;
}
.footer-link {
color: #8892b0;
text-decoration: none;
font-size: 0.9rem;
transition: color 0.3s ease;
background: none;
border: none;
cursor: pointer;
font-family: inherit;
}
.footer-link:hover {
color: #00d4ff;
}
.cookie-settings-btn {
background: none !important;
border: none !important;
padding: 0 !important;
font-size: 0.9rem !important;
}
.footer-text {
color: #6b7280;
font-size: 0.85rem;
}
.footer-text p {
margin: 0;
}
@media (max-width: 768px) {
.footer-content {
flex-direction: column;
text-align: center;
gap: 1.5rem;
}
.footer-links {
flex-wrap: wrap;
justify-content: center;
gap: 1.5rem;
}
}

902
public/dashboard.html Normal file
View File

@@ -0,0 +1,902 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SPEEDRUN ARENA - Admin Dashboard</title>
<link rel="icon" type="image/x-icon" href="/pictures/favicon.ico">
<link rel="manifest" href="/manifest.json">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="Ninja Cross">
<link rel="apple-touch-icon" href="/pictures/favicon.ico">
<script src="https://unpkg.com/@supabase/supabase-js@2"></script>
<!-- QR Code Scanner Library -->
<script src="https://unpkg.com/jsqr@1.4.0/dist/jsQR.js"></script>
<link rel="stylesheet" href="/css/dashboard.css">
<!-- Notification Permission Script -->
<script>
// Register Service Worker for iPhone Notifications
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js')
.then(function(registration) {
console.log('✅ Service Worker registered:', registration);
})
.catch(function(error) {
console.log('❌ Service Worker registration failed:', error);
});
}
// Don't automatically request notification permission
// User must click the button to enable push notifications
// Convert VAPID key from base64url to Uint8Array
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/\-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
// Convert ArrayBuffer to Base64 string
function arrayBufferToBase64(buffer) {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return window.btoa(binary);
}
// Push notification state
let pushSubscription = null;
let pushEnabled = false;
// Subscribe to push notifications
async function subscribeToPush() {
try {
console.log('🔔 Starting push subscription...');
const registration = await navigator.serviceWorker.ready;
const vapidPublicKey = 'BJmNVx0C3XeVxeKGTP9c-Z4HcuZNmdk6QdiLocZgCmb-miCS0ESFO3W2TvJlRhhNAShV63pWA5p36BTVSetyTds';
const applicationServerKey = urlBase64ToUint8Array(vapidPublicKey);
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: applicationServerKey
});
pushSubscription = subscription;
// Player ID wird automatisch vom Server aus der Session geholt
console.log(`📱 Subscribing to push notifications...`);
// Convert ArrayBuffer keys to Base64 strings
const p256dhKey = subscription.getKey('p256dh');
const authKey = subscription.getKey('auth');
// Convert ArrayBuffer to Base64 URL-safe string
const p256dhString = arrayBufferToBase64(p256dhKey);
const authString = arrayBufferToBase64(authKey);
console.log('📱 Converted keys to Base64 strings');
console.log('📱 p256dh length:', p256dhString.length);
console.log('📱 auth length:', authString.length);
// Get current Supabase user ID
const { data: { session } } = await supabase.auth.getSession();
const supabaseUserId = session?.user?.id || null;
// Send subscription to server with player ID
const response = await fetch('/api/v1/public/subscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include', // Include cookies for session
body: JSON.stringify({
endpoint: subscription.endpoint,
keys: {
p256dh: p256dhString,
auth: authString
},
supabaseUserId: supabaseUserId
})
});
const result = await response.json();
if (result.success) {
pushEnabled = true;
updatePushButton();
console.log('✅ Push subscription successful');
// Store player ID for notifications
if (result.playerId) {
localStorage.setItem('pushPlayerId', result.playerId);
}
} else {
throw new Error(result.message);
}
} catch (error) {
console.error('❌ Push subscription failed:', error);
pushEnabled = false;
updatePushButton();
}
}
// Unsubscribe from push notifications
async function unsubscribeFromPush() {
try {
console.log('🔕 Unsubscribing from push notifications...');
// Get player ID from localStorage
const playerId = localStorage.getItem('pushPlayerId');
if (pushSubscription) {
// Store endpoint before unsubscribing
const endpoint = pushSubscription.endpoint;
await pushSubscription.unsubscribe();
pushSubscription = null;
console.log('✅ Local push subscription removed');
// Notify server to remove specific subscription from database
if (playerId && endpoint) {
try {
const response = await fetch('/api/v1/public/unsubscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include', // Include cookies for session
body: JSON.stringify({
playerId: playerId,
endpoint: endpoint
})
});
const result = await response.json();
if (result.success) {
console.log('✅ Server notified - specific subscription removed from database');
} else {
console.warn('⚠️ Server notification failed:', result.message);
}
} catch (error) {
console.warn('⚠️ Failed to notify server:', error);
}
}
}
// Clear stored player ID
localStorage.removeItem('pushPlayerId');
pushEnabled = false;
updatePushButton();
console.log('🔕 Push notifications disabled');
} catch (error) {
console.error('❌ Push unsubscribe failed:', error);
pushEnabled = false;
updatePushButton();
}
}
// Check if user is on iOS
function isIOS() {
return /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
}
// Check if PWA is installed
function isPWAInstalled() {
return window.matchMedia('(display-mode: standalone)').matches ||
window.navigator.standalone === true;
}
// Show iOS PWA installation hint
function showIOSPWAHint() {
if (isIOS() && !isPWAInstalled()) {
const hint = document.createElement('div');
hint.id = 'ios-pwa-hint';
hint.style.cssText = `
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 15px 20px;
border-radius: 10px;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
z-index: 10000;
max-width: 90%;
text-align: center;
font-size: 14px;
line-height: 1.4;
`;
hint.innerHTML = `
<div style="display: flex; align-items: center; gap: 10px;">
<span style="font-size: 20px;">📱</span>
<div>
<strong>Push-Benachrichtigungen für iOS</strong><br>
<small>Für Push-Benachrichtigungen auf iOS: Tippe auf <span style="background: rgba(255,255,255,0.2); padding: 2px 6px; border-radius: 4px;">📤 Teilen</span> → <span style="background: rgba(255,255,255,0.2); padding: 2px 6px; border-radius: 4px;">Zum Home-Bildschirm hinzufügen</span></small>
</div>
<button onclick="this.parentElement.parentElement.remove()" style="background: none; border: none; color: white; font-size: 18px; cursor: pointer; padding: 0; margin-left: 10px;">✕</button>
</div>
`;
document.body.appendChild(hint);
// Auto-remove after 10 seconds
setTimeout(() => {
if (hint.parentNode) {
hint.remove();
}
}, 10000);
}
}
// Toggle push notifications
async function togglePushNotifications() {
if (pushEnabled) {
await unsubscribeFromPush();
} else {
// Check if iOS and not PWA
if (isIOS() && !isPWAInstalled()) {
showIOSPWAHint();
return;
}
// Check notification permission first
if (Notification.permission === 'denied') {
alert('Push-Benachrichtigungen sind blockiert. Bitte erlaube sie in den Browser-Einstellungen.');
return;
}
if (Notification.permission === 'default') {
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
alert('Push-Benachrichtigungen wurden nicht erlaubt.');
return;
}
}
await subscribeToPush();
}
}
// Update push button appearance
function updatePushButton() {
const button = document.getElementById('pushButton');
if (!button) {
console.log('❌ Push button not found in DOM');
return;
}
console.log(`🔔 Updating push button - Status: ${pushEnabled ? 'ENABLED' : 'DISABLED'}`);
if (pushEnabled) {
button.classList.add('active');
button.setAttribute('data-de', '🔕 Push deaktivieren');
button.setAttribute('data-en', '🔕 Disable Push');
button.textContent = '🔕 Push deaktivieren';
console.log('✅ Button updated to: Push deaktivieren (RED)');
} else {
button.classList.remove('active');
button.setAttribute('data-de', '🔔 Push aktivieren');
button.setAttribute('data-en', '🔔 Enable Push');
button.textContent = '🔔 Push aktivieren';
console.log('✅ Button updated to: Push aktivieren (GREEN)');
}
}
// Check existing push subscription on page load
async function checkPushStatus() {
try {
console.log('🔍 Checking push status...');
if (!('serviceWorker' in navigator)) {
console.log('❌ Service Worker not supported');
pushEnabled = false;
updatePushButton();
return;
}
if (!('PushManager' in window)) {
console.log('❌ Push Manager not supported');
pushEnabled = false;
updatePushButton();
return;
}
const registration = await navigator.serviceWorker.ready;
console.log('✅ Service Worker ready');
const subscription = await registration.pushManager.getSubscription();
console.log('📱 Current subscription:', subscription ? 'EXISTS' : 'NONE');
if (subscription) {
pushSubscription = subscription;
pushEnabled = true;
updatePushButton();
console.log('✅ Existing push subscription found and activated');
// Also check if we have a stored player ID
const storedPlayerId = localStorage.getItem('pushPlayerId');
if (storedPlayerId) {
console.log(`📱 Push subscription linked to player: ${storedPlayerId}`);
}
} else {
pushEnabled = false;
updatePushButton();
console.log(' No existing push subscription found');
}
} catch (error) {
console.error('❌ Error checking push status:', error);
pushEnabled = false;
updatePushButton();
}
}
</script>
</head>
<body>
<div class="main-container">
<!-- Sticky Header Container -->
<div class="sticky-header">
<!-- Language Selector -->
<div class="language-selector">
<select id="languageSelect" onchange="changeLanguage()">
<option value="de" data-flag="🇩🇪">Deutsch</option>
<option value="en" data-flag="🇺🇸">English</option>
</select>
</div>
<div class="nav-buttons">
<div class="user-info">
<div class="user-avatar" id="userAvatar">U</div>
<span id="userEmail">user@example.com</span>
</div>
<button class="btn btn-push" id="pushButton" onclick="togglePushNotifications()" data-de="🔔 Push aktivieren" data-en="🔔 Enable Push">🔔 Push aktivieren</button>
<button class="btn btn-pwa" id="pwaButton" onclick="installPWA()" style="display: none;" data-de="📱 App installieren" data-en="📱 Install App">📱 App installieren</button>
<a href="/" class="btn btn-primary" data-de="Zurück zu Zeiten" data-en="Back to Times">Back to Times</a>
<button class="btn btn-logout" onclick="logout()" data-de="Logout" data-en="Logout">Logout</button>
</div>
</div>
<div class="header-section">
<h1 class="main-title" data-de="DEIN DASHBOARD" data-en="YOUR DASHBOARD">DEIN DASHBOARD</h1>
<p class="tagline" data-de="Verwalte deine Läufe in der NINJACROSS ARENA" data-en="Manage your runs in the NINJACROSS ARENA">Verwalte deine Läufe in der NINJACROSS ARENA</p>
</div>
<div id="loading" class="loading">
<div class="spinner"></div>
<p data-de="Lade dein Dashboard..." data-en="Loading your dashboard...">Lade dein Dashboard...</p>
</div>
<div id="dashboardContent" style="display: none;">
<div class="welcome-card">
<h2 data-de="Dein Dashboard 🥷" data-en="Your Dashboard 🥷">Dein Dashboard 🥷</h2>
<p data-de="Willkommen in Deinem Dashboard-Panel! Deine übersichtliche Übersicht aller deiner Läufe." data-en="Welcome to your Dashboard panel! Your clear overview of all your runs.">Willkommen in Deinem Dashboard-Panel! Deine übersichtliche Übersicht aller deiner Läufe.</p>
</div>
<div class="dashboard-grid">
<div class="card" id="analyticsCard" style="cursor: pointer;">
<h3 data-de="📊 Analytics" data-en="📊 Analytics">📊 Analytics</h3>
<div id="analyticsPreview" style="display: none;">
<div class="analytics-stats">
<div class="mini-stat">
<div class="mini-stat-number" id="avgTimeThisWeek">--:--</div>
<div class="mini-stat-label">Durchschnitt diese Woche</div>
</div>
<div class="mini-stat">
<div class="mini-stat-number" id="improvementThisWeek">+0.0%</div>
<div class="mini-stat-label">Verbesserung</div>
</div>
<div class="mini-stat">
<div class="mini-stat-number" id="runsThisWeek">0</div>
<div class="mini-stat-label">Läufe diese Woche</div>
</div>
</div>
</div>
<p data-de="Verfolge deine Leistung und überwache wichtige Metriken." data-en="Track your performance and monitor important metrics.">Verfolge deine Leistung und überwache wichtige Metriken.</p>
<button class="btn btn-primary" style="margin-top: 1rem;" onclick="event.stopPropagation(); showAnalytics();" data-de="Analytics öffnen" data-en="Open Analytics">Analytics öffnen</button>
</div>
<div class="card" onclick="showSettings()" style="cursor: pointer;">
<h3 data-de="⚙️ Settings" data-en="⚙️ Settings">⚙️ Settings</h3>
<p data-de="Verwalte deine Privatsphäre-Einstellungen und andere Optionen." data-en="Manage your privacy settings and other options.">Verwalte deine Privatsphäre-Einstellungen und andere Optionen.</p>
<button class="btn btn-primary" style="margin-top: 1rem;" onclick="event.stopPropagation(); showSettings();" data-de="Einstellungen öffnen" data-en="Open Settings">Einstellungen öffnen</button>
</div>
<div class="card" onclick="showRFIDSettings()" style="cursor: pointer;">
<h3 data-de="🏷️ RFID Verknüpfung" data-en="🏷️ RFID Linking">🏷️ RFID Verknüpfung</h3>
<p data-de="Verknüpfe deine RFID-Karte mit deinem Account, um deine Zeiten automatisch zu tracken." data-en="Link your RFID card with your account to automatically track your times.">Verknüpfe deine RFID-Karte mit deinem Account, um deine Zeiten automatisch zu tracken.</p>
<button class="btn btn-primary" style="margin-top: 1rem;" onclick="event.stopPropagation(); showRFIDSettings();" data-de="RFID verknüpfen" data-en="Link RFID">RFID verknüpfen</button>
</div>
<div class="card" id="statisticsCard" style="cursor: pointer;">
<h3 data-de="📊 Statistiken" data-en="📊 Statistics">📊 Statistiken</h3>
<div id="statisticsPreview" style="display: none;">
<div class="statistics-stats">
<div class="mini-stat">
<div class="mini-stat-number" id="personalBest">--:--</div>
<div class="mini-stat-label">Persönliche Bestzeit</div>
</div>
<div class="mini-stat">
<div class="mini-stat-number" id="totalRunsCount">0</div>
<div class="mini-stat-label">Gesamte Läufe</div>
</div>
<div class="mini-stat">
<div class="mini-stat-number" id="rankPosition">-</div>
<div class="mini-stat-label">Ranglisten-Position</div>
</div>
</div>
</div>
<p data-de="Detaillierte Statistiken zu deinen Läufen - beste Zeiten, Verbesserungen und Vergleiche." data-en="Detailed statistics about your runs - best times, improvements and comparisons.">Detaillierte Statistiken zu deinen Läufen - beste Zeiten, Verbesserungen und Vergleiche.</p>
<button class="btn btn-primary" style="margin-top: 1rem;" onclick="event.stopPropagation(); showStatistics();" data-de="Statistiken öffnen" data-en="Open Statistics">Statistiken öffnen</button>
</div>
</div>
<!-- User Times Section -->
<div class="times-section">
<div class="times-header">
<h2 data-de="🏃‍♂️ Meine Zeiten" data-en="🏃‍♂️ My Times">🏃‍♂️ Meine Zeiten</h2>
<p data-de="Deine persönlichen Bestzeiten an allen Standorten" data-en="Your personal best times at all locations">Deine persönlichen Bestzeiten an allen Standorten</p>
</div>
<!-- Loading State -->
<div id="timesLoading" class="times-loading" style="display: none;">
<div class="spinner"></div>
<p data-de="Lade deine Zeiten..." data-en="Loading your times...">Lade deine Zeiten...</p>
</div>
<!-- Not Linked State -->
<div id="timesNotLinked" class="times-not-linked">
<div class="not-linked-content">
<div class="not-linked-icon">🔗</div>
<h3 data-de="RFID noch nicht verknüpft" data-en="RFID not linked yet">RFID noch nicht verknüpft</h3>
<p data-de="Um deine persönlichen Zeiten zu sehen, musst du zuerst deine RFID-Karte mit deinem Account verknüpfen." data-en="To see your personal times, you must first link your RFID card with your account.">Um deine persönlichen Zeiten zu sehen, musst du zuerst deine RFID-Karte mit deinem Account verknüpfen.</p>
<button class="btn btn-primary" onclick="showRFIDSettings()" data-de="🏷️ RFID jetzt verknüpfen" data-en="🏷️ Link RFID now">
🏷️ RFID jetzt verknüpfen
</button>
<div class="link-info">
<h4 data-de="So funktioniert's:" data-en="How it works:">So funktioniert's:</h4>
<ol>
<li data-de="Klicke auf \"RFID jetzt verknüpfen\"" data-en="Click on \"Link RFID now\"">Klicke auf "RFID jetzt verknüpfen"</li>
<li data-de="Scanne den QR-Code auf deiner RFID-Karte" data-en="Scan the QR code on your RFID card">Scanne den QR-Code auf deiner RFID-Karte</li>
<li data-de="Fertig! Deine Zeiten werden automatisch hier angezeigt" data-en="Done! Your times will be displayed here automatically">Fertig! Deine Zeiten werden automatisch hier angezeigt</li>
</ol>
</div>
</div>
</div>
<!-- Times Display -->
<div id="timesDisplay" style="display: none;">
<div class="times-stats">
<div class="stat-card">
<div class="stat-number" id="totalRuns">0</div>
<div class="stat-label" data-de="Gesamte Läufe" data-en="Total Runs">Gesamte Läufe</div>
</div>
<div class="stat-card">
<div class="stat-number" id="bestTime">--:--</div>
<div class="stat-label" data-de="Beste Zeit" data-en="Best Time">Beste Zeit</div>
</div>
<div class="stat-card">
<div class="stat-number" id="locationsCount">0</div>
<div class="stat-label" data-de="Standorte" data-en="Locations">Standorte</div>
</div>
<div class="stat-card">
<div class="stat-number" id="linkedPlayer">--</div>
<div class="stat-label" data-de="Verknüpfter Spieler" data-en="Linked Player">Verknüpfter Spieler</div>
</div>
</div>
<div class="times-content">
<div class="times-grid" id="userTimesGrid">
<!-- Times will be populated here -->
</div>
</div>
</div>
</div>
<!-- Analytics Section -->
<div id="analyticsSection" class="analytics-section" style="display: none;">
<div class="section-header">
<h2 data-de="📊 Analytics" data-en="📊 Analytics">📊 Analytics</h2>
<p data-de="Detaillierte Analyse deiner Performance und Trends" data-en="Detailed analysis of your performance and trends">Detaillierte Analyse deiner Performance und Trends</p>
</div>
<!-- Performance Overview -->
<div class="analytics-grid">
<div class="analytics-card">
<h3>📈 Performance-Trends</h3>
<div class="trend-stats">
<div class="trend-item">
<span class="trend-label">Diese Woche:</span>
<span class="trend-value" id="avgTimeThisWeekDetail">--:--</span>
</div>
<div class="trend-item">
<span class="trend-label">Letzte Woche:</span>
<span class="trend-value" id="avgTimeLastWeek">--:--</span>
</div>
<div class="trend-item">
<span class="trend-label">Verbesserung:</span>
<span class="trend-value" id="improvementDetail">+0.0%</span>
</div>
</div>
</div>
<div class="analytics-card">
<h3>🏃‍♂️ Aktivitäts-Heatmap</h3>
<div class="activity-stats">
<div class="activity-item">
<span class="activity-label">Heute:</span>
<span class="activity-value" id="runsToday">0 Läufe</span>
</div>
<div class="activity-item">
<span class="activity-label">Diese Woche:</span>
<span class="activity-value" id="runsThisWeekDetail">0 Läufe</span>
</div>
<div class="activity-item">
<span class="activity-label">Durchschnitt/Tag:</span>
<span class="activity-value" id="avgRunsPerDay">0.0</span>
</div>
</div>
</div>
<div class="analytics-card">
<h3>🏆 Standort-Performance</h3>
<div class="location-stats" id="locationPerformance">
<p data-de="Lade Standort-Daten..." data-en="Loading location data...">Lade Standort-Daten...</p>
</div>
</div>
<div class="analytics-card">
<h3>📅 Monatlicher Fortschritt</h3>
<div class="monthly-stats">
<div class="monthly-item">
<span class="monthly-label">Dieser Monat:</span>
<span class="monthly-value" id="runsThisMonth">0 Läufe</span>
</div>
<div class="monthly-item">
<span class="monthly-label">Letzter Monat:</span>
<span class="monthly-value" id="runsLastMonth">0 Läufe</span>
</div>
<div class="monthly-item">
<span class="monthly-label">Beste Zeit:</span>
<span class="monthly-value" id="bestTimeThisMonth">--:--</span>
</div>
</div>
</div>
</div>
</div>
<!-- Statistics Section -->
<div id="statisticsSection" class="statistics-section" style="display: none;">
<div class="section-header">
<h2 data-de="📊 Statistiken" data-en="📊 Statistics">📊 Statistiken</h2>
<p data-de="Detaillierte Statistiken zu deinen Läufen und Vergleiche" data-en="Detailed statistics about your runs and comparisons">Detaillierte Statistiken zu deinen Läufen und Vergleiche</p>
</div>
<!-- Personal Records -->
<div class="statistics-grid">
<div class="statistics-card">
<h3>🏆 Persönliche Bestzeiten</h3>
<div class="personal-records" id="personalRecords">
<p data-de="Lade Bestzeiten..." data-en="Loading best times...">Lade Bestzeiten...</p>
</div>
</div>
<div class="statistics-card">
<h3>📊 Konsistenz-Metriken</h3>
<div class="consistency-stats">
<div class="consistency-item">
<span class="consistency-label">Durchschnittszeit:</span>
<span class="consistency-value" id="averageTime">--:--</span>
</div>
<div class="consistency-item">
<span class="consistency-label">Standardabweichung:</span>
<span class="consistency-value" id="timeDeviation">--:--</span>
</div>
<div class="consistency-item">
<span class="consistency-label">Konsistenz-Score:</span>
<span class="consistency-value" id="consistencyScore">0%</span>
</div>
</div>
</div>
<div class="statistics-card">
<h3>🏅 Ranglisten-Positionen</h3>
<div class="ranking-stats" id="rankingStats">
<p data-de="Lade Ranglisten..." data-en="Loading rankings...">Lade Ranglisten...</p>
</div>
</div>
<div class="statistics-card">
<h3>📈 Fortschritt-Übersicht</h3>
<div class="progress-stats">
<div class="progress-item">
<span class="progress-label">Gesamte Läufe:</span>
<span class="progress-value" id="totalRunsStats">0</span>
</div>
<div class="progress-item">
<span class="progress-label">Aktive Tage:</span>
<span class="progress-value" id="activeDays">0</span>
</div>
<div class="progress-item">
<span class="progress-label">Standorte besucht:</span>
<span class="progress-value" id="locationsVisited">0</span>
</div>
</div>
</div>
</div>
</div>
<!-- Achievements Section -->
<div class="achievements-section">
<div class="achievements-header">
<h2 data-de="🏆 Meine Achievements" data-en="🏆 My Achievements">🏆 Meine Achievements</h2>
<p data-de="Sammele Punkte und erreiche neue Meilensteine!" data-en="Collect points and reach new milestones!">Sammele Punkte und erreiche neue Meilensteine!</p>
</div>
<!-- Achievement Stats -->
<div class="achievement-stats" id="achievementStats" style="display: none;">
<div class="stat-card achievement-stat">
<div class="stat-number" id="totalPoints">0</div>
<div class="stat-label" data-de="Gesamtpunkte" data-en="Total Points">Gesamtpunkte</div>
</div>
<div class="stat-card achievement-stat">
<div class="stat-number" id="completedAchievements">0</div>
<div class="stat-label" data-de="Abgeschlossen" data-en="Completed">Abgeschlossen</div>
</div>
<div class="stat-card achievement-stat">
<div class="stat-number" id="achievementsToday">0</div>
<div class="stat-label" data-de="Heute erreicht" data-en="Achieved Today">Heute erreicht</div>
</div>
<div class="stat-card achievement-stat">
<div class="stat-number" id="completionPercentage">0%</div>
<div class="stat-label" data-de="Fortschritt" data-en="Progress">Fortschritt</div>
</div>
</div>
<!-- Achievement Categories -->
<div class="achievement-categories" id="achievementCategories" style="display: none;">
<div class="category-tabs">
<button class="category-tab active" onclick="showAchievementCategory('all')" data-category="all" data-de="Alle" data-en="All">Alle</button>
<button class="category-tab" onclick="showAchievementCategory('consistency')" data-category="consistency" data-de="Konsistenz" data-en="Consistency">Konsistenz</button>
<button class="category-tab" onclick="showAchievementCategory('improvement')" data-category="improvement" data-de="Verbesserung" data-en="Improvement">Verbesserung</button>
<button class="category-tab" onclick="showAchievementCategory('seasonal')" data-category="seasonal" data-de="Saisonal" data-en="Seasonal">Saisonal</button>
<button class="category-tab" onclick="showAchievementCategory('monthly')" data-category="monthly" data-de="Monatlich" data-en="Monthly">Monatlich</button>
</div>
<div class="achievements-grid" id="achievementsGrid">
<!-- Achievements will be populated here -->
</div>
</div>
<!-- Achievement Loading State -->
<div id="achievementsLoading" class="achievements-loading" style="display: none;">
<div class="spinner"></div>
<p data-de="Lade deine Achievements..." data-en="Loading your achievements...">Lade deine Achievements...</p>
</div>
<!-- Achievement Not Available State -->
<div id="achievementsNotAvailable" class="achievements-not-available" style="display: none;">
<div class="not-available-content">
<div class="not-available-icon">🏆</div>
<h3 data-de="Achievements noch nicht verfügbar" data-en="Achievements not available yet">Achievements noch nicht verfügbar</h3>
<p data-de="Um Achievements zu sammeln, musst du zuerst deine RFID-Karte mit deinem Account verknüpfen und einige Läufe absolvieren." data-en="To collect achievements, you must first link your RFID card with your account and complete some runs.">Um Achievements zu sammeln, musst du zuerst deine RFID-Karte mit deinem Account verknüpfen und einige Läufe absolvieren.</p>
<button class="btn btn-primary" onclick="showRFIDSettings()" data-de="🏷️ RFID jetzt verknüpfen" data-en="🏷️ Link RFID now">
🏷️ RFID jetzt verknüpfen
</button>
</div>
</div>
</div>
</div>
</div>
<!-- RFID Settings Modal -->
<div id="rfidModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title" data-de="📱 RFID QR-Code Scanner" data-en="📱 RFID QR Code Scanner">📱 RFID QR-Code Scanner</h2>
<span class="close" onclick="closeModal('rfidModal')">&times;</span>
</div>
<div id="rfidMessage"></div>
<!-- QR Scanner Step -->
<div id="qrScannerStep">
<p style="color: #8892b0; margin-bottom: 1.5rem; text-align: center;" data-de="Scanne den QR-Code auf deiner RFID-Karte, um sie mit deinem Account zu verknüpfen." data-en="Scan the QR code on your RFID card to link it with your account.">
Scanne den QR-Code auf deiner RFID-Karte, um sie mit deinem Account zu verknüpfen.
</p>
<!-- Camera Preview -->
<div id="cameraContainer" style="display: none;">
<video id="qrVideo" style="width: 100%; max-width: 400px; border-radius: 0.75rem; margin: 0 auto; display: block;"></video>
<canvas id="qrCanvas" style="display: none;"></canvas>
</div>
<!-- Scanner Controls -->
<div style="text-align: center; margin: 1.5rem 0;">
<button class="btn btn-primary" onclick="startQRScanner()" id="startScanBtn" data-de="📷 Kamera starten" data-en="📷 Start Camera">
📷 Kamera starten
</button>
<button class="btn btn-secondary" onclick="stopQRScanner()" id="stopScanBtn" style="display: none;" data-de="🛑 Scanner stoppen" data-en="🛑 Stop Scanner">
🛑 Scanner stoppen
</button>
</div>
<!-- Manual Input Fallback -->
<div style="border-top: 1px solid #334155; padding-top: 1.5rem; margin-top: 1.5rem;">
<p style="color: #8892b0; text-align: center; margin-bottom: 1rem; font-size: 0.9rem;" data-de="Kamera funktioniert nicht? RFID UID manuell eingeben:" data-en="Camera not working? Enter RFID UID manually:">
Kamera funktioniert nicht? RFID UID manuell eingeben:
</p>
<div class="form-group">
<input type="text" id="manualRfidInput" class="form-input" placeholder="z.B. aaaaaa, FFFFFF oder FF:FF:FF:FF" style="text-align: center; font-family: monospace;" data-de="z.B. aaaaaa, FFFFFF oder FF:FF:FF:FF" data-en="e.g. aaaaaa, FFFFFF or FF:FF:FF:FF">
</div>
<button class="btn btn-secondary" onclick="linkManualRfid()" style="width: 100%;" data-de="Manuell verknüpfen" data-en="Link Manually">
Manuell verknüpfen
</button>
</div>
<!-- Create New Player Section -->
<div id="createPlayerSection" style="border-top: 1px solid #334155; padding-top: 1.5rem; margin-top: 1.5rem;">
<p style="color: #8892b0; text-align: center; margin-bottom: 1rem; font-size: 0.9rem;" data-de="Neuen Spieler mit RFID erstellen:" data-en="Create new player with RFID:">
Neuen Spieler mit RFID erstellen:
</p>
<div class="form-group">
<label for="playerFirstname" style="color: #8892b0; font-size: 0.9rem; margin-bottom: 0.5rem; display: block;" data-de="Vorname:" data-en="First Name:">Vorname:</label>
<input type="text" id="playerFirstname" class="form-input" placeholder="Max" style="text-align: center;">
</div>
<div class="form-group">
<label for="playerLastname" style="color: #8892b0; font-size: 0.9rem; margin-bottom: 0.5rem; display: block;" data-de="Nachname:" data-en="Last Name:">Nachname:</label>
<input type="text" id="playerLastname" class="form-input" placeholder="Mustermann" style="text-align: center;">
</div>
<div class="form-group">
<label for="playerBirthdate" style="color: #8892b0; font-size: 0.9rem; margin-bottom: 0.5rem; display: block;" data-de="Geburtsdatum:" data-en="Birth Date:">Geburtsdatum:</label>
<input type="date" id="playerBirthdate" class="form-input" style="text-align: center;">
</div>
<!-- AGB Section -->
<div class="agb-section" style="background: #1e293b; border: 1px solid #334155; border-radius: 8px; padding: 15px; margin: 15px 0;">
<div class="agb-checkbox" style="display: flex; align-items: flex-start; gap: 10px; margin-bottom: 10px;">
<input type="checkbox" id="agbAccepted" name="agbAccepted" required style="width: auto; margin: 0; margin-top: 3px;">
<label for="agbAccepted" style="color: #e2e8f0; font-size: 0.85rem; line-height: 1.4; margin: 0; font-weight: normal;">
Ich habe die <a href="/agb.html" target="_blank" style="color: #00d4ff; text-decoration: none; font-weight: bold;">Allgemeinen Geschäftsbedingungen</a>
gelesen und stimme zu, dass mein Name und meine Laufzeiten im öffentlichen Leaderboard angezeigt werden.
</label>
</div>
<div class="agb-warning" style="color: #fbbf24; font-size: 0.8rem; margin-top: 10px;">
⚠️ <strong>Wichtig:</strong> Ohne Zustimmung zu den AGB können Sie das System nutzen,
aber Ihre Zeiten werden nicht im öffentlichen Leaderboard angezeigt.
</div>
</div>
<button class="btn btn-primary" onclick="createRfidPlayerRecord()" style="width: 100%;" data-de="Spieler erstellen" data-en="Create Player">
Spieler erstellen
</button>
</div>
<!-- Scanning Status -->
<div id="scanningStatus" style="display: none; text-align: center; color: #00d4ff; margin-top: 1rem;">
<div class="spinner" style="width: 20px; height: 20px; margin: 0 auto 0.5rem;"></div>
<span data-de="Suche nach QR-Code..." data-en="Searching for QR code...">Suche nach QR-Code...</span>
</div>
</div>
</div>
</div>
<!-- Settings Modal -->
<div id="settingsModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title" data-de="⚙️ Einstellungen" data-en="⚙️ Settings">⚙️ Einstellungen</h2>
<span class="close" onclick="closeModal('settingsModal')">&times;</span>
</div>
<div class="settings-content">
<div class="setting-item">
<div class="setting-info">
<h3 data-de="🏆 Leaderboard Sichtbarkeit" data-en="🏆 Leaderboard Visibility">🏆 Leaderboard Sichtbarkeit</h3>
<p data-de="Bestimme, ob deine Zeiten im globalen Leaderboard angezeigt werden sollen." data-en="Determine whether your times should be displayed in the global leaderboard.">Bestimme, ob deine Zeiten im globalen Leaderboard angezeigt werden sollen.</p>
</div>
<div class="setting-control">
<label class="toggle-switch">
<input type="checkbox" id="showInLeaderboard" onchange="updateLeaderboardSetting()">
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="setting-description">
<p style="color: #8892b0; font-size: 0.9rem; margin-top: 1rem; padding: 1rem; background: #1e293b; border-radius: 0.5rem;" data-de="<strong>Hinweis:</strong> Wenn diese Option deaktiviert ist, werden deine Zeiten nur in deinem persönlichen Dashboard angezeigt, aber nicht im öffentlichen Leaderboard. Du kannst diese Einstellung jederzeit ändern." data-en="<strong>Note:</strong> If this option is disabled, your times will only be displayed in your personal dashboard, but not in the public leaderboard. You can change this setting at any time.">
<strong>Hinweis:</strong> Wenn diese Option deaktiviert ist, werden deine Zeiten nur in deinem persönlichen Dashboard angezeigt, aber nicht im öffentlichen Leaderboard. Du kannst diese Einstellung jederzeit ändern.
</p>
</div>
<div class="settings-actions">
<button class="btn btn-primary" onclick="saveSettings()" data-de="Einstellungen speichern" data-en="Save Settings">Einstellungen speichern</button>
<button class="btn btn-secondary" onclick="closeModal('settingsModal')" data-de="Abbrechen" data-en="Cancel">Abbrechen</button>
</div>
</div>
</div>
</div>
<!-- Footer -->
<footer class="footer">
<div class="footer-content">
<div class="footer-links">
<a href="/impressum.html" class="footer-link" data-de="Impressum" data-en="Imprint">Impressum</a>
<a href="/datenschutz.html" class="footer-link" data-de="Datenschutz" data-en="Privacy">Datenschutz</a>
<button id="cookie-settings-footer" class="footer-link cookie-settings-btn" data-de="Cookie-Einstellungen" data-en="Cookie Settings">Cookie-Einstellungen</button>
</div>
<div class="footer-text">
<p data-de="&copy; 2024 NinjaCross. Alle Rechte vorbehalten." data-en="&copy; 2024 NinjaCross. All rights reserved.">&copy; 2024 NinjaCross. Alle Rechte vorbehalten.</p>
</div>
</div>
</footer>
<script src="/js/cookie-consent.js"></script>
<script src="/js/dashboard.js?v=1.6"></script>
<script>
// PWA Installation
let deferredPrompt;
// Listen for PWA install prompt
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredPrompt = e;
const pwaButton = document.getElementById('pwaButton');
if (pwaButton) {
pwaButton.style.display = 'inline-block';
}
});
// Install PWA
async function installPWA() {
if (deferredPrompt) {
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
console.log(`PWA install outcome: ${outcome}`);
deferredPrompt = null;
const pwaButton = document.getElementById('pwaButton');
if (pwaButton) {
pwaButton.style.display = 'none';
}
} else if (isIOS()) {
// Show iOS installation instructions
showIOSPWAHint();
}
}
// Check if PWA is already installed
window.addEventListener('appinstalled', () => {
console.log('PWA was installed');
const pwaButton = document.getElementById('pwaButton');
if (pwaButton) {
pwaButton.style.display = 'none';
}
});
// Initialize dashboard when page loads
document.addEventListener('DOMContentLoaded', function () {
// Show PWA hint for iOS users
if (isIOS() && !isPWAInstalled()) {
setTimeout(showIOSPWAHint, 2000); // Show after 2 seconds
}
});
</script>
</body>
</html>

362
public/datenschutz.html Normal file
View File

@@ -0,0 +1,362 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Datenschutzerklärung - NinjaCross</title>
<link rel="icon" type="image/x-icon" href="/pictures/favicon.ico">
<link rel="stylesheet" href="/css/leaderboard.css">
<style>
.legal-container {
max-width: 1000px;
margin: 0 auto;
padding: 2rem;
min-height: 100vh;
}
.legal-header {
text-align: center;
margin-bottom: 3rem;
}
.legal-title {
font-size: 3.5rem;
font-weight: 700;
background: linear-gradient(135deg, #00d4ff, #ff6b35, #ffd700);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 0.5rem;
letter-spacing: -0.02em;
}
.legal-subtitle {
font-size: 1.2rem;
color: #8892b0;
font-weight: 300;
}
.legal-content {
background: rgba(30, 41, 59, 0.8);
backdrop-filter: blur(20px);
border: 1px solid rgba(51, 65, 85, 0.3);
border-radius: 20px;
padding: 3rem;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
}
.section {
margin-bottom: 2.5rem;
}
.section h2 {
color: #00d4ff;
font-size: 1.8rem;
margin-bottom: 1rem;
border-bottom: 2px solid #334155;
padding-bottom: 0.5rem;
font-weight: 600;
}
.section h3 {
color: #ff6b35;
font-size: 1.3rem;
margin: 1.5rem 0 0.8rem 0;
font-weight: 500;
}
.section p, .section li {
margin-bottom: 0.8rem;
color: #e2e8f0;
line-height: 1.6;
}
.section ul, .section ol {
margin-left: 1.5rem;
}
.contact-info {
background: rgba(0, 212, 255, 0.1);
border: 1px solid rgba(0, 212, 255, 0.3);
padding: 1.5rem;
border-radius: 12px;
margin: 1rem 0;
}
.contact-info p {
margin-bottom: 0.5rem;
}
.contact-info strong {
color: #00d4ff;
}
.back-button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
background: linear-gradient(135deg, #00d4ff, #0099cc);
color: white;
padding: 12px 24px;
text-decoration: none;
border-radius: 12px;
margin-top: 2rem;
font-weight: 600;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(0, 212, 255, 0.3);
}
.back-button:hover {
background: linear-gradient(135deg, #0099cc, #007aa3);
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 212, 255, 0.4);
}
.highlight-box {
background: rgba(255, 107, 53, 0.1);
border: 1px solid rgba(255, 107, 53, 0.3);
padding: 1.5rem;
border-radius: 12px;
margin: 1.5rem 0;
}
.highlight-box h3 {
color: #ff6b35;
margin-top: 0;
}
.highlight-box p {
color: #e2e8f0;
margin: 0;
}
.cookie-table {
width: 100%;
border-collapse: collapse;
margin: 1.5rem 0;
background: rgba(30, 41, 59, 0.5);
border-radius: 12px;
overflow: hidden;
}
.cookie-table th, .cookie-table td {
border: 1px solid rgba(51, 65, 85, 0.3);
padding: 1rem;
text-align: left;
}
.cookie-table th {
background: rgba(0, 212, 255, 0.1);
font-weight: 600;
color: #00d4ff;
}
.cookie-table td {
color: #e2e8f0;
}
@media (max-width: 768px) {
.legal-container {
padding: 1rem;
}
.legal-content {
padding: 1.5rem;
}
.legal-title {
font-size: 2.5rem;
}
}
</style>
</head>
<body>
<div class="legal-container">
<div class="legal-header">
<h1 class="legal-title">🔒 Datenschutzerklärung</h1>
<p class="legal-subtitle">NinjaCross - Speedrun Arena</p>
</div>
<div class="legal-content">
<div class="section">
<h2>1. Datenschutz auf einen Blick</h2>
<h3>Allgemeine Hinweise</h3>
<p>Die folgenden Hinweise geben einen einfachen Überblick darüber, was mit Ihren personenbezogenen Daten passiert, wenn Sie diese Website besuchen. Personenbezogene Daten sind alle Daten, mit denen Sie persönlich identifiziert werden können. Ausführliche Informationen zum Thema Datenschutz entnehmen Sie unserer unter diesem Text aufgeführten Datenschutzerklärung.</p>
</div>
<div class="section">
<h2>2. Datenerfassung auf dieser Website</h2>
<h3>Wer ist verantwortlich für die Datenerfassung auf dieser Website?</h3>
<p>Die Datenverarbeitung auf dieser Website erfolgt durch den Websitebetreiber. Dessen Kontaktdaten können Sie dem Abschnitt „Hinweis zur Verantwortlichen Stelle" in dieser Datenschutzerklärung entnehmen.</p>
<h3>Wie erfassen wir Ihre Daten?</h3>
<p>Ihre Daten werden zum einen dadurch erhoben, dass Sie uns diese mitteilen. Hierbei kann es sich z. B. um Daten handeln, die Sie in ein Kontaktformular eingeben.</p>
<p>Andere Daten werden automatisch oder nach Ihrer Einwilligung beim Besuch der Website durch unsere IT-Systeme erfasst. Das sind vor allem technische Daten (z. B. Internetbrowser, Betriebssystem oder Uhrzeit des Seitenaufrufs). Die Erfassung dieser Daten erfolgt automatisch, sobald Sie diese Website betreten.</p>
<h3>Wofür nutzen wir Ihre Daten?</h3>
<p>Ein Teil der Daten wird erhoben, um eine fehlerfreie Bereitstellung der Website zu gewährleisten. Andere Daten können zur Analyse Ihres Nutzerverhaltens verwendet werden.</p>
<h3>Welche Rechte haben Sie bezüglich Ihrer Daten?</h3>
<p>Sie haben jederzeit das Recht, unentgeltlich Auskunft über Herkunft, Empfänger und Zweck Ihrer gespeicherten personenbezogenen Daten zu erhalten. Sie haben außerdem ein Recht, die Berichtigung oder Löschung dieser Daten zu verlangen. Wenn Sie eine Einwilligung zur Datenverarbeitung erteilt haben, können Sie diese Einwilligung jederzeit für die Zukunft widerrufen. Außerdem haben Sie das Recht, unter bestimmten Umständen die Einschränkung der Verarbeitung Ihrer personenbezogenen Daten zu verlangen. Des Weiteren steht Ihnen ein Beschwerderecht bei der zuständigen Aufsichtsbehörde zu.</p>
</div>
<div class="section">
<h2>3. Hosting</h2>
<p>Wir hosten die Inhalte unserer Website bei folgendem Anbieter:</p>
<div class="contact-info">
<p><strong>Serverstandort:</strong> Deutschland<br>
<strong>Anbieter:</strong> [Ihr Hosting-Anbieter]<br>
<strong>Datenschutz:</strong> <a href="#" target="_blank" rel="noopener">Datenschutzerklärung des Anbieters</a></p>
</div>
</div>
<div class="section">
<h2>4. Allgemeine Hinweise und Pflichtinformationen</h2>
<h3>Datenschutz</h3>
<p>Die Betreiber dieser Seiten nehmen den Schutz Ihrer persönlichen Daten sehr ernst. Wir behandeln Ihre personenbezogenen Daten vertraulich und entsprechend den gesetzlichen Datenschutzvorschriften sowie dieser Datenschutzerklärung.</p>
<h3>Hinweis zur verantwortlichen Stelle</h3>
<p>Die verantwortliche Stelle für die Datenverarbeitung auf dieser Website ist:</p>
<div class="contact-info">
<p>Max Mustermann<br>
Musterstraße 123<br>
12345 Musterstadt<br>
Deutschland</p>
<p>Telefon: +49 (0) 123 456789<br>
E-Mail: info@ninjacross.de</p>
</div>
<h3>Speicherdauer</h3>
<p>Soweit innerhalb dieser Datenschutzerklärung keine speziellere Speicherdauer genannt wurde, verbleiben Ihre personenbezogenen Daten bei uns, bis der Zweck für die Datenverarbeitung entfällt. Wenn Sie ein berechtigtes Löschersuchen geltend machen oder eine Einwilligung zur Datenverarbeitung widerrufen, werden Ihre Daten gelöscht, sofern wir keine anderen rechtlich zulässigen Gründe für die Speicherung Ihrer personenbezogenen Daten haben (z. B. steuer- oder handelsrechtliche Aufbewahrungsfristen); im letztgenannten Fall erfolgt die Löschung nach Fortfall dieser Gründe.</p>
</div>
<div class="section">
<h2>5. Datenerfassung auf dieser Website</h2>
<h3>Cookies</h3>
<p>Unsere Internetseiten verwenden so genannte „Cookies". Cookies sind kleine Textdateien und richten auf Ihrem Endgerät keinen Schaden an. Sie werden entweder vorübergehend für die Dauer einer Sitzung (Session-Cookies) oder dauerhaft (dauerhafte Cookies) auf Ihrem Endgerät gespeichert. Session-Cookies werden nach Ende Ihres Besuchs automatisch gelöscht. Von dauerhaften Cookies bleibt eine Teil auf Ihrem Endgerät gespeichert, bis Sie diese selbst löschen oder eine automatische Löschung durch Ihren Webbrowser erfolgt.</p>
<p>Teilweise können auch Cookies von Drittanbietern auf Ihrem Endgerät gespeichert werden, wenn Sie unsere Seite betreten (Third-Party-Cookies). Diese ermöglichen uns oder Ihnen die Nutzung bestimmter Dienstleistungen des Drittanbieters (z. B. Cookies zur Abwicklung von Zahlungsdienstleistungen).</p>
<h3>Cookie-Übersicht</h3>
<table class="cookie-table">
<thead>
<tr>
<th>Cookie-Name</th>
<th>Zweck</th>
<th>Speicherdauer</th>
<th>Typ</th>
</tr>
</thead>
<tbody>
<tr>
<td>session</td>
<td>Authentifizierung und Session-Management</td>
<td>24 Stunden</td>
<td>Notwendig</td>
</tr>
<tr>
<td>supabase.auth.token</td>
<td>Benutzer-Authentifizierung (Supabase)</td>
<td>1 Jahr</td>
<td>Funktional</td>
</tr>
<tr>
<td>page_views</td>
<td>Seitenaufruf-Statistiken</td>
<td>30 Tage</td>
<td>Statistik</td>
</tr>
</tbody>
</table>
<h3>Server-Log-Dateien</h3>
<p>Der Provider der Seiten erhebt und speichert automatisch Informationen in so genannten Server-Log-Dateien, die Ihr Browser automatisch an uns übermittelt. Dies sind:</p>
<ul>
<li>Browsertyp und Browserversion</li>
<li>verwendetes Betriebssystem</li>
<li>Referrer URL</li>
<li>Hostname des zugreifenden Rechners</li>
<li>Uhrzeit der Serveranfrage</li>
<li>IP-Adresse</li>
</ul>
<p>Eine Zusammenführung dieser Daten mit anderen Datenquellen wird nicht vorgenommen. Die Erfassung dieser Daten erfolgt auf Grundlage von Art. 6 Abs. 1 lit. f DSGVO.</p>
</div>
<div class="section">
<h2>6. Plugins und Tools</h2>
<h3>Google OAuth</h3>
<p>Diese Website nutzt Google OAuth für die Benutzeranmeldung. Anbieter ist die Google Ireland Limited („Google"), Gordon House, Barrow Street, Dublin 4, Irland.</p>
<p>Wenn Sie sich über Google anmelden, werden Ihre Daten an Google übertragen. Die Nutzung von Google OAuth erfolgt auf Grundlage von Art. 6 Abs. 1 lit. f DSGVO. Weitere Informationen finden Sie in der <a href="https://policies.google.com/privacy" target="_blank" rel="noopener">Datenschutzerklärung von Google</a>.</p>
<h3>Supabase</h3>
<p>Diese Website nutzt Supabase für die Benutzerauthentifizierung und Datenbankdienste. Anbieter ist Supabase Inc., 970 Toa Payoh North #07-04, Singapore 318992.</p>
<p>Weitere Informationen finden Sie in der <a href="https://supabase.com/privacy" target="_blank" rel="noopener">Datenschutzerklärung von Supabase</a>.</p>
</div>
<div class="section">
<h2>7. Ihre Rechte</h2>
<h3>Recht auf Auskunft</h3>
<p>Sie haben das Recht, jederzeit Auskunft über die von uns über Sie gespeicherten personenbezogenen Daten zu verlangen.</p>
<h3>Recht auf Berichtigung</h3>
<p>Sie haben das Recht, die Berichtigung unrichtiger oder die Vervollständigung unvollständiger Daten zu verlangen.</p>
<h3>Recht auf Löschung</h3>
<p>Sie haben das Recht, die Löschung Ihrer personenbezogenen Daten zu verlangen.</p>
<h3>Recht auf Einschränkung der Verarbeitung</h3>
<p>Sie haben das Recht, die Einschränkung der Verarbeitung Ihrer personenbezogenen Daten zu verlangen.</p>
<h3>Recht auf Datenübertragbarkeit</h3>
<p>Sie haben das Recht, die Sie betreffenden personenbezogenen Daten in einem strukturierten, gängigen und maschinenlesbaren Format zu erhalten.</p>
<h3>Widerruf Ihrer Einwilligung zur Datenverarbeitung</h3>
<p>Viele Datenverarbeitungsvorgänge sind nur mit Ihrer ausdrücklichen Einwilligung möglich. Sie können eine bereits erteilte Einwilligung jederzeit widerrufen. Die Rechtmäßigkeit der bis zum Widerruf erfolgten Datenverarbeitung bleibt vom Widerruf unberührt.</p>
<h3>Recht auf Beschwerde bei der zuständigen Aufsichtsbehörde</h3>
<p>Im Falle von Verstößen gegen die DSGVO steht den Betroffenen ein Beschwerderecht bei einer Aufsichtsbehörde, insbesondere in dem Mitgliedstaat ihres gewöhnlichen Aufenthalts, ihres Arbeitsplatzes oder des Orts des mutmaßlichen Verstoßes zu. Das Beschwerderecht besteht unbeschadet anderweitiger verwaltungsrechtlicher oder gerichtlicher Rechtsbehelfe.</p>
</div>
<div class="section">
<h2>8. Kontakt</h2>
<p>Bei Fragen zum Datenschutz wenden Sie sich bitte an:</p>
<div class="contact-info">
<p>E-Mail: datenschutz@ninjacross.de<br>
Telefon: +49 (0) 123 456789</p>
</div>
</div>
<a href="/" class="back-button">← Zurück zur Startseite</a>
</div>
</div>
<!-- Footer -->
<footer class="footer">
<div class="footer-content">
<div class="footer-links">
<a href="/impressum.html" class="footer-link">Impressum</a>
<a href="/datenschutz.html" class="footer-link">Datenschutz</a>
<button id="cookie-settings-footer" class="footer-link cookie-settings-btn">Cookie-Einstellungen</button>
</div>
<div class="footer-text">
<p>&copy; 2024 NinjaCross. Alle Rechte vorbehalten.</p>
</div>
</div>
</footer>
<script src="/js/cookie-consent.js"></script>
<script>
// Add cookie settings button functionality
document.addEventListener('DOMContentLoaded', function() {
const cookieSettingsBtn = document.getElementById('cookie-settings-footer');
if (cookieSettingsBtn) {
cookieSettingsBtn.addEventListener('click', function() {
if (window.cookieConsent) {
window.cookieConsent.resetConsent();
}
});
}
});
</script>
</body>
</html>

View File

@@ -0,0 +1,181 @@
# E-Mail-Client-Kompatibilität Guide
## 🚨 Problem: Farben sehen in E-Mail-Clients komisch aus
E-Mail-Clients sind sehr restriktiv mit CSS und unterstützen oft keine modernen Features. Hier sind die Lösungen:
## ❌ Was E-Mail-Clients NICHT unterstützen:
### CSS-Features
- **Gradients** (`linear-gradient`, `radial-gradient`)
- **Backdrop-Filter** (`backdrop-filter: blur()`)
- **Box-Shadow** (komplexe Schatten)
- **Transforms** (`transform: translateY()`)
- **Custom Fonts** (Google Fonts, Inter)
- **Flexbox/Grid** (begrenzte Unterstützung)
- **CSS-Variablen** (`--custom-property`)
### Farben
- **Transparente Hintergründe** (`rgba()` mit Alpha)
- **Komplexe Farbverläufe**
- **Moderne CSS-Farben** (HSL, etc.)
## ✅ E-Mail-Client-kompatible Alternativen:
### 1. Einfache Hintergrundfarben
```css
/* ❌ Nicht kompatibel */
background: linear-gradient(135deg, #00d4ff, #0891b2);
/* ✅ Kompatibel */
background-color: #00d4ff;
```
### 2. Einfache Borders
```css
/* ❌ Nicht kompatibel */
border: 1px solid rgba(51, 65, 85, 0.3);
/* ✅ Kompatibel */
border: 1px solid #334155;
```
### 3. Standard-Fonts
```css
/* ❌ Nicht kompatibel */
font-family: 'Inter', sans-serif;
/* ✅ Kompatibel */
font-family: Arial, sans-serif;
```
### 4. Einfache Container
```css
/* ❌ Nicht kompatibel */
background: rgba(30, 41, 59, 0.95);
backdrop-filter: blur(20px);
/* ✅ Kompatibel */
background-color: #1e293b;
```
## 🎨 Optimierte Farbpalette für E-Mail:
### Hauptfarben
- **Hintergrund:** `#0a0a0f` (Dunkelblau)
- **Container:** `#1e293b` (Dunkelgrau)
- **Akzent:** `#00d4ff` (Neon-Blau)
- **Text:** `#ffffff` (Weiß)
- **Sekundärtext:** `#cbd5e1` (Hellgrau)
### Status-Farben
- **Erfolg:** `#22c55e` (Grün)
- **Warnung:** `#f59e0b` (Orange)
- **Fehler:** `#ef4444` (Rot)
- **Info:** `#00d4ff` (Blau)
## 📱 Responsive Design für E-Mail:
### Media Queries
```css
@media (max-width: 600px) {
.email-container {
margin: 0 10px;
}
.email-content {
padding: 20px 15px;
}
}
```
### Mobile-First Approach
- **Max-Width:** 600px für Container
- **Padding:** Reduziert auf mobilen Geräten
- **Schriftgrößen:** Angepasst für kleine Bildschirme
## 🔧 Template-Optimierungen:
### 1. Inline-CSS verwenden
```html
<div style="background-color: #1e293b; padding: 20px;">
Inhalt
</div>
```
### 2. Tabellen-Layout für komplexe Strukturen
```html
<table width="100%" cellpadding="0" cellspacing="0">
<tr>
<td style="padding: 20px;">
Inhalt
</td>
</tr>
</table>
```
### 3. Fallback-Farben definieren
```css
background-color: #00d4ff; /* Fallback */
background: linear-gradient(135deg, #00d4ff, #0891b2); /* Modern */
```
## 📊 E-Mail-Client-Test-Matrix:
| Client | Gradients | Backdrop-Filter | Box-Shadow | Custom Fonts |
|--------|-----------|-----------------|------------|--------------|
| Gmail | ❌ | ❌ | ❌ | ❌ |
| Outlook | ❌ | ❌ | ❌ | ❌ |
| Apple Mail | ✅ | ❌ | ✅ | ✅ |
| Thunderbird | ❌ | ❌ | ❌ | ❌ |
| Yahoo Mail | ❌ | ❌ | ❌ | ❌ |
## 🚀 Empfohlene Template-Struktur:
### 1. Kompatible Versionen erstellen
- `welcome-email-compatible.html` - E-Mail-Client-optimiert
- `welcome-email.html` - Moderne Browser-Version
### 2. Fallback-Strategien
- **HTML-Version** für moderne Clients
- **Text-Version** für einfache Clients
- **Kompatible HTML** für E-Mail-Clients
### 3. Testing
- **Litmus** oder **Email on Acid** für E-Mail-Testing
- **Verschiedene Clients** testen
- **Mobile Geräte** berücksichtigen
## 📝 Best Practices:
### 1. Einfache Struktur
- **Minimale CSS** verwenden
- **Inline-Styles** bevorzugen
- **Tabellen-Layout** für komplexe Strukturen
### 2. Farben
- **Hex-Codes** verwenden (`#00d4ff`)
- **Keine Transparenz** (`rgba()` vermeiden)
- **Hoher Kontrast** für Lesbarkeit
### 3. Typografie
- **Web-Safe Fonts** verwenden
- **Fallback-Fonts** definieren
- **Angemessene Schriftgrößen** (mind. 14px)
### 4. Bilder
- **Alt-Text** immer angeben
- **Optimierte Größen** verwenden
- **Fallback-Farben** definieren
## 🎯 Sofortige Lösung:
Verwende die **kompatiblen Versionen** der Templates:
- `welcome-email-compatible.html`
- `reset-password-compatible.html`
Diese verwenden nur E-Mail-Client-kompatible CSS-Features und sollten in allen Clients korrekt angezeigt werden.
---
**Tipp:** Teste immer in verschiedenen E-Mail-Clients, bevor du die Templates produktiv einsetzt! 📧✨

View File

@@ -0,0 +1,157 @@
# NinjaCross E-Mail Templates
Diese E-Mail-Templates sind im gleichen Design wie die NinjaCross-Website erstellt und können in Supabase für die E-Mail-Authentifizierung verwendet werden.
## 📁 Dateien
### Welcome Email (Registrierung)
- `welcome-email.html` - Vollständige HTML-Version mit modernem Design
- `welcome-email-compatible.html` - **EMPFOHLEN** - E-Mail-Client-optimierte Version
- `welcome-email-simple.html` - Vereinfachte HTML-Version für bessere E-Mail-Client-Kompatibilität
- `welcome-email.txt` - Text-Version für E-Mail-Client-Kompatibilität
### Invite User (Einladung)
- `invite-user.html` - HTML-Version für Benutzereinladungen
- `invite-user.txt` - Text-Version für Benutzereinladungen
### Magic Link (Passwortlose Anmeldung)
- `magic-link.html` - HTML-Version für Magic Link Anmeldung
- `magic-link.txt` - Text-Version für Magic Link Anmeldung
### Change Email Address (E-Mail-Adresse ändern)
- `change-email.html` - HTML-Version für E-Mail-Adressen-Änderung
- `change-email.txt` - Text-Version für E-Mail-Adressen-Änderung
### Reset Password (Passwort zurücksetzen)
- `reset-password.html` - HTML-Version für Passwort-Reset
- `reset-password-compatible.html` - E-Mail-Client-optimierte Version
- `reset-password-optimized.html` - **EMPFOHLEN** - Verbesserte Kompatibilität
- `reset-password-table.html` - **MAXIMALE KOMPATIBILITÄT** - Tabellen-basiert
- `reset-password.txt` - Text-Version für Passwort-Reset
### Reauthentication (Erneute Authentifizierung)
- `reauthentication.html` - HTML-Version für erneute Authentifizierung
- `reauthentication.txt` - Text-Version für erneute Authentifizierung
## 🚀 Supabase Setup
### 1. Supabase Dashboard öffnen
1. Gehe zu deinem Supabase-Projekt
2. Navigiere zu **Authentication****Email Templates**
### 2. E-Mail-Templates anpassen
1. Wähle das entsprechende Template aus der Liste:
- **Confirm signup** → `welcome-email-compatible.html` / `welcome-email.txt`
- **Invite user** → `invite-user.html` / `invite-user.txt`
- **Magic Link** → `magic-link.html` / `magic-link.txt`
- **Change Email Address** → `change-email.html` / `change-email.txt`
- **Reset Password** → `reset-password-table.html` / `reset-password.txt`
- **Reauthentication** → `reauthentication.html` / `reauthentication.txt`
2. Ersetze den Standard-HTML-Code mit dem Inhalt aus der entsprechenden `.html` Datei
3. Ersetze den Standard-Text mit dem Inhalt aus der entsprechenden `.txt` Datei
**Empfohlene kompatible Versionen** für bessere E-Mail-Client-Unterstützung
### 3. Template-Variablen
Die folgenden Variablen werden automatisch von Supabase ersetzt:
- `{{ .ConfirmationURL }}` - Link zur E-Mail-Bestätigung/Aktion
- `{{ .SiteURL }}` - URL deiner Website
- `{{ .Email }}` - E-Mail-Adresse des Benutzers
- `{{ .NewEmail }}` - Neue E-Mail-Adresse (nur bei Change Email)
- `{{ .InvitedBy }}` - Name des einladenden Benutzers (nur bei Invite User)
### 4. E-Mail-Provider konfigurieren
Stelle sicher, dass dein E-Mail-Provider in Supabase konfiguriert ist:
- **SMTP Settings** in **Authentication****Settings**
- Oder verwende **Supabase Edge Functions** für erweiterte E-Mail-Funktionen
## 🎨 Design-Features
### Farbpalette
- **Hintergrund:** #0a0a0f (Dunkelblau)
- **Container:** #1e293b (Dunkelgrau)
- **Akzent:** #00d4ff (Neon-Blau)
- **Text:** #e2e8f0 (Hellgrau)
### Typografie
- **Font:** Inter (Google Fonts)
- **Fallback:** Arial, sans-serif
- **Gewichtungen:** 300, 400, 500, 600, 700
### Responsive Design
- **Mobile-optimiert** für alle Bildschirmgrößen
- **Flexible Container** mit max-width
- **Angepasste Schriftgrößen** für mobile Geräte
## 🔧 Anpassungen
### Farben ändern
Suche und ersetze die Hex-Codes in den CSS-Styles:
```css
/* Neon-Blau ändern */
#00d4ff #deine-farbe
#0891b2 #deine-farbe-dunkler
```
### Logo anpassen
Ändere den Logo-Text in der HTML-Datei:
```html
<div class="logo">🥷 DEIN-LOGO</div>
```
### Features hinzufügen/entfernen
Bearbeite den `.features-section` Bereich in der HTML-Datei.
## 📱 E-Mail-Client-Kompatibilität
### Unterstützte Clients
- ✅ Gmail (Web & App)
- ✅ Outlook (Web & App)
- ✅ Apple Mail
- ✅ Thunderbird
- ✅ Yahoo Mail
### Fallback-Strategien
1. **HTML-Version** für moderne Clients
2. **Text-Version** für einfache Clients
3. **Inline-CSS** für bessere Kompatibilität
## 🧪 Testing
### E-Mail-Test
1. Erstelle einen Test-Account
2. Registriere dich mit einer Test-E-Mail
3. Überprüfe das E-Mail-Design in verschiedenen Clients
### Browser-Test
1. Öffne `welcome-email.html` in einem Browser
2. Teste die responsive Darstellung
3. Überprüfe alle Links und Buttons
## 🚨 E-Mail-Client-Kompatibilität
**WICHTIG:** Die ursprünglichen Templates verwenden moderne CSS-Features, die in E-Mail-Clients nicht unterstützt werden.
### Empfohlene Versionen:
- **Welcome Email:** `welcome-email-compatible.html`
- **Reset Password:** `reset-password-table.html` ⭐ (für maximale Kompatibilität)
### Was wurde optimiert:
-**Gradients** → ✅ **Einfache Hintergrundfarben**
-**Backdrop-Filter** → ✅ **Standard-Container**
-**Custom Fonts** → ✅ **Arial, sans-serif**
-**Transparente Farben** → ✅ **Hex-Codes**
Siehe `EMAIL-COMPATIBILITY-GUIDE.md` und `URL-CONFIGURATION-GUIDE.md` für Details.
## 📞 Support
Bei Fragen oder Problemen:
- Überprüfe die Supabase-Dokumentation
- Teste mit verschiedenen E-Mail-Providern
- Verwende E-Mail-Testing-Tools wie Litmus oder Email on Acid
- Verwende die **kompatiblen Versionen** für bessere E-Mail-Client-Unterstützung
---
**Erstellt für NinjaCross Timer Leaderboard** 🥷

View File

@@ -0,0 +1,119 @@
# NinjaCross E-Mail Templates - Übersicht
## 📋 Alle verfügbaren Templates
| Template | HTML-Datei | Text-Datei | Beschreibung |
|----------|------------|------------|--------------|
| **Confirm signup** | `welcome-email.html` | `welcome-email.txt` | Willkommens-E-Mail für neue Registrierungen |
| **Invite user** | `invite-user.html` | `invite-user.txt` | Einladungs-E-Mail für neue Benutzer |
| **Magic Link** | `magic-link.html` | `magic-link.txt` | Passwortlose Anmeldung per Magic Link |
| **Change Email Address** | `change-email.html` | `change-email.txt` | E-Mail-Adressen-Änderung bestätigen |
| **Reset Password** | `reset-password.html` | `reset-password.txt` | Passwort zurücksetzen |
| **Reauthentication** | `reauthentication.html` | `reauthentication.txt` | Erneute Authentifizierung für sensible Aktionen |
## 🎨 Design-Features
### Einheitliche Gestaltung
- **Dunkles Design** mit Neon-Blau-Akzenten (#00d4ff)
- **Gradient-Titel** mit den NinjaCross-Farben
- **Glas-Effekt Container** mit Backdrop-Filter
- **Responsive Design** für alle Geräte
- **Inter-Font** für moderne Typografie
### Sicherheits-Features
- **Zeitlimits** für alle Links (15 Min - 24 Std)
- **Sicherheitshinweise** in jeder E-Mail
- **Klare Call-to-Action Buttons**
- **Warnungen** bei verdächtigen Aktivitäten
## 🔧 Template-Variablen
### Standard-Variablen (alle Templates)
- `{{ .ConfirmationURL }}` - Link zur Bestätigung/Aktion
- `{{ .SiteURL }}` - URL der Website
- `{{ .Email }}` - E-Mail-Adresse des Benutzers
### Spezielle Variablen
- `{{ .NewEmail }}` - Neue E-Mail-Adresse (nur Change Email)
- `{{ .InvitedBy }}` - Name des einladenden Benutzers (nur Invite User)
## 📱 E-Mail-Client-Kompatibilität
### Unterstützte Clients
- ✅ Gmail (Web & App)
- ✅ Outlook (Web & App)
- ✅ Apple Mail
- ✅ Thunderbird
- ✅ Yahoo Mail
- ✅ Mobile E-Mail-Apps
### Fallback-Strategien
1. **HTML-Version** für moderne Clients
2. **Text-Version** für einfache Clients
3. **Inline-CSS** für bessere Kompatibilität
4. **Responsive Design** für mobile Geräte
## 🚀 Supabase Integration
### Setup-Schritte
1. **Supabase Dashboard** → Authentication → Email Templates
2. **Template auswählen** (z.B. "Confirm signup")
3. **HTML-Code ersetzen** mit Inhalt aus `.html` Datei
4. **Text-Code ersetzen** mit Inhalt aus `.txt` Datei
5. **Speichern** und testen
### E-Mail-Provider
- **SMTP Settings** in Authentication → Settings
- **Supabase Edge Functions** für erweiterte Funktionen
- **Custom SMTP** für eigene E-Mail-Server
## 🧪 Testing
### E-Mail-Test
1. **Test-Account** erstellen
2. **Template auslösen** (Registrierung, Reset, etc.)
3. **E-Mail prüfen** in verschiedenen Clients
4. **Links testen** auf Funktionalität
### Browser-Test
1. **HTML-Datei** in Browser öffnen
2. **Responsive Design** testen
3. **Links und Buttons** überprüfen
4. **Design-Konsistenz** sicherstellen
## 📊 Template-Statistiken
### Dateigrößen
- **HTML-Templates**: ~8-12 KB
- **Text-Templates**: ~1-2 KB
- **Gesamt**: ~60 KB für alle Templates
### Performance
- **Ladezeit**: < 1 Sekunde
- **Rendering**: Optimiert für alle Clients
- **Mobile**: Vollständig responsive
## 🔒 Sicherheit
### Link-Sicherheit
- **Zeitlimits**: 15 Min (Reauth) bis 24 Std (Reset)
- **Einmalige Nutzung**: Links werden nach Verwendung ungültig
- **HTTPS**: Alle Links verwenden sichere Verbindungen
### Datenschutz
- **Keine sensiblen Daten** in E-Mail-Inhalten
- **Minimale Informationen** in Templates
- **DSGVO-konform** gestaltet
## 🎯 Nächste Schritte
1. **Supabase konfigurieren** mit neuen Templates
2. **E-Mail-Provider** einrichten
3. **Test-E-Mails** versenden
4. **Design anpassen** falls gewünscht
5. **Monitoring** einrichten für E-Mail-Delivery
---
**Erstellt für NinjaCross Timer Leaderboard** 🥷
**Alle Templates sind bereit für den produktiven Einsatz!**

View File

@@ -0,0 +1,174 @@
# URL-Konfiguration für NinjaCross E-Mails
## 🚨 Problem: Reset-Password E-Mail funktioniert nicht
**Deine Server-URL:** `ninja.reptilfpv.de:3000`
## 🔧 Mögliche Ursachen:
### 1. Supabase URL-Konfiguration
Supabase muss wissen, wohin die Bestätigungslinks führen sollen.
**Lösung:**
1. **Supabase Dashboard****Authentication****URL Configuration**
2. **Site URL** setzen auf: `https://ninja.reptilfpv.de:3000`
3. **Redirect URLs** hinzufügen:
- `https://ninja.reptilfpv.de:3000/**`
- `http://ninja.reptilfpv.de:3000/**` (falls HTTP verwendet wird)
### 2. HTTPS vs HTTP Problem
E-Mail-Clients blockieren oft HTTP-Links in E-Mails.
**Lösung:**
- **HTTPS verwenden** für alle Links
- **SSL-Zertifikat** für `ninja.reptilfpv.de:3000` einrichten
- **Port 3000** in der URL kann problematisch sein
### 3. E-Mail-Client-Sicherheit
Manche E-Mail-Clients blockieren Links mit Ports oder verdächtigen Domains.
**Lösung:**
- **Standard-Ports** verwenden (80 für HTTP, 443 für HTTPS)
- **Subdomain** verwenden: `ninja.reptilfpv.de` ohne Port
- **Reverse Proxy** einrichten (Nginx/Apache)
## 🚀 Empfohlene Lösungen:
### Option 1: HTTPS mit Standard-Port
```
https://ninja.reptilfpv.de
```
- **Port 443** (Standard HTTPS)
- **SSL-Zertifikat** erforderlich
- **Reverse Proxy** (Nginx) einrichten
### Option 2: Subdomain ohne Port
```
https://ninja.reptilfpv.de
```
- **Port 3000** intern weiterleiten
- **Nginx** als Reverse Proxy
- **SSL-Zertifikat** für Subdomain
### Option 3: Hauptdomain verwenden
```
https://reptilfpv.de/ninja
```
- **Hauptdomain** mit Pfad
- **Bessere E-Mail-Client-Kompatibilität**
- **Standard-Ports** verwenden
## 🔧 Nginx Reverse Proxy Setup:
### 1. Nginx-Konfiguration
```nginx
server {
listen 80;
listen 443 ssl;
server_name ninja.reptilfpv.de;
ssl_certificate /path/to/certificate.crt;
ssl_certificate_key /path/to/private.key;
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
}
```
### 2. SSL-Zertifikat
```bash
# Let's Encrypt SSL-Zertifikat
sudo certbot --nginx -d ninja.reptilfpv.de
```
## 📧 E-Mail-Template-Optimierungen:
### 1. Absolute URLs verwenden
```html
<!-- ❌ Relativ -->
<a href="/reset-password">Reset</a>
<!-- ✅ Absolut -->
<a href="https://ninja.reptilfpv.de/reset-password">Reset</a>
```
### 2. HTTPS erzwingen
```html
<!-- ✅ Immer HTTPS verwenden -->
<a href="https://ninja.reptilfpv.de{{ .ConfirmationURL }}">Reset</a>
```
### 3. Fallback-URLs
```html
<!-- ✅ Mit Fallback -->
<a href="{{ .ConfirmationURL }}">Reset</a>
<p>Falls der Link nicht funktioniert: https://ninja.reptilfpv.de</p>
```
## 🧪 Testing:
### 1. E-Mail-Links testen
```bash
# Test-URLs
curl -I https://ninja.reptilfpv.de:3000
curl -I https://ninja.reptilfpv.de
curl -I http://ninja.reptilfpv.de:3000
```
### 2. E-Mail-Client-Test
- **Gmail** - Links in E-Mail testen
- **Outlook** - Sicherheitswarnungen prüfen
- **Apple Mail** - Link-Funktionalität testen
### 3. Supabase-Logs prüfen
- **Authentication Logs** in Supabase Dashboard
- **Fehler-Meldungen** analysieren
- **Redirect-URLs** überprüfen
## 🎯 Sofortige Lösung:
### 1. Supabase konfigurieren
```
Site URL: https://ninja.reptilfpv.de:3000
Redirect URLs:
- https://ninja.reptilfpv.de:3000/**
- https://ninja.reptilfpv.de:3000/auth/callback
```
### 2. Optimierte Templates verwenden
- **`reset-password-optimized.html`** - Verbesserte Kompatibilität
- **`reset-password-table.html`** - Tabellen-basiert für maximale Kompatibilität
### 3. HTTPS einrichten
- **SSL-Zertifikat** für `ninja.reptilfpv.de:3000`
- **Oder** Reverse Proxy mit Standard-Port
## 📞 Debugging:
### 1. E-Mail-Links prüfen
- **Link in E-Mail** kopieren und in Browser testen
- **URL-Struktur** analysieren
- **Redirects** verfolgen
### 2. Supabase-Logs
- **Authentication** → **Logs** in Supabase Dashboard
- **Fehler-Meldungen** suchen
- **URL-Parameter** prüfen
### 3. Browser-Entwicklertools
- **Network-Tab** für Redirects
- **Console** für JavaScript-Fehler
- **Security-Tab** für HTTPS-Probleme
---
**Empfehlung:** Verwende `reset-password-table.html` mit HTTPS und Standard-Port für beste Kompatibilität! 🚀

View File

@@ -0,0 +1,274 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>E-Mail-Adresse ändern - NinjaCross</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', sans-serif;
background: #0a0a0f;
color: #ffffff;
line-height: 1.6;
margin: 0;
padding: 0;
}
.email-container {
max-width: 600px;
margin: 0 auto;
background: #0a0a0f;
background-image:
radial-gradient(circle at 20% 80%, #1a1a2e 0%, transparent 50%),
radial-gradient(circle at 80% 20%, #16213e 0%, transparent 50%),
radial-gradient(circle at 40% 40%, #0f3460 0%, transparent 50%);
}
.email-header {
text-align: center;
padding: 3rem 2rem 2rem;
}
.logo {
font-size: 2.5rem;
font-weight: 700;
background: linear-gradient(135deg, #00d4ff, #ff6b35, #ffd700);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 0.5rem;
letter-spacing: -0.02em;
}
.tagline {
color: #94a3b8;
font-size: 1rem;
font-weight: 400;
}
.email-content {
background: rgba(30, 41, 59, 0.95);
backdrop-filter: blur(20px);
border: 1px solid rgba(51, 65, 85, 0.3);
margin: 0 2rem;
padding: 2.5rem;
border-radius: 1.5rem;
box-shadow:
0 25px 50px rgba(0, 0, 0, 0.3),
0 0 0 1px rgba(0, 212, 255, 0.1);
}
.change-title {
font-size: 1.75rem;
font-weight: 600;
color: #e2e8f0;
text-align: center;
margin-bottom: 1.5rem;
}
.change-message {
color: #cbd5e1;
font-size: 1rem;
margin-bottom: 2rem;
text-align: center;
}
.email-info {
background: rgba(51, 65, 85, 0.3);
border: 1px solid rgba(0, 212, 255, 0.1);
border-radius: 0.75rem;
padding: 1.5rem;
margin-bottom: 2rem;
}
.email-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 0;
border-bottom: 1px solid rgba(51, 65, 85, 0.5);
}
.email-row:last-child {
border-bottom: none;
}
.email-label {
color: #94a3b8;
font-size: 0.9rem;
font-weight: 500;
}
.email-value {
color: #e2e8f0;
font-size: 0.9rem;
font-weight: 600;
}
.cta-button {
display: inline-block;
width: 100%;
padding: 1rem 2rem;
background: linear-gradient(135deg, #00d4ff, #0891b2);
color: white;
text-decoration: none;
border-radius: 0.75rem;
font-weight: 600;
font-size: 1rem;
text-align: center;
text-transform: uppercase;
letter-spacing: 0.05em;
box-shadow: 0 4px 12px rgba(0, 212, 255, 0.3);
transition: all 0.2s ease;
margin-bottom: 2rem;
}
.cta-button:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 212, 255, 0.4);
}
.warning-info {
background: rgba(245, 158, 11, 0.1);
border: 1px solid rgba(245, 158, 11, 0.3);
border-radius: 0.75rem;
padding: 1rem;
margin-top: 2rem;
}
.warning-title {
color: #f59e0b;
font-weight: 600;
font-size: 0.9rem;
margin-bottom: 0.5rem;
text-align: center;
}
.warning-text {
color: #fbbf24;
font-size: 0.85rem;
text-align: center;
}
.email-footer {
text-align: center;
padding: 2rem;
color: #64748b;
font-size: 0.875rem;
}
.footer-links {
margin-top: 1rem;
}
.footer-links a {
color: #00d4ff;
text-decoration: none;
margin: 0 1rem;
}
.footer-links a:hover {
color: #0891b2;
}
.divider {
height: 1px;
background: linear-gradient(90deg, transparent, #334155, transparent);
margin: 2rem 0;
}
/* Mobile Responsive */
@media (max-width: 600px) {
.email-content {
margin: 0 1rem;
padding: 2rem;
}
.email-header {
padding: 2rem 1rem 1rem;
}
.logo {
font-size: 2rem;
}
.change-title {
font-size: 1.5rem;
}
.email-row {
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
}
}
</style>
</head>
<body>
<div class="email-container">
<!-- Header -->
<div class="email-header">
<div class="logo">🥷 NINJACROSS</div>
<div class="tagline">Die ultimative Timer-Rangliste</div>
</div>
<!-- Main Content -->
<div class="email-content">
<h1 class="change-title">E-Mail-Adresse ändern 📧</h1>
<p class="change-message">
Du möchtest deine E-Mail-Adresse ändern. Bestätige die neue E-Mail-Adresse,
um die Änderung abzuschließen.
</p>
<div class="email-info">
<div class="email-row">
<span class="email-label">Aktuelle E-Mail:</span>
<span class="email-value">{{ .Email }}</span>
</div>
<div class="email-row">
<span class="email-label">Neue E-Mail:</span>
<span class="email-value">{{ .NewEmail }}</span>
</div>
</div>
<a href="{{ .ConfirmationURL }}" class="cta-button">
✅ E-Mail-Adresse bestätigen
</a>
<div class="warning-info">
<div class="warning-title">⚠️ Wichtiger Hinweis</div>
<div class="warning-text">
Nach der Bestätigung wird deine neue E-Mail-Adresse für alle zukünftigen
Benachrichtigungen und Anmeldungen verwendet.
</div>
</div>
</div>
<!-- Footer -->
<div class="email-footer">
<p>
Falls du diese Änderung nicht angefordert hast, kannst du diese E-Mail ignorieren.
</p>
<div class="footer-links">
<a href="{{ .SiteURL }}">Zur Website</a>
<a href="{{ .SiteURL }}/support">Support</a>
<a href="{{ .SiteURL }}/privacy">Datenschutz</a>
</div>
<p style="margin-top: 1.5rem; font-size: 0.75rem; color: #64748b;">
© 2024 NinjaCross. Alle Rechte vorbehalten.
</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,29 @@
🥷 NINJACROSS - Die ultimative Timer-Rangliste
================================================
E-Mail-Adresse ändern 📧
Du möchtest deine E-Mail-Adresse ändern. Bestätige die neue E-Mail-Adresse,
um die Änderung abzuschließen.
📧 E-Mail-Informationen:
- Aktuelle E-Mail: {{ .Email }}
- Neue E-Mail: {{ .NewEmail }}
✅ E-Mail-Adresse bestätigen:
{{ .ConfirmationURL }}
⚠️ Wichtiger Hinweis:
Nach der Bestätigung wird deine neue E-Mail-Adresse für alle zukünftigen
Benachrichtigungen und Anmeldungen verwendet.
================================================
Falls du diese Änderung nicht angefordert hast, kannst du diese E-Mail ignorieren.
Links:
- Zur Website: {{ .SiteURL }}
- Support: {{ .SiteURL }}/support
- Datenschutz: {{ .SiteURL }}/privacy
© 2024 NinjaCross. Alle Rechte vorbehalten.

View File

@@ -0,0 +1,300 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Einladung zu NinjaCross</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', sans-serif;
background: #0a0a0f;
color: #ffffff;
line-height: 1.6;
margin: 0;
padding: 0;
}
.email-container {
max-width: 600px;
margin: 0 auto;
background: #0a0a0f;
background-image:
radial-gradient(circle at 20% 80%, #1a1a2e 0%, transparent 50%),
radial-gradient(circle at 80% 20%, #16213e 0%, transparent 50%),
radial-gradient(circle at 40% 40%, #0f3460 0%, transparent 50%);
}
.email-header {
text-align: center;
padding: 3rem 2rem 2rem;
}
.logo {
font-size: 2.5rem;
font-weight: 700;
background: linear-gradient(135deg, #00d4ff, #ff6b35, #ffd700);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 0.5rem;
letter-spacing: -0.02em;
}
.tagline {
color: #94a3b8;
font-size: 1rem;
font-weight: 400;
}
.email-content {
background: rgba(30, 41, 59, 0.95);
backdrop-filter: blur(20px);
border: 1px solid rgba(51, 65, 85, 0.3);
margin: 0 2rem;
padding: 2.5rem;
border-radius: 1.5rem;
box-shadow:
0 25px 50px rgba(0, 0, 0, 0.3),
0 0 0 1px rgba(0, 212, 255, 0.1);
}
.invite-title {
font-size: 1.75rem;
font-weight: 600;
color: #e2e8f0;
text-align: center;
margin-bottom: 1.5rem;
}
.invite-message {
color: #cbd5e1;
font-size: 1rem;
margin-bottom: 2rem;
text-align: center;
}
.inviter-info {
background: rgba(51, 65, 85, 0.3);
border: 1px solid rgba(0, 212, 255, 0.1);
border-radius: 0.75rem;
padding: 1.5rem;
margin-bottom: 2rem;
text-align: center;
}
.inviter-name {
font-size: 1.25rem;
font-weight: 600;
color: #00d4ff;
margin-bottom: 0.5rem;
}
.inviter-role {
color: #94a3b8;
font-size: 0.9rem;
}
.cta-button {
display: inline-block;
width: 100%;
padding: 1rem 2rem;
background: linear-gradient(135deg, #00d4ff, #0891b2);
color: white;
text-decoration: none;
border-radius: 0.75rem;
font-weight: 600;
font-size: 1rem;
text-align: center;
text-transform: uppercase;
letter-spacing: 0.05em;
box-shadow: 0 4px 12px rgba(0, 212, 255, 0.3);
transition: all 0.2s ease;
margin-bottom: 2rem;
}
.cta-button:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 212, 255, 0.4);
}
.features-section {
margin-top: 2rem;
}
.features-title {
font-size: 1.25rem;
font-weight: 600;
color: #e2e8f0;
margin-bottom: 1rem;
text-align: center;
}
.feature-item {
display: flex;
align-items: center;
margin-bottom: 1rem;
padding: 1rem;
background: rgba(51, 65, 85, 0.3);
border-radius: 0.75rem;
border: 1px solid rgba(0, 212, 255, 0.1);
}
.feature-icon {
font-size: 1.5rem;
margin-right: 1rem;
width: 2rem;
text-align: center;
}
.feature-text {
color: #cbd5e1;
font-size: 0.95rem;
}
.email-footer {
text-align: center;
padding: 2rem;
color: #64748b;
font-size: 0.875rem;
}
.footer-links {
margin-top: 1rem;
}
.footer-links a {
color: #00d4ff;
text-decoration: none;
margin: 0 1rem;
}
.footer-links a:hover {
color: #0891b2;
}
.divider {
height: 1px;
background: linear-gradient(90deg, transparent, #334155, transparent);
margin: 2rem 0;
}
/* Mobile Responsive */
@media (max-width: 600px) {
.email-content {
margin: 0 1rem;
padding: 2rem;
}
.email-header {
padding: 2rem 1rem 1rem;
}
.logo {
font-size: 2rem;
}
.invite-title {
font-size: 1.5rem;
}
.feature-item {
flex-direction: column;
text-align: center;
}
.feature-icon {
margin-right: 0;
margin-bottom: 0.5rem;
}
}
</style>
</head>
<body>
<div class="email-container">
<!-- Header -->
<div class="email-header">
<div class="logo">🥷 NINJACROSS</div>
<div class="tagline">Die ultimative Timer-Rangliste</div>
</div>
<!-- Main Content -->
<div class="email-content">
<h1 class="invite-title">Du wurdest eingeladen! 🎉</h1>
<p class="invite-message">
Du wurdest von einem Administrator zu NinjaCross eingeladen.
Klicke auf den Button unten, um dein Konto zu erstellen und der Community beizutreten.
</p>
<div class="inviter-info">
<div class="inviter-name">{{ .InvitedBy }}</div>
<div class="inviter-role">hat dich zu NinjaCross eingeladen</div>
</div>
<a href="{{ .ConfirmationURL }}" class="cta-button">
🚀 Konto erstellen
</a>
<div class="divider"></div>
<!-- Features Section -->
<div class="features-section">
<h2 class="features-title">Was dich erwartet:</h2>
<div class="feature-item">
<div class="feature-icon">🏃‍♂️</div>
<div class="feature-text">
<strong>Timer-Tracking:</strong> Erfasse deine Zeiten und verfolge deinen Fortschritt
</div>
</div>
<div class="feature-item">
<div class="feature-icon">🏆</div>
<div class="feature-text">
<strong>Leaderboards:</strong> Vergleiche dich mit anderen Spielern und erreiche die Spitze
</div>
</div>
<div class="feature-item">
<div class="feature-icon">📊</div>
<div class="feature-text">
<strong>Statistiken:</strong> Detaillierte Analysen deiner Performance und Verbesserungen
</div>
</div>
<div class="feature-item">
<div class="feature-icon">🌍</div>
<div class="feature-text">
<strong>Multi-Location:</strong> Spiele an verschiedenen Standorten und sammle Erfahrungen
</div>
</div>
</div>
</div>
<!-- Footer -->
<div class="email-footer">
<p>
Falls du diese Einladung nicht erwartet hast, kannst du diese E-Mail ignorieren.
</p>
<div class="footer-links">
<a href="{{ .SiteURL }}">Zur Website</a>
<a href="{{ .SiteURL }}/support">Support</a>
<a href="{{ .SiteURL }}/privacy">Datenschutz</a>
</div>
<p style="margin-top: 1.5rem; font-size: 0.75rem; color: #64748b;">
© 2024 NinjaCross. Alle Rechte vorbehalten.
</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,34 @@
🥷 NINJACROSS - Die ultimative Timer-Rangliste
================================================
Du wurdest eingeladen! 🎉
Du wurdest von einem Administrator zu NinjaCross eingeladen.
Klicke auf den Link unten, um dein Konto zu erstellen und der Community beizutreten.
👤 Einladung von: {{ .InvitedBy }}
🚀 Konto erstellen:
{{ .ConfirmationURL }}
Was dich erwartet:
==================
🏃‍♂️ Timer-Tracking: Erfasse deine Zeiten und verfolge deinen Fortschritt
🏆 Leaderboards: Vergleiche dich mit anderen Spielern und erreiche die Spitze
📊 Statistiken: Detaillierte Analysen deiner Performance und Verbesserungen
🌍 Multi-Location: Spiele an verschiedenen Standorten und sammle Erfahrungen
================================================
Falls du diese Einladung nicht erwartet hast, kannst du diese E-Mail ignorieren.
Links:
- Zur Website: {{ .SiteURL }}
- Support: {{ .SiteURL }}/support
- Datenschutz: {{ .SiteURL }}/privacy
© 2024 NinjaCross. Alle Rechte vorbehalten.

View File

@@ -0,0 +1,257 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Magic Link - NinjaCross</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', sans-serif;
background: #0a0a0f;
color: #ffffff;
line-height: 1.6;
margin: 0;
padding: 0;
}
.email-container {
max-width: 600px;
margin: 0 auto;
background: #0a0a0f;
background-image:
radial-gradient(circle at 20% 80%, #1a1a2e 0%, transparent 50%),
radial-gradient(circle at 80% 20%, #16213e 0%, transparent 50%),
radial-gradient(circle at 40% 40%, #0f3460 0%, transparent 50%);
}
.email-header {
text-align: center;
padding: 3rem 2rem 2rem;
}
.logo {
font-size: 2.5rem;
font-weight: 700;
background: linear-gradient(135deg, #00d4ff, #ff6b35, #ffd700);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 0.5rem;
letter-spacing: -0.02em;
}
.tagline {
color: #94a3b8;
font-size: 1rem;
font-weight: 400;
}
.email-content {
background: rgba(30, 41, 59, 0.95);
backdrop-filter: blur(20px);
border: 1px solid rgba(51, 65, 85, 0.3);
margin: 0 2rem;
padding: 2.5rem;
border-radius: 1.5rem;
box-shadow:
0 25px 50px rgba(0, 0, 0, 0.3),
0 0 0 1px rgba(0, 212, 255, 0.1);
}
.magic-title {
font-size: 1.75rem;
font-weight: 600;
color: #e2e8f0;
text-align: center;
margin-bottom: 1.5rem;
}
.magic-message {
color: #cbd5e1;
font-size: 1rem;
margin-bottom: 2rem;
text-align: center;
}
.magic-info {
background: rgba(51, 65, 85, 0.3);
border: 1px solid rgba(0, 212, 255, 0.1);
border-radius: 0.75rem;
padding: 1.5rem;
margin-bottom: 2rem;
text-align: center;
}
.magic-icon {
font-size: 3rem;
margin-bottom: 1rem;
display: block;
}
.magic-description {
color: #94a3b8;
font-size: 0.9rem;
margin-bottom: 1rem;
}
.cta-button {
display: inline-block;
width: 100%;
padding: 1rem 2rem;
background: linear-gradient(135deg, #00d4ff, #0891b2);
color: white;
text-decoration: none;
border-radius: 0.75rem;
font-weight: 600;
font-size: 1rem;
text-align: center;
text-transform: uppercase;
letter-spacing: 0.05em;
box-shadow: 0 4px 12px rgba(0, 212, 255, 0.3);
transition: all 0.2s ease;
margin-bottom: 2rem;
}
.cta-button:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 212, 255, 0.4);
}
.security-info {
background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.3);
border-radius: 0.75rem;
padding: 1rem;
margin-top: 2rem;
}
.security-title {
color: #22c55e;
font-weight: 600;
font-size: 0.9rem;
margin-bottom: 0.5rem;
text-align: center;
}
.security-text {
color: #86efac;
font-size: 0.85rem;
text-align: center;
}
.email-footer {
text-align: center;
padding: 2rem;
color: #64748b;
font-size: 0.875rem;
}
.footer-links {
margin-top: 1rem;
}
.footer-links a {
color: #00d4ff;
text-decoration: none;
margin: 0 1rem;
}
.footer-links a:hover {
color: #0891b2;
}
.divider {
height: 1px;
background: linear-gradient(90deg, transparent, #334155, transparent);
margin: 2rem 0;
}
/* Mobile Responsive */
@media (max-width: 600px) {
.email-content {
margin: 0 1rem;
padding: 2rem;
}
.email-header {
padding: 2rem 1rem 1rem;
}
.logo {
font-size: 2rem;
}
.magic-title {
font-size: 1.5rem;
}
.magic-icon {
font-size: 2.5rem;
}
}
</style>
</head>
<body>
<div class="email-container">
<!-- Header -->
<div class="email-header">
<div class="logo">🥷 NINJACROSS</div>
<div class="tagline">Die ultimative Timer-Rangliste</div>
</div>
<!-- Main Content -->
<div class="email-content">
<h1 class="magic-title">Dein Magic Link ist da! ✨</h1>
<p class="magic-message">
Du hast einen Magic Link angefordert. Klicke auf den Button unten,
um dich sicher und ohne Passwort bei NinjaCross anzumelden.
</p>
<div class="magic-info">
<span class="magic-icon">🔗</span>
<div class="magic-description">
Dieser Link ist sicher und führt dich direkt zu deinem Konto
</div>
</div>
<a href="{{ .ConfirmationURL }}" class="cta-button">
🚀 Anmelden
</a>
<div class="security-info">
<div class="security-title">🔒 Sicherheitshinweis</div>
<div class="security-text">
Dieser Link ist nur für dich bestimmt und verfällt nach 24 Stunden.
Teile ihn nicht mit anderen Personen.
</div>
</div>
</div>
<!-- Footer -->
<div class="email-footer">
<p>
Falls du diesen Magic Link nicht angefordert hast, kannst du diese E-Mail ignorieren.
</p>
<div class="footer-links">
<a href="{{ .SiteURL }}">Zur Website</a>
<a href="{{ .SiteURL }}/support">Support</a>
<a href="{{ .SiteURL }}/privacy">Datenschutz</a>
</div>
<p style="margin-top: 1.5rem; font-size: 0.75rem; color: #64748b;">
© 2024 NinjaCross. Alle Rechte vorbehalten.
</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,25 @@
🥷 NINJACROSS - Die ultimative Timer-Rangliste
================================================
Dein Magic Link ist da! ✨
Du hast einen Magic Link angefordert. Klicke auf den Link unten,
um dich sicher und ohne Passwort bei NinjaCross anzumelden.
🔗 Magic Link:
{{ .ConfirmationURL }}
🔒 Sicherheitshinweis:
Dieser Link ist nur für dich bestimmt und verfällt nach 24 Stunden.
Teile ihn nicht mit anderen Personen.
================================================
Falls du diesen Magic Link nicht angefordert hast, kannst du diese E-Mail ignorieren.
Links:
- Zur Website: {{ .SiteURL }}
- Support: {{ .SiteURL }}/support
- Datenschutz: {{ .SiteURL }}/privacy
© 2024 NinjaCross. Alle Rechte vorbehalten.

View File

@@ -0,0 +1,327 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Erneute Authentifizierung - NinjaCross</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', sans-serif;
background: #0a0a0f;
color: #ffffff;
line-height: 1.6;
margin: 0;
padding: 0;
}
.email-container {
max-width: 600px;
margin: 0 auto;
background: #0a0a0f;
background-image:
radial-gradient(circle at 20% 80%, #1a1a2e 0%, transparent 50%),
radial-gradient(circle at 80% 20%, #16213e 0%, transparent 50%),
radial-gradient(circle at 40% 40%, #0f3460 0%, transparent 50%);
}
.email-header {
text-align: center;
padding: 3rem 2rem 2rem;
}
.logo {
font-size: 2.5rem;
font-weight: 700;
background: linear-gradient(135deg, #00d4ff, #ff6b35, #ffd700);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 0.5rem;
letter-spacing: -0.02em;
}
.tagline {
color: #94a3b8;
font-size: 1rem;
font-weight: 400;
}
.email-content {
background: rgba(30, 41, 59, 0.95);
backdrop-filter: blur(20px);
border: 1px solid rgba(51, 65, 85, 0.3);
margin: 0 2rem;
padding: 2.5rem;
border-radius: 1.5rem;
box-shadow:
0 25px 50px rgba(0, 0, 0, 0.3),
0 0 0 1px rgba(0, 212, 255, 0.1);
}
.reauth-title {
font-size: 1.75rem;
font-weight: 600;
color: #e2e8f0;
text-align: center;
margin-bottom: 1.5rem;
}
.reauth-message {
color: #cbd5e1;
font-size: 1rem;
margin-bottom: 2rem;
text-align: center;
}
.reauth-info {
background: rgba(51, 65, 85, 0.3);
border: 1px solid rgba(0, 212, 255, 0.1);
border-radius: 0.75rem;
padding: 1.5rem;
margin-bottom: 2rem;
text-align: center;
}
.reauth-icon {
font-size: 3rem;
margin-bottom: 1rem;
display: block;
}
.reauth-description {
color: #94a3b8;
font-size: 0.9rem;
margin-bottom: 1rem;
}
.cta-button {
display: inline-block;
width: 100%;
padding: 1rem 2rem;
background: linear-gradient(135deg, #00d4ff, #0891b2);
color: white;
text-decoration: none;
border-radius: 0.75rem;
font-weight: 600;
font-size: 1rem;
text-align: center;
text-transform: uppercase;
letter-spacing: 0.05em;
box-shadow: 0 4px 12px rgba(0, 212, 255, 0.3);
transition: all 0.2s ease;
margin-bottom: 2rem;
}
.cta-button:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 212, 255, 0.4);
}
.security-info {
background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.3);
border-radius: 0.75rem;
padding: 1.5rem;
margin-top: 2rem;
}
.security-title {
color: #22c55e;
font-weight: 600;
font-size: 1rem;
margin-bottom: 1rem;
text-align: center;
}
.security-text {
color: #86efac;
font-size: 0.9rem;
text-align: center;
line-height: 1.6;
}
.warning-info {
background: rgba(245, 158, 11, 0.1);
border: 1px solid rgba(245, 158, 11, 0.3);
border-radius: 0.75rem;
padding: 1rem;
margin-top: 1.5rem;
}
.warning-title {
color: #f59e0b;
font-weight: 600;
font-size: 0.9rem;
margin-bottom: 0.5rem;
text-align: center;
}
.warning-text {
color: #fbbf24;
font-size: 0.85rem;
text-align: center;
}
.token-section {
background: rgba(0, 212, 255, 0.1);
border: 1px solid rgba(0, 212, 255, 0.3);
border-radius: 0.75rem;
padding: 1.5rem;
margin: 2rem 0;
text-align: center;
}
.token-display {
margin-top: 1rem;
}
.token-code {
background: #1e293b;
border: 2px solid #00d4ff;
border-radius: 0.5rem;
padding: 1rem;
font-family: 'Courier New', monospace;
font-size: 1.5rem;
font-weight: 700;
color: #00d4ff;
letter-spacing: 0.1em;
text-align: center;
margin: 0 auto;
max-width: 300px;
box-shadow: 0 0 20px rgba(0, 212, 255, 0.2);
}
.email-footer {
text-align: center;
padding: 2rem;
color: #64748b;
font-size: 0.875rem;
}
.footer-links {
margin-top: 1rem;
}
.footer-links a {
color: #00d4ff;
text-decoration: none;
margin: 0 1rem;
}
.footer-links a:hover {
color: #0891b2;
}
.divider {
height: 1px;
background: linear-gradient(90deg, transparent, #334155, transparent);
margin: 2rem 0;
}
/* Mobile Responsive */
@media (max-width: 600px) {
.email-content {
margin: 0 1rem;
padding: 2rem;
}
.email-header {
padding: 2rem 1rem 1rem;
}
.logo {
font-size: 2rem;
}
.reauth-title {
font-size: 1.5rem;
}
.reauth-icon {
font-size: 2.5rem;
}
}
</style>
</head>
<body>
<div class="email-container">
<!-- Header -->
<div class="email-header">
<div class="logo">🥷 NINJACROSS</div>
<div class="tagline">Die ultimative Timer-Rangliste</div>
</div>
<!-- Main Content -->
<div class="email-content">
<h1 class="reauth-title">Erneute Authentifizierung erforderlich 🔒</h1>
<p class="reauth-message">
Für diese Aktion ist eine erneute Authentifizierung erforderlich.
Klicke auf den Button unten, um deine Identität zu bestätigen.
</p>
<div class="reauth-info">
<span class="reauth-icon">🛡️</span>
<div class="reauth-description">
Diese zusätzliche Sicherheitsmaßnahme schützt dein Konto
</div>
</div>
<!-- Token Section -->
<div class="token-section">
<h2 style="color: #e2e8f0; text-align: center; margin-bottom: 1rem; font-size: 1.25rem;">Bestätigungscode</h2>
<div class="token-display">
<p style="color: #cbd5e1; text-align: center; margin-bottom: 0.5rem;">Gib diesen Code ein:</p>
<div class="token-code">{{ .Token }}</div>
</div>
</div>
<a href="{{ .ConfirmationURL }}" class="cta-button">
🔐 Identität bestätigen
</a>
<div class="security-info">
<div class="security-title">🔒 Warum ist das nötig?</div>
<div class="security-text">
Bestimmte Aktionen wie das Ändern sensibler Daten oder das Zugreifen auf
administrative Funktionen erfordern eine erneute Authentifizierung,
um die Sicherheit deines Kontos zu gewährleisten.
</div>
</div>
<div class="warning-info">
<div class="warning-title">⏰ Zeitlimit</div>
<div class="warning-text">
Dieser Link verfällt nach 15 Minuten. Falls du diese Aktion nicht
angefordert hast, kannst du diese E-Mail ignorieren.
</div>
</div>
</div>
<!-- Footer -->
<div class="email-footer">
<p>
Falls du diese Aktion nicht angefordert hast, kannst du diese E-Mail ignorieren.
</p>
<div class="footer-links">
<a href="{{ .SiteURL }}">Zur Website</a>
<a href="{{ .SiteURL }}/support">Support</a>
<a href="{{ .SiteURL }}/privacy">Datenschutz</a>
</div>
<p style="margin-top: 1.5rem; font-size: 0.75rem; color: #64748b;">
© 2024 NinjaCross. Alle Rechte vorbehalten.
</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,30 @@
🥷 NINJACROSS - Die ultimative Timer-Rangliste
================================================
Erneute Authentifizierung erforderlich 🔒
Für diese Aktion ist eine erneute Authentifizierung erforderlich.
Klicke auf den Link unten, um deine Identität zu bestätigen.
🛡️ Identität bestätigen:
{{ .ConfirmationURL }}
🔒 Warum ist das nötig?
Bestimmte Aktionen wie das Ändern sensibler Daten oder das Zugreifen auf
administrative Funktionen erfordern eine erneute Authentifizierung,
um die Sicherheit deines Kontos zu gewährleisten.
⏰ Zeitlimit:
Dieser Link verfällt nach 15 Minuten. Falls du diese Aktion nicht
angefordert hast, kannst du diese E-Mail ignorieren.
================================================
Falls du diese Aktion nicht angefordert hast, kannst du diese E-Mail ignorieren.
Links:
- Zur Website: {{ .SiteURL }}
- Support: {{ .SiteURL }}/support
- Datenschutz: {{ .SiteURL }}/privacy
© 2024 NinjaCross. Alle Rechte vorbehalten.

View File

@@ -0,0 +1,249 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Passwort zurücksetzen - NinjaCross</title>
<style>
/* E-Mail-Client-kompatible Styles */
body {
font-family: Arial, sans-serif;
background-color: #0a0a0f;
color: #ffffff;
margin: 0;
padding: 20px;
line-height: 1.6;
}
.email-container {
max-width: 600px;
margin: 0 auto;
background-color: #1e293b;
border: 2px solid #334155;
border-radius: 15px;
overflow: hidden;
}
.email-header {
background-color: #00d4ff;
padding: 30px 20px;
text-align: center;
}
.logo {
font-size: 28px;
font-weight: bold;
color: #ffffff;
margin-bottom: 5px;
}
.tagline {
color: #e2e8f0;
font-size: 14px;
}
.email-content {
padding: 30px 20px;
background-color: #1e293b;
}
.reset-title {
font-size: 24px;
font-weight: bold;
color: #e2e8f0;
text-align: center;
margin-bottom: 20px;
}
.reset-message {
color: #cbd5e1;
font-size: 16px;
text-align: center;
margin-bottom: 30px;
}
.reset-info {
background-color: #334155;
border: 1px solid #475569;
border-radius: 10px;
padding: 20px;
margin-bottom: 30px;
text-align: center;
}
.reset-icon {
font-size: 40px;
margin-bottom: 10px;
display: block;
}
.reset-description {
color: #94a3b8;
font-size: 14px;
}
.cta-button {
display: block;
width: 100%;
max-width: 300px;
margin: 0 auto 30px;
padding: 15px 30px;
background-color: #00d4ff;
color: #ffffff;
text-decoration: none;
border-radius: 10px;
font-weight: bold;
font-size: 16px;
text-align: center;
text-transform: uppercase;
}
.security-tips {
background-color: #22c55e;
border: 1px solid #16a34a;
border-radius: 10px;
padding: 20px;
margin-top: 30px;
}
.security-title {
color: #ffffff;
font-weight: bold;
font-size: 16px;
margin-bottom: 15px;
text-align: center;
}
.security-list {
color: #ffffff;
font-size: 14px;
line-height: 1.8;
}
.security-list li {
margin-bottom: 8px;
}
.warning-info {
background-color: #ef4444;
border: 1px solid #dc2626;
border-radius: 10px;
padding: 15px;
margin-top: 20px;
}
.warning-title {
color: #ffffff;
font-weight: bold;
font-size: 14px;
margin-bottom: 8px;
text-align: center;
}
.warning-text {
color: #ffffff;
font-size: 13px;
text-align: center;
}
.email-footer {
background-color: #0f172a;
padding: 20px;
text-align: center;
color: #64748b;
font-size: 12px;
}
.footer a {
color: #00d4ff;
text-decoration: none;
margin: 0 10px;
}
/* Mobile Responsive */
@media (max-width: 600px) {
.email-container {
margin: 0 10px;
}
.email-content {
padding: 20px 15px;
}
.logo {
font-size: 24px;
}
.reset-title {
font-size: 20px;
}
}
</style>
</head>
<body>
<div class="email-container">
<!-- Header -->
<div class="email-header">
<div class="logo">🥷 NINJACROSS</div>
<div class="tagline">Die ultimative Timer-Rangliste</div>
</div>
<!-- Content -->
<div class="email-content">
<h1 class="reset-title">Passwort zurücksetzen 🔐</h1>
<p class="reset-message">
Du hast eine Anfrage zum Zurücksetzen deines Passworts gestellt.
Klicke auf den Button unten, um ein neues Passwort zu erstellen.
</p>
<div class="reset-info">
<span class="reset-icon">🔑</span>
<div class="reset-description">
Dieser Link ist sicher und führt dich zur Passwort-Reset-Seite
</div>
</div>
<a href="{{ .ConfirmationURL }}" class="cta-button">
🔄 Passwort zurücksetzen
</a>
<div class="security-tips">
<div class="security-title">🛡️ Tipps für ein sicheres Passwort:</div>
<ul class="security-list">
<li>• Verwende mindestens 8 Zeichen</li>
<li>• Kombiniere Groß- und Kleinbuchstaben</li>
<li>• Füge Zahlen und Sonderzeichen hinzu</li>
<li>• Verwende keine persönlichen Informationen</li>
<li>• Nutze ein einzigartiges Passwort nur für NinjaCross</li>
</ul>
</div>
<div class="warning-info">
<div class="warning-title">⚠️ Sicherheitshinweis</div>
<div class="warning-text">
Dieser Link verfällt nach 24 Stunden. Falls du diese Anfrage nicht gestellt hast,
kannst du diese E-Mail ignorieren.
</div>
</div>
</div>
<!-- Footer -->
<div class="email-footer">
<p>
Falls du diese Anfrage nicht gestellt hast, kannst du diese E-Mail ignorieren.
</p>
<p style="margin-top: 15px;">
<a href="{{ .SiteURL }}">Zur Website</a>
<a href="{{ .SiteURL }}/support">Support</a>
<a href="{{ .SiteURL }}/privacy">Datenschutz</a>
</p>
<p style="margin-top: 15px;">
© 2024 NinjaCross. Alle Rechte vorbehalten.
</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,301 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Passwort zurücksetzen - NinjaCross</title>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
<style type="text/css">
/* Reset styles for email clients */
body, table, td, p, a, li, blockquote {
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table, td {
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
-ms-interpolation-mode: bicubic;
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
}
/* Main styles */
body {
font-family: Arial, sans-serif;
background-color: #0a0a0f;
color: #ffffff;
margin: 0;
padding: 0;
line-height: 1.6;
}
.email-wrapper {
width: 100%;
background-color: #0a0a0f;
padding: 20px 0;
}
.email-container {
max-width: 600px;
margin: 0 auto;
background-color: #1e293b;
border: 2px solid #334155;
}
.email-header {
background-color: #00d4ff;
padding: 30px 20px;
text-align: center;
}
.logo {
font-size: 28px;
font-weight: bold;
color: #ffffff;
margin: 0 0 5px 0;
}
.tagline {
color: #e2e8f0;
font-size: 14px;
margin: 0;
}
.email-content {
padding: 30px 20px;
background-color: #1e293b;
}
.reset-title {
font-size: 24px;
font-weight: bold;
color: #ffffff;
text-align: center;
margin: 0 0 20px 0;
}
.reset-message {
color: #cbd5e1;
font-size: 16px;
text-align: center;
margin: 0 0 30px 0;
}
.reset-info {
background-color: #334155;
border: 1px solid #475569;
padding: 20px;
margin: 0 0 30px 0;
text-align: center;
}
.reset-icon {
font-size: 40px;
margin: 0 0 10px 0;
display: block;
}
.reset-description {
color: #94a3b8;
font-size: 14px;
margin: 0;
}
.cta-button {
display: block;
width: 100%;
max-width: 300px;
margin: 0 auto 30px;
padding: 15px 30px;
background-color: #00d4ff;
color: #ffffff;
text-decoration: none;
font-weight: bold;
font-size: 16px;
text-align: center;
text-transform: uppercase;
}
.cta-button:hover {
background-color: #0891b2;
}
.security-tips {
background-color: #22c55e;
border: 1px solid #16a34a;
padding: 20px;
margin: 30px 0 0 0;
}
.security-title {
color: #ffffff;
font-weight: bold;
font-size: 16px;
margin: 0 0 15px 0;
text-align: center;
}
.security-list {
color: #ffffff;
font-size: 14px;
line-height: 1.8;
margin: 0;
padding: 0;
}
.security-list li {
margin: 0 0 8px 0;
list-style: none;
}
.warning-info {
background-color: #ef4444;
border: 1px solid #dc2626;
padding: 15px;
margin: 20px 0 0 0;
}
.warning-title {
color: #ffffff;
font-weight: bold;
font-size: 14px;
margin: 0 0 8px 0;
text-align: center;
}
.warning-text {
color: #ffffff;
font-size: 13px;
text-align: center;
margin: 0;
}
.email-footer {
background-color: #0f172a;
padding: 20px;
text-align: center;
color: #64748b;
font-size: 12px;
}
.footer a {
color: #00d4ff;
text-decoration: none;
margin: 0 10px;
}
.footer a:hover {
color: #0891b2;
}
/* Mobile styles */
@media only screen and (max-width: 600px) {
.email-wrapper {
padding: 10px 0;
}
.email-container {
margin: 0 10px;
}
.email-content {
padding: 20px 15px;
}
.logo {
font-size: 24px;
}
.reset-title {
font-size: 20px;
}
.cta-button {
padding: 12px 20px;
font-size: 14px;
}
}
</style>
</head>
<body>
<div class="email-wrapper">
<div class="email-container">
<!-- Header -->
<div class="email-header">
<div class="logo">🥷 NINJACROSS</div>
<div class="tagline">Die ultimative Timer-Rangliste</div>
</div>
<!-- Content -->
<div class="email-content">
<h1 class="reset-title">Passwort zurücksetzen 🔐</h1>
<p class="reset-message">
Du hast eine Anfrage zum Zurücksetzen deines Passworts gestellt.
Klicke auf den Button unten, um ein neues Passwort zu erstellen.
</p>
<div class="reset-info">
<span class="reset-icon">🔑</span>
<div class="reset-description">
Dieser Link ist sicher und führt dich zur Passwort-Reset-Seite
</div>
</div>
<a href="{{ .ConfirmationURL }}" class="cta-button">
🔄 Passwort zurücksetzen
</a>
<div class="security-tips">
<div class="security-title">🛡️ Tipps für ein sicheres Passwort:</div>
<ul class="security-list">
<li>• Verwende mindestens 8 Zeichen</li>
<li>• Kombiniere Groß- und Kleinbuchstaben</li>
<li>• Füge Zahlen und Sonderzeichen hinzu</li>
<li>• Verwende keine persönlichen Informationen</li>
<li>• Nutze ein einzigartiges Passwort nur für NinjaCross</li>
</ul>
</div>
<div class="warning-info">
<div class="warning-title">⚠️ Sicherheitshinweis</div>
<div class="warning-text">
Dieser Link verfällt nach 24 Stunden. Falls du diese Anfrage nicht gestellt hast,
kannst du diese E-Mail ignorieren.
</div>
</div>
</div>
<!-- Footer -->
<div class="email-footer">
<p>
Falls du diese Anfrage nicht gestellt hast, kannst du diese E-Mail ignorieren.
</p>
<p style="margin-top: 15px;">
<a href="https://ninja.reptilfpv.de:3000">Zur Website</a>
<a href="https://ninja.reptilfpv.de:3000/support">Support</a>
<a href="https://ninja.reptilfpv.de:3000/privacy">Datenschutz</a>
</p>
<p style="margin-top: 15px;">
© 2024 NinjaCross. Alle Rechte vorbehalten.
</p>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,323 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Passwort zurücksetzen - NinjaCross</title>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
<style type="text/css">
/* Reset styles */
body, table, td, p, a, li, blockquote {
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table, td {
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
-ms-interpolation-mode: bicubic;
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
}
/* Main styles */
body {
font-family: Arial, sans-serif;
background-color: #0a0a0f;
color: #ffffff;
margin: 0;
padding: 0;
line-height: 1.6;
}
.email-wrapper {
width: 100%;
background-color: #0a0a0f;
padding: 20px 0;
}
.email-container {
max-width: 600px;
margin: 0 auto;
background-color: #1e293b;
border: 2px solid #334155;
}
.email-header {
background-color: #00d4ff;
padding: 30px 20px;
text-align: center;
}
.logo {
font-size: 28px;
font-weight: bold;
color: #ffffff;
margin: 0 0 5px 0;
}
.tagline {
color: #e2e8f0;
font-size: 14px;
margin: 0;
}
.email-content {
padding: 30px 20px;
background-color: #1e293b;
}
.reset-title {
font-size: 24px;
font-weight: bold;
color: #ffffff;
text-align: center;
margin: 0 0 20px 0;
}
.reset-message {
color: #cbd5e1;
font-size: 16px;
text-align: center;
margin: 0 0 30px 0;
}
.reset-info {
background-color: #334155;
border: 1px solid #475569;
padding: 20px;
margin: 0 0 30px 0;
text-align: center;
}
.reset-icon {
font-size: 40px;
margin: 0 0 10px 0;
display: block;
}
.reset-description {
color: #94a3b8;
font-size: 14px;
margin: 0;
}
.cta-button {
display: block;
width: 100%;
max-width: 300px;
margin: 0 auto 30px;
padding: 15px 30px;
background-color: #00d4ff;
color: #ffffff;
text-decoration: none;
font-weight: bold;
font-size: 16px;
text-align: center;
text-transform: uppercase;
}
.security-tips {
background-color: #22c55e;
border: 1px solid #16a34a;
padding: 20px;
margin: 30px 0 0 0;
}
.security-title {
color: #ffffff;
font-weight: bold;
font-size: 16px;
margin: 0 0 15px 0;
text-align: center;
}
.security-list {
color: #ffffff;
font-size: 14px;
line-height: 1.8;
margin: 0;
padding: 0;
}
.security-list li {
margin: 0 0 8px 0;
list-style: none;
}
.warning-info {
background-color: #ef4444;
border: 1px solid #dc2626;
padding: 15px;
margin: 20px 0 0 0;
}
.warning-title {
color: #ffffff;
font-weight: bold;
font-size: 14px;
margin: 0 0 8px 0;
text-align: center;
}
.warning-text {
color: #ffffff;
font-size: 13px;
text-align: center;
margin: 0;
}
.email-footer {
background-color: #0f172a;
padding: 20px;
text-align: center;
color: #64748b;
font-size: 12px;
}
.footer a {
color: #00d4ff;
text-decoration: none;
margin: 0 10px;
}
/* Mobile styles */
@media only screen and (max-width: 600px) {
.email-wrapper {
padding: 10px 0;
}
.email-container {
margin: 0 10px;
}
.email-content {
padding: 20px 15px;
}
.logo {
font-size: 24px;
}
.reset-title {
font-size: 20px;
}
.cta-button {
padding: 12px 20px;
font-size: 14px;
}
}
</style>
</head>
<body>
<div class="email-wrapper">
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color: #0a0a0f;">
<tr>
<td align="center" style="padding: 20px 0;">
<table width="600" cellpadding="0" cellspacing="0" border="0" style="background-color: #1e293b; border: 2px solid #334155; max-width: 600px;">
<!-- Header -->
<tr>
<td style="background-color: #00d4ff; padding: 30px 20px; text-align: center;">
<div class="logo">🥷 NINJACROSS</div>
<div class="tagline">Die ultimative Timer-Rangliste</div>
</td>
</tr>
<!-- Content -->
<tr>
<td style="padding: 30px 20px; background-color: #1e293b;">
<h1 class="reset-title">Passwort zurücksetzen 🔐</h1>
<p class="reset-message">
Du hast eine Anfrage zum Zurücksetzen deines Passworts gestellt.
Klicke auf den Button unten, um ein neues Passwort zu erstellen.
</p>
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color: #334155; border: 1px solid #475569; margin: 0 0 30px 0;">
<tr>
<td style="padding: 20px; text-align: center;">
<span class="reset-icon">🔑</span>
<div class="reset-description">
Dieser Link ist sicher und führt dich zur Passwort-Reset-Seite
</div>
</td>
</tr>
</table>
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="margin: 0 0 30px 0;">
<tr>
<td align="center">
<a href="{{ .ConfirmationURL }}" class="cta-button">
🔄 Passwort zurücksetzen
</a>
</td>
</tr>
</table>
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color: #22c55e; border: 1px solid #16a34a; margin: 30px 0 0 0;">
<tr>
<td style="padding: 20px;">
<div class="security-title">🛡️ Tipps für ein sicheres Passwort:</div>
<ul class="security-list">
<li>• Verwende mindestens 8 Zeichen</li>
<li>• Kombiniere Groß- und Kleinbuchstaben</li>
<li>• Füge Zahlen und Sonderzeichen hinzu</li>
<li>• Verwende keine persönlichen Informationen</li>
<li>• Nutze ein einzigartiges Passwort nur für NinjaCross</li>
</ul>
</td>
</tr>
</table>
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color: #ef4444; border: 1px solid #dc2626; margin: 20px 0 0 0;">
<tr>
<td style="padding: 15px;">
<div class="warning-title">⚠️ Sicherheitshinweis</div>
<div class="warning-text">
Dieser Link verfällt nach 24 Stunden. Falls du diese Anfrage nicht gestellt hast,
kannst du diese E-Mail ignorieren.
</div>
</td>
</tr>
</table>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="background-color: #0f172a; padding: 20px; text-align: center; color: #64748b; font-size: 12px;">
<p>
Falls du diese Anfrage nicht gestellt hast, kannst du diese E-Mail ignorieren.
</p>
<p style="margin-top: 15px;">
<a href="https://ninja.reptilfpv.de:3000" style="color: #00d4ff; text-decoration: none; margin: 0 10px;">Zur Website</a>
<a href="https://ninja.reptilfpv.de:3000/support" style="color: #00d4ff; text-decoration: none; margin: 0 10px;">Support</a>
<a href="https://ninja.reptilfpv.de:3000/privacy" style="color: #00d4ff; text-decoration: none; margin: 0 10px;">Datenschutz</a>
</p>
<p style="margin-top: 15px;">
© 2024 NinjaCross. Alle Rechte vorbehalten.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</div>
</body>
</html>

View File

@@ -0,0 +1,294 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Passwort zurücksetzen - NinjaCross</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', sans-serif;
background: #0a0a0f;
color: #ffffff;
line-height: 1.6;
margin: 0;
padding: 0;
}
.email-container {
max-width: 600px;
margin: 0 auto;
background: #0a0a0f;
background-image:
radial-gradient(circle at 20% 80%, #1a1a2e 0%, transparent 50%),
radial-gradient(circle at 80% 20%, #16213e 0%, transparent 50%),
radial-gradient(circle at 40% 40%, #0f3460 0%, transparent 50%);
}
.email-header {
text-align: center;
padding: 3rem 2rem 2rem;
}
.logo {
font-size: 2.5rem;
font-weight: 700;
background: linear-gradient(135deg, #00d4ff, #ff6b35, #ffd700);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 0.5rem;
letter-spacing: -0.02em;
}
.tagline {
color: #94a3b8;
font-size: 1rem;
font-weight: 400;
}
.email-content {
background: rgba(30, 41, 59, 0.95);
backdrop-filter: blur(20px);
border: 1px solid rgba(51, 65, 85, 0.3);
margin: 0 2rem;
padding: 2.5rem;
border-radius: 1.5rem;
box-shadow:
0 25px 50px rgba(0, 0, 0, 0.3),
0 0 0 1px rgba(0, 212, 255, 0.1);
}
.reset-title {
font-size: 1.75rem;
font-weight: 600;
color: #e2e8f0;
text-align: center;
margin-bottom: 1.5rem;
}
.reset-message {
color: #cbd5e1;
font-size: 1rem;
margin-bottom: 2rem;
text-align: center;
}
.reset-info {
background: rgba(51, 65, 85, 0.3);
border: 1px solid rgba(0, 212, 255, 0.1);
border-radius: 0.75rem;
padding: 1.5rem;
margin-bottom: 2rem;
text-align: center;
}
.reset-icon {
font-size: 3rem;
margin-bottom: 1rem;
display: block;
}
.reset-description {
color: #94a3b8;
font-size: 0.9rem;
margin-bottom: 1rem;
}
.cta-button {
display: inline-block;
width: 100%;
padding: 1rem 2rem;
background: linear-gradient(135deg, #00d4ff, #0891b2);
color: white;
text-decoration: none;
border-radius: 0.75rem;
font-weight: 600;
font-size: 1rem;
text-align: center;
text-transform: uppercase;
letter-spacing: 0.05em;
box-shadow: 0 4px 12px rgba(0, 212, 255, 0.3);
transition: all 0.2s ease;
margin-bottom: 2rem;
}
.cta-button:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 212, 255, 0.4);
}
.security-tips {
background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.3);
border-radius: 0.75rem;
padding: 1.5rem;
margin-top: 2rem;
}
.security-title {
color: #22c55e;
font-weight: 600;
font-size: 1rem;
margin-bottom: 1rem;
text-align: center;
}
.security-list {
color: #86efac;
font-size: 0.9rem;
line-height: 1.8;
}
.security-list li {
margin-bottom: 0.5rem;
}
.warning-info {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 0.75rem;
padding: 1rem;
margin-top: 1.5rem;
}
.warning-title {
color: #ef4444;
font-weight: 600;
font-size: 0.9rem;
margin-bottom: 0.5rem;
text-align: center;
}
.warning-text {
color: #fca5a5;
font-size: 0.85rem;
text-align: center;
}
.email-footer {
text-align: center;
padding: 2rem;
color: #64748b;
font-size: 0.875rem;
}
.footer-links {
margin-top: 1rem;
}
.footer-links a {
color: #00d4ff;
text-decoration: none;
margin: 0 1rem;
}
.footer-links a:hover {
color: #0891b2;
}
.divider {
height: 1px;
background: linear-gradient(90deg, transparent, #334155, transparent);
margin: 2rem 0;
}
/* Mobile Responsive */
@media (max-width: 600px) {
.email-content {
margin: 0 1rem;
padding: 2rem;
}
.email-header {
padding: 2rem 1rem 1rem;
}
.logo {
font-size: 2rem;
}
.reset-title {
font-size: 1.5rem;
}
.reset-icon {
font-size: 2.5rem;
}
}
</style>
</head>
<body>
<div class="email-container">
<!-- Header -->
<div class="email-header">
<div class="logo">🥷 NINJACROSS</div>
<div class="tagline">Die ultimative Timer-Rangliste</div>
</div>
<!-- Main Content -->
<div class="email-content">
<h1 class="reset-title">Passwort zurücksetzen 🔐</h1>
<p class="reset-message">
Du hast eine Anfrage zum Zurücksetzen deines Passworts gestellt.
Klicke auf den Button unten, um ein neues Passwort zu erstellen.
</p>
<div class="reset-info">
<span class="reset-icon">🔑</span>
<div class="reset-description">
Dieser Link ist sicher und führt dich zur Passwort-Reset-Seite
</div>
</div>
<a href="{{ .ConfirmationURL }}" class="cta-button">
🔄 Passwort zurücksetzen
</a>
<div class="security-tips">
<div class="security-title">🛡️ Tipps für ein sicheres Passwort:</div>
<ul class="security-list">
<li>• Verwende mindestens 8 Zeichen</li>
<li>• Kombiniere Groß- und Kleinbuchstaben</li>
<li>• Füge Zahlen und Sonderzeichen hinzu</li>
<li>• Verwende keine persönlichen Informationen</li>
<li>• Nutze ein einzigartiges Passwort nur für NinjaCross</li>
</ul>
</div>
<div class="warning-info">
<div class="warning-title">⚠️ Sicherheitshinweis</div>
<div class="warning-text">
Dieser Link verfällt nach 24 Stunden. Falls du diese Anfrage nicht gestellt hast,
kannst du diese E-Mail ignorieren.
</div>
</div>
</div>
<!-- Footer -->
<div class="email-footer">
<p>
Falls du diese Anfrage nicht gestellt hast, kannst du diese E-Mail ignorieren.
</p>
<div class="footer-links">
<a href="{{ .SiteURL }}">Zur Website</a>
<a href="{{ .SiteURL }}/support">Support</a>
<a href="{{ .SiteURL }}/privacy">Datenschutz</a>
</div>
<p style="margin-top: 1.5rem; font-size: 0.75rem; color: #64748b;">
© 2024 NinjaCross. Alle Rechte vorbehalten.
</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,32 @@
🥷 NINJACROSS - Die ultimative Timer-Rangliste
================================================
Passwort zurücksetzen 🔐
Du hast eine Anfrage zum Zurücksetzen deines Passworts gestellt.
Klicke auf den Link unten, um ein neues Passwort zu erstellen.
🔑 Passwort zurücksetzen:
{{ .ConfirmationURL }}
🛡️ Tipps für ein sicheres Passwort:
• Verwende mindestens 8 Zeichen
• Kombiniere Groß- und Kleinbuchstaben
• Füge Zahlen und Sonderzeichen hinzu
• Verwende keine persönlichen Informationen
• Nutze ein einzigartiges Passwort nur für NinjaCross
⚠️ Sicherheitshinweis:
Dieser Link verfällt nach 24 Stunden. Falls du diese Anfrage nicht gestellt hast,
kannst du diese E-Mail ignorieren.
================================================
Falls du diese Anfrage nicht gestellt hast, kannst du diese E-Mail ignorieren.
Links:
- Zur Website: {{ .SiteURL }}
- Support: {{ .SiteURL }}/support
- Datenschutz: {{ .SiteURL }}/privacy
© 2024 NinjaCross. Alle Rechte vorbehalten.

View File

@@ -0,0 +1,208 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Willkommen bei NinjaCross</title>
<style>
/* E-Mail-Client-kompatible Styles */
body {
font-family: Arial, sans-serif;
background-color: #0a0a0f;
color: #ffffff;
margin: 0;
padding: 20px;
line-height: 1.6;
}
.email-container {
max-width: 600px;
margin: 0 auto;
background-color: #1e293b;
border: 2px solid #334155;
border-radius: 15px;
overflow: hidden;
}
.email-header {
background-color: #00d4ff;
padding: 30px 20px;
text-align: center;
}
.logo {
font-size: 28px;
font-weight: bold;
color: #ffffff;
margin-bottom: 5px;
}
.tagline {
color: #e2e8f0;
font-size: 14px;
}
.email-content {
padding: 30px 20px;
background-color: #1e293b;
}
.welcome-title {
font-size: 24px;
font-weight: bold;
color: #e2e8f0;
text-align: center;
margin-bottom: 20px;
}
.welcome-message {
color: #cbd5e1;
font-size: 16px;
text-align: center;
margin-bottom: 30px;
}
.cta-button {
display: block;
width: 100%;
max-width: 300px;
margin: 0 auto 30px;
padding: 15px 30px;
background-color: #00d4ff;
color: #ffffff;
text-decoration: none;
border-radius: 10px;
font-weight: bold;
font-size: 16px;
text-align: center;
text-transform: uppercase;
}
.features {
margin-top: 30px;
}
.feature {
background-color: #334155;
border: 1px solid #475569;
border-radius: 10px;
padding: 15px;
margin-bottom: 15px;
}
.feature-icon {
font-size: 20px;
margin-right: 10px;
}
.feature-text {
color: #cbd5e1;
font-size: 14px;
}
.email-footer {
background-color: #0f172a;
padding: 20px;
text-align: center;
color: #64748b;
font-size: 12px;
}
.footer a {
color: #00d4ff;
text-decoration: none;
margin: 0 10px;
}
/* Mobile Responsive */
@media (max-width: 600px) {
.email-container {
margin: 0 10px;
}
.email-content {
padding: 20px 15px;
}
.logo {
font-size: 24px;
}
.welcome-title {
font-size: 20px;
}
}
</style>
</head>
<body>
<div class="email-container">
<!-- Header -->
<div class="email-header">
<div class="logo">🥷 NINJACROSS</div>
<div class="tagline">Die ultimative Timer-Rangliste</div>
</div>
<!-- Content -->
<div class="email-content">
<h1 class="welcome-title">Willkommen bei NinjaCross! 🎉</h1>
<p class="welcome-message">
Vielen Dank für deine Registrierung! Du bist jetzt Teil der NinjaCross-Community.
Bestätige deine E-Mail-Adresse, um dein Konto zu aktivieren und sofort loszulegen.
</p>
<a href="{{ .ConfirmationURL }}" class="cta-button">
✉️ E-Mail bestätigen
</a>
<!-- Features -->
<div class="features">
<div class="feature">
<span class="feature-icon">🏃‍♂️</span>
<span class="feature-text">
<strong>Timer-Tracking:</strong> Erfasse deine Zeiten und verfolge deinen Fortschritt
</span>
</div>
<div class="feature">
<span class="feature-icon">🏆</span>
<span class="feature-text">
<strong>Leaderboards:</strong> Vergleiche dich mit anderen Spielern und erreiche die Spitze
</span>
</div>
<div class="feature">
<span class="feature-icon">📊</span>
<span class="feature-text">
<strong>Statistiken:</strong> Detaillierte Analysen deiner Performance und Verbesserungen
</span>
</div>
<div class="feature">
<span class="feature-icon">🌍</span>
<span class="feature-text">
<strong>Multi-Location:</strong> Spiele an verschiedenen Standorten und sammle Erfahrungen
</span>
</div>
</div>
</div>
<!-- Footer -->
<div class="email-footer">
<p>
Falls du dich nicht registriert hast, kannst du diese E-Mail ignorieren.
</p>
<p style="margin-top: 15px;">
<a href="{{ .SiteURL }}">Zur Website</a>
<a href="{{ .SiteURL }}/support">Support</a>
<a href="{{ .SiteURL }}/privacy">Datenschutz</a>
</p>
<p style="margin-top: 15px;">
© 2024 NinjaCross. Alle Rechte vorbehalten.
</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,187 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Willkommen bei NinjaCross</title>
<style>
body {
font-family: Arial, sans-serif;
background-color: #0a0a0f;
color: #ffffff;
margin: 0;
padding: 20px;
line-height: 1.6;
}
.container {
max-width: 600px;
margin: 0 auto;
background-color: #1e293b;
border: 2px solid #334155;
border-radius: 15px;
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #00d4ff, #0891b2);
padding: 30px 20px;
text-align: center;
}
.logo {
font-size: 28px;
font-weight: bold;
color: #ffffff;
margin-bottom: 5px;
}
.tagline {
color: #e2e8f0;
font-size: 14px;
}
.content {
padding: 30px 20px;
}
.title {
font-size: 24px;
font-weight: bold;
color: #e2e8f0;
text-align: center;
margin-bottom: 20px;
}
.message {
color: #cbd5e1;
font-size: 16px;
text-align: center;
margin-bottom: 30px;
}
.button {
display: block;
width: 100%;
max-width: 300px;
margin: 0 auto 30px;
padding: 15px 30px;
background: linear-gradient(135deg, #00d4ff, #0891b2);
color: #ffffff;
text-decoration: none;
border-radius: 10px;
font-weight: bold;
font-size: 16px;
text-align: center;
text-transform: uppercase;
}
.features {
margin-top: 30px;
}
.feature {
background-color: #334155;
border: 1px solid #475569;
border-radius: 10px;
padding: 15px;
margin-bottom: 15px;
}
.feature-icon {
font-size: 20px;
margin-right: 10px;
}
.feature-text {
color: #cbd5e1;
font-size: 14px;
}
.footer {
background-color: #0f172a;
padding: 20px;
text-align: center;
color: #64748b;
font-size: 12px;
}
.footer a {
color: #00d4ff;
text-decoration: none;
margin: 0 10px;
}
</style>
</head>
<body>
<div class="container">
<!-- Header -->
<div class="header">
<div class="logo">🥷 NINJACROSS</div>
<div class="tagline">Die ultimative Timer-Rangliste</div>
</div>
<!-- Content -->
<div class="content">
<h1 class="title">Willkommen bei NinjaCross! 🎉</h1>
<p class="message">
Vielen Dank für deine Registrierung! Du bist jetzt Teil der NinjaCross-Community.
Bestätige deine E-Mail-Adresse, um dein Konto zu aktivieren und sofort loszulegen.
</p>
<a href="{{ .ConfirmationURL }}" class="button">
✉️ E-Mail bestätigen
</a>
<!-- Features -->
<div class="features">
<div class="feature">
<span class="feature-icon">🏃‍♂️</span>
<span class="feature-text">
<strong>Timer-Tracking:</strong> Erfasse deine Zeiten und verfolge deinen Fortschritt
</span>
</div>
<div class="feature">
<span class="feature-icon">🏆</span>
<span class="feature-text">
<strong>Leaderboards:</strong> Vergleiche dich mit anderen Spielern und erreiche die Spitze
</span>
</div>
<div class="feature">
<span class="feature-icon">📊</span>
<span class="feature-text">
<strong>Statistiken:</strong> Detaillierte Analysen deiner Performance und Verbesserungen
</span>
</div>
<div class="feature">
<span class="feature-icon">🌍</span>
<span class="feature-text">
<strong>Multi-Location:</strong> Spiele an verschiedenen Standorten und sammle Erfahrungen
</span>
</div>
</div>
</div>
<!-- Footer -->
<div class="footer">
<p>
Falls du dich nicht registriert hast, kannst du diese E-Mail ignorieren.
</p>
<p style="margin-top: 15px;">
<a href="{{ .SiteURL }}">Zur Website</a>
<a href="{{ .SiteURL }}/support">Support</a>
<a href="{{ .SiteURL }}/privacy">Datenschutz</a>
</p>
<p style="margin-top: 15px;">
© 2024 NinjaCross. Alle Rechte vorbehalten.
</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,274 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Willkommen bei NinjaCross</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', sans-serif;
background: #0a0a0f;
color: #ffffff;
line-height: 1.6;
margin: 0;
padding: 0;
}
.email-container {
max-width: 600px;
margin: 0 auto;
background: #0a0a0f;
background-image:
radial-gradient(circle at 20% 80%, #1a1a2e 0%, transparent 50%),
radial-gradient(circle at 80% 20%, #16213e 0%, transparent 50%),
radial-gradient(circle at 40% 40%, #0f3460 0%, transparent 50%);
}
.email-header {
text-align: center;
padding: 3rem 2rem 2rem;
}
.logo {
font-size: 2.5rem;
font-weight: 700;
background: linear-gradient(135deg, #00d4ff, #ff6b35, #ffd700);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 0.5rem;
letter-spacing: -0.02em;
}
.tagline {
color: #94a3b8;
font-size: 1rem;
font-weight: 400;
}
.email-content {
background: rgba(30, 41, 59, 0.95);
backdrop-filter: blur(20px);
border: 1px solid rgba(51, 65, 85, 0.3);
margin: 0 2rem;
padding: 2.5rem;
border-radius: 1.5rem;
box-shadow:
0 25px 50px rgba(0, 0, 0, 0.3),
0 0 0 1px rgba(0, 212, 255, 0.1);
}
.welcome-title {
font-size: 1.75rem;
font-weight: 600;
color: #e2e8f0;
text-align: center;
margin-bottom: 1.5rem;
}
.welcome-message {
color: #cbd5e1;
font-size: 1rem;
margin-bottom: 2rem;
text-align: center;
}
.cta-button {
display: inline-block;
width: 100%;
padding: 1rem 2rem;
background: linear-gradient(135deg, #00d4ff, #0891b2);
color: white;
text-decoration: none;
border-radius: 0.75rem;
font-weight: 600;
font-size: 1rem;
text-align: center;
text-transform: uppercase;
letter-spacing: 0.05em;
box-shadow: 0 4px 12px rgba(0, 212, 255, 0.3);
transition: all 0.2s ease;
margin-bottom: 2rem;
}
.cta-button:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 212, 255, 0.4);
}
.features-section {
margin-top: 2rem;
}
.features-title {
font-size: 1.25rem;
font-weight: 600;
color: #e2e8f0;
margin-bottom: 1rem;
text-align: center;
}
.feature-item {
display: flex;
align-items: center;
margin-bottom: 1rem;
padding: 1rem;
background: rgba(51, 65, 85, 0.3);
border-radius: 0.75rem;
border: 1px solid rgba(0, 212, 255, 0.1);
}
.feature-icon {
font-size: 1.5rem;
margin-right: 1rem;
width: 2rem;
text-align: center;
}
.feature-text {
color: #cbd5e1;
font-size: 0.95rem;
}
.email-footer {
text-align: center;
padding: 2rem;
color: #64748b;
font-size: 0.875rem;
}
.footer-links {
margin-top: 1rem;
}
.footer-links a {
color: #00d4ff;
text-decoration: none;
margin: 0 1rem;
}
.footer-links a:hover {
color: #0891b2;
}
.divider {
height: 1px;
background: linear-gradient(90deg, transparent, #334155, transparent);
margin: 2rem 0;
}
/* Mobile Responsive */
@media (max-width: 600px) {
.email-content {
margin: 0 1rem;
padding: 2rem;
}
.email-header {
padding: 2rem 1rem 1rem;
}
.logo {
font-size: 2rem;
}
.welcome-title {
font-size: 1.5rem;
}
.feature-item {
flex-direction: column;
text-align: center;
}
.feature-icon {
margin-right: 0;
margin-bottom: 0.5rem;
}
}
</style>
</head>
<body>
<div class="email-container">
<!-- Header -->
<div class="email-header">
<div class="logo">🥷 NINJACROSS</div>
<div class="tagline">Die ultimative Timer-Rangliste</div>
</div>
<!-- Main Content -->
<div class="email-content">
<h1 class="welcome-title">Willkommen bei NinjaCross! 🎉</h1>
<p class="welcome-message">
Vielen Dank für deine Registrierung! Du bist jetzt Teil der NinjaCross-Community.
Bestätige deine E-Mail-Adresse, um dein Konto zu aktivieren und sofort loszulegen.
</p>
<a href="{{ .ConfirmationURL }}" class="cta-button">
✉️ E-Mail bestätigen
</a>
<div class="divider"></div>
<!-- Features Section -->
<div class="features-section">
<h2 class="features-title">Was dich erwartet:</h2>
<div class="feature-item">
<div class="feature-icon">🏃‍♂️</div>
<div class="feature-text">
<strong>Timer-Tracking:</strong> Erfasse deine Zeiten und verfolge deinen Fortschritt
</div>
</div>
<div class="feature-item">
<div class="feature-icon">🏆</div>
<div class="feature-text">
<strong>Leaderboards:</strong> Vergleiche dich mit anderen Spielern und erreiche die Spitze
</div>
</div>
<div class="feature-item">
<div class="feature-icon">📊</div>
<div class="feature-text">
<strong>Statistiken:</strong> Detaillierte Analysen deiner Performance und Verbesserungen
</div>
</div>
<div class="feature-item">
<div class="feature-icon">🌍</div>
<div class="feature-text">
<strong>Multi-Location:</strong> Spiele an verschiedenen Standorten und sammle Erfahrungen
</div>
</div>
</div>
</div>
<!-- Footer -->
<div class="email-footer">
<p>
Falls du dich nicht registriert hast, kannst du diese E-Mail ignorieren.
</p>
<div class="footer-links">
<a href="{{ .SiteURL }}">Zur Website</a>
<a href="{{ .SiteURL }}/support">Support</a>
<a href="{{ .SiteURL }}/privacy">Datenschutz</a>
</div>
<p style="margin-top: 1.5rem; font-size: 0.75rem; color: #64748b;">
© 2024 NinjaCross. Alle Rechte vorbehalten.
</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,32 @@
🥷 NINJACROSS - Die ultimative Timer-Rangliste
================================================
Willkommen bei NinjaCross! 🎉
Vielen Dank für deine Registrierung! Du bist jetzt Teil der NinjaCross-Community.
Bestätige deine E-Mail-Adresse, um dein Konto zu aktivieren und sofort loszulegen.
📧 E-Mail bestätigen:
{{ .ConfirmationURL }}
Was dich erwartet:
==================
🏃‍♂️ Timer-Tracking: Erfasse deine Zeiten und verfolge deinen Fortschritt
🏆 Leaderboards: Vergleiche dich mit anderen Spielern und erreiche die Spitze
📊 Statistiken: Detaillierte Analysen deiner Performance und Verbesserungen
🌍 Multi-Location: Spiele an verschiedenen Standorten und sammle Erfahrungen
================================================
Falls du dich nicht registriert hast, kannst du diese E-Mail ignorieren.
Links:
- Zur Website: {{ .SiteURL }}
- Support: {{ .SiteURL }}/support
- Datenschutz: {{ .SiteURL }}/privacy
© 2024 NinjaCross. Alle Rechte vorbehalten.

85
public/generator.html Normal file
View File

@@ -0,0 +1,85 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Lizenzgenerator</title>
<link rel="icon" type="image/x-icon" href="/pictures/favicon.ico">
<link rel="stylesheet" href="/css/generator.css">
</head>
<body>
<div class="container">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h1 style="margin: 0;">🔐 Lizenzgenerator</h1>
<div style="display: flex; gap: 10px;">
<button onclick="goBackToDashboard()" style="padding: 10px 20px; background: #2196f3; color: white; border: none; border-radius: 8px; cursor: pointer; font-weight: 500; transition: all 0.3s ease;">⬅️ Zurück zum Dashboard</button>
<button onclick="logout()" style="padding: 10px 20px; background: #f44336; color: white; border: none; border-radius: 8px; cursor: pointer; font-weight: 500; transition: all 0.3s ease;">🚪 Abmelden</button>
</div>
</div>
<form id="licenseForm" onsubmit="generateLicense(); return false;">
<div class="form-group">
<label for="mac">MAC-Adresse</label>
<input type="text" id="mac" name="mac" placeholder="00:1A:2B:3C:4D:5E" required>
</div>
<div class="form-group">
<label for="tier">Lizenzstufe</label>
<input type="number" id="tier" name="tier" min="1" max="4" value="1" required>
</div>
<div id="dbConfig" class="db-config">
<!-- Token-Felder werden hier dynamisch eingefügt -->
</div>
<button type="submit" class="generate-btn" id="generateBtn">
<span id="btn-text">Lizenz generieren</span>
</button>
</form>
<div id="result" class="result-section">
<div class="license-label">Generierter Lizenzschlüssel:</div>
<div class="license-output" id="license-output"></div>
<button class="copy-btn" id="copyButton" onclick="copyToClipboard()">📋 In Zwischenablage kopieren</button>
</div>
<div id="success" class="success"></div>
<div id="error" class="error"></div>
<div class="info-text">
Der Lizenzgenerator erstellt sichere API-Token für verschiedene Zugriffsstufen.
</div>
</div>
<!-- Footer -->
<footer class="footer">
<div class="footer-content">
<div class="footer-links">
<a href="/impressum.html" class="footer-link">Impressum</a>
<a href="/datenschutz.html" class="footer-link">Datenschutz</a>
<button id="cookie-settings-footer" class="footer-link cookie-settings-btn">Cookie-Einstellungen</button>
</div>
<div class="footer-text">
<p>&copy; 2024 NinjaCross. Alle Rechte vorbehalten.</p>
</div>
</div>
</footer>
<script src="/js/cookie-consent.js"></script>
<script src="/js/generator.js"></script>
<script>
// Verhindert Enter-Taste in Eingabefeldern
document.addEventListener('DOMContentLoaded', function() {
const inputs = document.querySelectorAll('input, textarea');
inputs.forEach(input => {
input.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
return false;
}
});
});
});
</script>
</body>
</html>

241
public/impressum.html Normal file
View File

@@ -0,0 +1,241 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Impressum - NinjaCross</title>
<link rel="icon" type="image/x-icon" href="/pictures/favicon.ico">
<link rel="stylesheet" href="/css/leaderboard.css">
<style>
.legal-container {
max-width: 1000px;
margin: 0 auto;
padding: 2rem;
min-height: 100vh;
}
.legal-header {
text-align: center;
margin-bottom: 3rem;
}
.legal-title {
font-size: 3.5rem;
font-weight: 700;
background: linear-gradient(135deg, #00d4ff, #ff6b35, #ffd700);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 0.5rem;
letter-spacing: -0.02em;
}
.legal-subtitle {
font-size: 1.2rem;
color: #8892b0;
font-weight: 300;
}
.legal-content {
background: rgba(30, 41, 59, 0.8);
backdrop-filter: blur(20px);
border: 1px solid rgba(51, 65, 85, 0.3);
border-radius: 20px;
padding: 3rem;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
}
.section {
margin-bottom: 2.5rem;
}
.section h2 {
color: #00d4ff;
font-size: 1.8rem;
margin-bottom: 1rem;
border-bottom: 2px solid #334155;
padding-bottom: 0.5rem;
font-weight: 600;
}
.section h3 {
color: #ff6b35;
font-size: 1.3rem;
margin: 1.5rem 0 0.8rem 0;
font-weight: 500;
}
.section p, .section li {
margin-bottom: 0.8rem;
color: #e2e8f0;
line-height: 1.6;
}
.section ul {
margin-left: 1.5rem;
}
.contact-info {
background: rgba(0, 212, 255, 0.1);
border: 1px solid rgba(0, 212, 255, 0.3);
padding: 1.5rem;
border-radius: 12px;
margin: 1rem 0;
}
.contact-info p {
margin-bottom: 0.5rem;
}
.contact-info strong {
color: #00d4ff;
}
.back-button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
background: linear-gradient(135deg, #00d4ff, #0099cc);
color: white;
padding: 12px 24px;
text-decoration: none;
border-radius: 12px;
margin-top: 2rem;
font-weight: 600;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(0, 212, 255, 0.3);
}
.back-button:hover {
background: linear-gradient(135deg, #0099cc, #007aa3);
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 212, 255, 0.4);
}
.highlight-box {
background: rgba(255, 107, 53, 0.1);
border: 1px solid rgba(255, 107, 53, 0.3);
padding: 1.5rem;
border-radius: 12px;
margin: 1.5rem 0;
}
.highlight-box h3 {
color: #ff6b35;
margin-top: 0;
}
.highlight-box p {
color: #e2e8f0;
margin: 0;
}
@media (max-width: 768px) {
.legal-container {
padding: 1rem;
}
.legal-content {
padding: 1.5rem;
}
.legal-title {
font-size: 2.5rem;
}
}
</style>
</head>
<body>
<div class="legal-container">
<div class="legal-header">
<h1 class="legal-title">🏃‍♂️ Impressum</h1>
<p class="legal-subtitle">NinjaCross - Speedrun Arena</p>
</div>
<div class="legal-content">
<div class="section">
<h2>Angaben gemäß § 5 TMG</h2>
<div class="contact-info">
<p><strong>Betreiber der Website:</strong></p>
<p>Carsten Graf<br>
Erfurter Str. 20<br>
75365 Calw<br>
Deutschland</p>
<p><strong>Kontakt:</strong></p>
<p>Telefon: +49 (0) 123 456789<br>
E-Mail: reptil1990(at)me.com</p>
</div>
</div>
<div class="section">
<h2>Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV</h2>
<p>Carsten Graf<br>
Erfurter Str. 20<br>
75365 Calw<br>
Deutschland</p>
</div>
<div class="section">
<h2>Haftung für Inhalte</h2>
<p>Als Diensteanbieter sind wir gemäß § 7 Abs.1 TMG für eigene Inhalte auf diesen Seiten nach den allgemeinen Gesetzen verantwortlich. Nach §§ 8 bis 10 TMG sind wir als Diensteanbieter jedoch nicht unter der Verpflichtung, übermittelte oder gespeicherte fremde Informationen zu überwachen oder nach Umständen zu forschen, die auf eine rechtswidrige Tätigkeit hinweisen.</p>
<p>Verpflichtungen zur Entfernung oder Sperrung der Nutzung von Informationen nach den allgemeinen Gesetzen bleiben hiervon unberührt. Eine diesbezügliche Haftung ist jedoch erst ab dem Zeitpunkt der Kenntnis einer konkreten Rechtsverletzung möglich. Bei Bekanntwerden von entsprechenden Rechtsverletzungen werden wir diese Inhalte umgehend entfernen.</p>
</div>
<div class="section">
<h2>Haftung für Links</h2>
<p>Unser Angebot enthält Links zu externen Websites Dritter, auf deren Inhalte wir keinen Einfluss haben. Deshalb können wir für diese fremden Inhalte auch keine Gewähr übernehmen. Für die Inhalte der verlinkten Seiten ist stets der jeweilige Anbieter oder Betreiber der Seiten verantwortlich. Die verlinkten Seiten wurden zum Zeitpunkt der Verlinkung auf mögliche Rechtsverstöße überprüft. Rechtswidrige Inhalte waren zum Zeitpunkt der Verlinkung nicht erkennbar.</p>
<p>Eine permanente inhaltliche Kontrolle der verlinkten Seiten ist jedoch ohne konkrete Anhaltspunkte einer Rechtsverletzung nicht zumutbar. Bei Bekanntwerden von Rechtsverletzungen werden wir derartige Links umgehend entfernen.</p>
</div>
<div class="section">
<h2>Urheberrecht</h2>
<p>Die durch die Seitenbetreiber erstellten Inhalte und Werke auf diesen Seiten unterliegen dem deutschen Urheberrecht. Die Vervielfältigung, Bearbeitung, Verbreitung und jede Art der Verwertung außerhalb der Grenzen des Urheberrechtes bedürfen der schriftlichen Zustimmung des jeweiligen Autors bzw. Erstellers. Downloads und Kopien dieser Seite sind nur für den privaten, nicht kommerziellen Gebrauch gestattet.</p>
<p>Soweit die Inhalte auf dieser Seite nicht vom Betreiber erstellt wurden, werden die Urheberrechte Dritter beachtet. Insbesondere werden Inhalte Dritter als solche gekennzeichnet. Sollten Sie trotzdem auf eine Urheberrechtsverletzung aufmerksam werden, bitten wir um einen entsprechenden Hinweis. Bei Bekanntwerden von Rechtsverletzungen werden wir derartige Inhalte umgehend entfernen.</p>
</div>
<div class="section">
<h2>Streitschlichtung</h2>
<p>Die Europäische Kommission stellt eine Plattform zur Online-Streitbeilegung (OS) bereit: <a href="https://ec.europa.eu/consumers/odr/" target="_blank" rel="noopener">https://ec.europa.eu/consumers/odr/</a></p>
<p>Unsere E-Mail-Adresse finden Sie oben im Impressum.</p>
<p>Wir sind nicht bereit oder verpflichtet, an Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen.</p>
</div>
<a href="/" class="back-button">← Zurück zur Startseite</a>
</div>
</div>
<!-- Footer -->
<footer class="footer">
<div class="footer-content">
<div class="footer-links">
<a href="/impressum.html" class="footer-link">Impressum</a>
<a href="/datenschutz.html" class="footer-link">Datenschutz</a>
<button id="cookie-settings-footer" class="footer-link cookie-settings-btn">Cookie-Einstellungen</button>
</div>
<div class="footer-text">
<p>&copy; 2024 NinjaCross. Alle Rechte vorbehalten.</p>
</div>
</div>
</footer>
<script src="/js/cookie-consent.js"></script>
<script>
// Add cookie settings button functionality
document.addEventListener('DOMContentLoaded', function() {
const cookieSettingsBtn = document.getElementById('cookie-settings-footer');
if (cookieSettingsBtn) {
cookieSettingsBtn.addEventListener('click', function() {
if (window.cookieConsent) {
window.cookieConsent.resetConsent();
}
});
}
});
</script>
</body>
</html>

262
public/index.html Normal file
View File

@@ -0,0 +1,262 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Timer Leaderboard</title>
<link rel="icon" type="image/x-icon" href="/pictures/favicon.ico">
<script src="/js/page-tracking.js"></script>
<script src="/js/cookie-utils.js"></script>
<link rel="stylesheet" href="/css/index.css">
</head>
<body>
<!-- Notification Bubble -->
<div class="notification-bubble" id="notificationBubble">
<div class="notification-content">
<div class="notification-icon">🏁</div>
<div class="notification-text">
<div class="notification-title" id="notificationTitle" data-de="Neue Zeit!" data-en="New Time!">New Time!</div>
<div class="notification-subtitle" id="notificationSubtitle" data-de="Ein neuer Rekord wurde erstellt" data-en="A new record has been created">A new record has been created</div>
</div>
</div>
</div>
<div class="main-container">
<!-- Sticky Header Container -->
<div class="sticky-header">
<!-- Language Selector -->
<div class="language-selector">
<select id="languageSelect" onchange="changeLanguage()">
<option value="de" data-flag="🇩🇪">Deutsch</option>
<option value="en" data-flag="🇺🇸">English</option>
</select>
</div>
<!-- Admin Login Button -->
<div class="mobile-nav-buttons">
<a href="/login" class="admin-login-btn" id="adminLoginBtn" onclick="handleLoginClick(event)">🔐 Login</a>
<a href="/dashboard" class="dashboard-btn" id="dashboardBtn" style="display: none;">📊 Dashboard</a>
<button class="logout-btn" id="logoutBtn" onclick="logout()" style="display: none;">🚪 Logout</button>
</div>
</div>
<div class="header-section">
<h1 class="main-title">NINJACROSS LEADERBOARD</h1>
<p class="tagline" data-de="Die ultimative NinjaCross-Rangliste" data-en="The ultimate NinjaCross leaderboard">The ultimate NinjaCross leaderboard</p>
</div>
<div class="dashboard-grid">
<div class="control-panel">
<div class="control-group">
<label class="control-label" data-de="Standort" data-en="Location">Location</label>
<div class="location-control">
<select class="custom-select location-select" id="locationSelect">
<option value="">📍 Please select location</option>
<!-- Locations are loaded dynamically -->
</select>
<button class="location-btn" id="findLocationBtn" onclick="findNearestLocation()" title="Find nearest location" data-de="📍 Mein Standort" data-en="📍 My Location">
📍 My Location
</button>
</div>
</div>
<div class="control-group">
<label class="control-label" data-de="Zeitraum" data-en="Time Period">Time Period</label>
<div class="time-tabs">
<button class="time-tab" data-period="today">
<span class="tab-icon">📅</span>
<span class="tab-text" data-de="Heute" data-en="Today">Today</span>
</button>
<button class="time-tab" data-period="week">
<span class="tab-icon">📊</span>
<span class="tab-text" data-de="Diese Woche" data-en="This Week">This Week</span>
</button>
<button class="time-tab" data-period="month">
<span class="tab-icon">📈</span>
<span class="tab-text" data-de="Dieser Monat" data-en="This Month">This Month</span>
</button>
<button class="time-tab active" data-period="all">
<span class="tab-icon">♾️</span>
<span class="tab-text" data-de="Alle Zeiten" data-en="All Times">All Times</span>
</button>
</div>
</div>
<button class="refresh-btn pulse-animation" onclick="loadData()" data-de="⚡ Live Update" data-en="⚡ Live Update">
⚡ Live Update
</button>
</div>
<div class="stats-panel">
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value" id="totalPlayers">0</div>
<div class="stat-label" data-de="Teilnehmer" data-en="Participants">Participants</div>
</div>
<div class="stat-card">
<div class="stat-value" id="bestTime">--:--</div>
<div class="stat-label" data-de="Rekordzeit" data-en="Record Time">Record Time</div>
</div>
<div class="stat-card">
<div class="stat-value" id="totalRecords">0</div>
<div class="stat-label" data-de="Läufe" data-en="Runs">Runs</div>
</div>
</div>
</div>
</div>
<div class="leaderboard-container">
<div class="leaderboard-header">
<div class="active-filters" id="currentSelection">
📍 Please select location • ♾️ All Times
</div>
<div class="last-sync" id="lastUpdated">
Last Sync: Loading...
</div>
</div>
<div class="rankings-list" id="rankingList">
<!-- Rankings will be inserted here -->
</div>
</div>
</div>
<!-- Footer -->
<footer class="footer">
<div class="footer-content">
<div class="footer-links">
<a href="/impressum.html" class="footer-link" data-de="Impressum" data-en="Imprint">Imprint</a>
<a href="/datenschutz.html" class="footer-link" data-de="Datenschutz" data-en="Privacy">Privacy</a>
<button id="cookie-settings-footer" class="footer-link cookie-settings-btn" data-de="Cookie-Einstellungen" data-en="Cookie Settings">Cookie Settings</button>
</div>
<div class="footer-text">
<p data-de="&copy; 2024 NinjaCross. Alle Rechte vorbehalten." data-en="&copy; 2024 NinjaCross. All rights reserved.">&copy; 2024 NinjaCross. All rights reserved.</p>
</div>
</div>
</footer>
<!-- External Libraries -->
<script src="https://unpkg.com/@supabase/supabase-js@2"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.8.1/socket.io.min.js"></script>
<!-- Application JavaScript -->
<script src="/js/cookie-consent.js"></script>
<script src="/js/index.js"></script>
<script>
// iOS Detection
function isIOS() {
return /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
}
// Mobile Sticky Header Fix
function initMobileStickyHeader() {
const stickyHeader = document.querySelector('.sticky-header');
if (!stickyHeader) return;
// Check if we're on mobile
const isMobile = window.innerWidth <= 768;
if (isMobile || isIOS()) {
// Force fixed positioning for mobile
stickyHeader.style.position = 'fixed';
stickyHeader.style.top = '0';
stickyHeader.style.left = '0';
stickyHeader.style.right = '0';
stickyHeader.style.width = '100%';
stickyHeader.style.zIndex = '99999';
stickyHeader.style.marginBottom = '0';
stickyHeader.style.borderRadius = '0';
// Add padding to main container
const mainContainer = document.querySelector('.main-container');
if (mainContainer) {
mainContainer.style.paddingTop = '80px';
}
console.log('Mobile sticky header fix applied');
}
}
// iOS Touch Event Fix
function handleLoginClick(event) {
console.log('Login button clicked');
// Prevent default behavior temporarily
event.preventDefault();
// Add visual feedback
const button = event.target;
button.style.transform = 'scale(0.95)';
button.style.opacity = '0.8';
// Reset after short delay
setTimeout(() => {
button.style.transform = '';
button.style.opacity = '';
// Navigate to login page
window.location.href = '/login';
}, 150);
}
// Add touch event listeners for better mobile support
document.addEventListener('DOMContentLoaded', function() {
// Initialize mobile sticky header
initMobileStickyHeader();
const loginBtn = document.getElementById('adminLoginBtn');
if (loginBtn) {
// Make sure button is clickable
loginBtn.style.pointerEvents = 'auto';
loginBtn.style.position = 'relative';
loginBtn.style.zIndex = '100000';
// Add multiple event listeners for maximum compatibility
loginBtn.addEventListener('touchstart', function(e) {
console.log('Touch start on login button');
e.preventDefault();
this.style.transform = 'scale(0.95)';
this.style.opacity = '0.8';
}, { passive: false });
loginBtn.addEventListener('touchend', function(e) {
console.log('Touch end on login button');
e.preventDefault();
e.stopPropagation();
setTimeout(() => {
this.style.transform = '';
this.style.opacity = '';
window.location.href = '/login';
}, 100);
}, { passive: false });
loginBtn.addEventListener('click', function(e) {
console.log('Click on login button');
e.preventDefault();
e.stopPropagation();
window.location.href = '/login';
});
// Add mousedown for desktop
loginBtn.addEventListener('mousedown', function(e) {
console.log('Mouse down on login button');
this.style.transform = 'scale(0.95)';
this.style.opacity = '0.8';
});
loginBtn.addEventListener('mouseup', function(e) {
console.log('Mouse up on login button');
this.style.transform = '';
this.style.opacity = '';
});
}
});
// Re-apply mobile fixes on window resize
window.addEventListener('resize', function() {
setTimeout(initMobileStickyHeader, 100);
});
</script>
</body>
</html>

1768
public/js/admin-dashboard.js Normal file

File diff suppressed because it is too large Load Diff

91
public/js/adminlogin.js Normal file
View File

@@ -0,0 +1,91 @@
function showMessage(elementId, message, isError = false) {
const messageDiv = document.getElementById(elementId);
messageDiv.textContent = message;
messageDiv.classList.add("show");
setTimeout(() => {
messageDiv.classList.remove("show");
}, 4000);
}
function showError(message) {
showMessage("error", message, true);
}
function showSuccess(message) {
showMessage("success", message, false);
}
function setLoading(isLoading) {
const btnText = document.getElementById("btn-text");
const btn = document.getElementById("loginBtn");
if (isLoading) {
btnText.innerHTML = '<span class="loading"></span>Anmelde...';
btn.disabled = true;
} else {
btnText.textContent = 'Anmelden';
btn.disabled = false;
}
}
async function handleLogin(event) {
event.preventDefault();
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value;
if (!username || !password) {
showError('Bitte füllen Sie alle Felder aus.');
return;
}
setLoading(true);
try {
const response = await fetch('/api/v1/public/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, password })
});
const result = await response.json();
if (result.success) {
showSuccess('✅ Anmeldung erfolgreich! Weiterleitung...');
setTimeout(() => {
window.location.href = '/admin-dashboard';
}, 1000);
} else {
showError(result.message || 'Anmeldung fehlgeschlagen');
}
} catch (error) {
console.error('Fehler bei der Anmeldung:', error);
showError('Verbindungsfehler. Bitte versuchen Sie es erneut.');
} finally {
setLoading(false);
}
}
// Enter-Taste für Login
document.addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
handleLogin(e);
}
});
// Fokus auf erstes Eingabefeld
document.addEventListener('DOMContentLoaded', function() {
document.getElementById('username').focus();
// Add cookie settings button functionality
const cookieSettingsBtn = document.getElementById('cookie-settings-footer');
if (cookieSettingsBtn) {
cookieSettingsBtn.addEventListener('click', function() {
if (window.cookieConsent) {
window.cookieConsent.resetConsent();
}
});
}
});

601
public/js/cookie-consent.js Normal file
View File

@@ -0,0 +1,601 @@
// Cookie Consent Management
class CookieConsent {
constructor() {
this.cookieName = 'ninjacross_cookie_consent';
this.cookieSettingsName = 'ninjacross_cookie_settings';
this.consentGiven = false;
this.settings = {
necessary: true, // Always true, can't be disabled
functional: false,
analytics: false
};
this.init();
}
init() {
// Check if consent was already given
const savedConsent = this.getCookie(this.cookieName);
const savedSettings = this.getCookie(this.cookieSettingsName);
if (savedConsent === 'true') {
this.consentGiven = true;
if (savedSettings) {
this.settings = { ...this.settings, ...JSON.parse(savedSettings) };
}
this.applySettings();
} else {
this.showConsentBanner();
}
}
showConsentBanner() {
// Don't show banner if already shown
if (document.getElementById('cookie-consent-banner')) {
return;
}
const banner = document.createElement('div');
banner.id = 'cookie-consent-banner';
banner.innerHTML = `
<div class="cookie-banner">
<div class="cookie-content">
<div class="cookie-icon">🍪</div>
<div class="cookie-text">
<h3>Cookie-Einstellungen</h3>
<p>Wir verwenden Cookies, um Ihnen die beste Erfahrung auf unserer Website zu bieten. Einige sind notwendig, andere helfen uns, die Website zu verbessern.</p>
</div>
</div>
<div class="cookie-actions">
<button id="cookie-settings-btn" class="btn-cookie-settings">Einstellungen</button>
<button id="cookie-accept-all" class="btn-cookie-accept">Alle akzeptieren</button>
<button id="cookie-accept-necessary" class="btn-cookie-necessary">Nur notwendige</button>
</div>
</div>
`;
// Add styles
const style = document.createElement('style');
style.textContent = `
#cookie-consent-banner {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%);
color: white;
z-index: 10000;
box-shadow: 0 -4px 20px rgba(0,0,0,0.3);
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
.cookie-banner {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px;
max-width: 1200px;
margin: 0 auto;
gap: 20px;
}
.cookie-content {
display: flex;
align-items: center;
gap: 15px;
flex: 1;
}
.cookie-icon {
font-size: 2rem;
flex-shrink: 0;
}
.cookie-text h3 {
margin: 0 0 5px 0;
font-size: 1.1rem;
color: white;
}
.cookie-text p {
margin: 0;
font-size: 0.9rem;
opacity: 0.9;
line-height: 1.4;
}
.cookie-actions {
display: flex;
gap: 10px;
flex-shrink: 0;
}
.btn-cookie-settings,
.btn-cookie-accept,
.btn-cookie-necessary {
padding: 10px 16px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
transition: all 0.2s ease;
}
.btn-cookie-settings {
background: rgba(255,255,255,0.2);
color: white;
border: 1px solid rgba(255,255,255,0.3);
}
.btn-cookie-settings:hover {
background: rgba(255,255,255,0.3);
}
.btn-cookie-accept {
background: #10b981;
color: white;
}
.btn-cookie-accept:hover {
background: #059669;
}
.btn-cookie-necessary {
background: #6b7280;
color: white;
}
.btn-cookie-necessary:hover {
background: #4b5563;
}
@media (max-width: 768px) {
.cookie-banner {
flex-direction: column;
text-align: center;
gap: 15px;
}
.cookie-content {
flex-direction: column;
text-align: center;
}
.cookie-actions {
flex-wrap: wrap;
justify-content: center;
}
}
`;
document.head.appendChild(style);
document.body.appendChild(banner);
// Add event listeners
document.getElementById('cookie-accept-all').addEventListener('click', () => {
this.acceptAll();
});
document.getElementById('cookie-accept-necessary').addEventListener('click', () => {
this.acceptNecessary();
});
document.getElementById('cookie-settings-btn').addEventListener('click', () => {
this.showSettingsModal();
});
}
showSettingsModal() {
// Remove banner
const banner = document.getElementById('cookie-consent-banner');
if (banner) {
banner.remove();
}
// Create modal
const modal = document.createElement('div');
modal.id = 'cookie-settings-modal';
modal.innerHTML = `
<div class="cookie-modal-overlay">
<div class="cookie-modal">
<div class="cookie-modal-header">
<h2>🍪 Cookie-Einstellungen</h2>
<button id="cookie-modal-close" class="btn-close">&times;</button>
</div>
<div class="cookie-modal-content">
<p>Wählen Sie aus, welche Cookies Sie zulassen möchten:</p>
<div class="cookie-category">
<div class="cookie-category-header">
<h3>Notwendige Cookies</h3>
<label class="cookie-toggle">
<input type="checkbox" id="necessary-cookies" checked disabled>
<span class="toggle-slider"></span>
</label>
</div>
<p>Diese Cookies sind für die Grundfunktionen der Website erforderlich und können nicht deaktiviert werden.</p>
</div>
<div class="cookie-category">
<div class="cookie-category-header">
<h3>Funktionale Cookies</h3>
<label class="cookie-toggle">
<input type="checkbox" id="functional-cookies" ${this.settings.functional ? 'checked' : ''}>
<span class="toggle-slider"></span>
</label>
</div>
<p>Diese Cookies ermöglichen erweiterte Funktionen wie Benutzeranmeldung und Einstellungen.</p>
</div>
<div class="cookie-category">
<div class="cookie-category-header">
<h3>Analyse-Cookies</h3>
<label class="cookie-toggle">
<input type="checkbox" id="analytics-cookies" ${this.settings.analytics ? 'checked' : ''}>
<span class="toggle-slider"></span>
</label>
</div>
<p>Diese Cookies helfen uns zu verstehen, wie Besucher mit der Website interagieren.</p>
</div>
</div>
<div class="cookie-modal-footer">
<button id="cookie-save-settings" class="btn-save">Einstellungen speichern</button>
<button id="cookie-accept-all-modal" class="btn-accept-all">Alle akzeptieren</button>
</div>
</div>
</div>
`;
// Add modal styles
const modalStyle = document.createElement('style');
modalStyle.textContent = `
.cookie-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
z-index: 10001;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.cookie-modal {
background: white;
border-radius: 12px;
max-width: 600px;
width: 100%;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 20px 40px rgba(0,0,0,0.3);
animation: modalSlideIn 0.3s ease-out;
}
@keyframes modalSlideIn {
from { transform: scale(0.9); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
.cookie-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
border-bottom: 1px solid #e2e8f0;
}
.cookie-modal-header h2 {
margin: 0;
color: #1e3c72;
}
.btn-close {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #6b7280;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
}
.btn-close:hover {
color: #374151;
}
.cookie-modal-content {
padding: 20px;
}
.cookie-category {
margin-bottom: 20px;
padding: 15px;
border: 1px solid #e2e8f0;
border-radius: 8px;
}
.cookie-category-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.cookie-category-header h3 {
margin: 0;
color: #1e3c72;
font-size: 1.1rem;
}
.cookie-category p {
margin: 0;
color: #6b7280;
font-size: 0.9rem;
line-height: 1.4;
}
.cookie-toggle {
position: relative;
display: inline-block;
width: 50px;
height: 24px;
}
.cookie-toggle input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: .4s;
border-radius: 24px;
}
.toggle-slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: white;
transition: .4s;
border-radius: 50%;
}
.cookie-toggle input:checked + .toggle-slider {
background-color: #1e3c72;
}
.cookie-toggle input:checked + .toggle-slider:before {
transform: translateX(26px);
}
.cookie-toggle input:disabled + .toggle-slider {
background-color: #10b981;
cursor: not-allowed;
}
.cookie-modal-footer {
padding: 20px;
border-top: 1px solid #e2e8f0;
display: flex;
gap: 10px;
justify-content: flex-end;
}
.btn-save, .btn-accept-all {
padding: 10px 20px;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
transition: all 0.2s ease;
}
.btn-save {
background: #1e3c72;
color: white;
}
.btn-save:hover {
background: #2a5298;
}
.btn-accept-all {
background: #10b981;
color: white;
}
.btn-accept-all:hover {
background: #059669;
}
@media (max-width: 768px) {
.cookie-modal {
margin: 10px;
max-height: 90vh;
}
.cookie-modal-footer {
flex-direction: column;
}
.btn-save, .btn-accept-all {
width: 100%;
}
}
`;
document.head.appendChild(modalStyle);
document.body.appendChild(modal);
// Add event listeners
document.getElementById('cookie-modal-close').addEventListener('click', () => {
modal.remove();
this.showConsentBanner();
});
document.getElementById('cookie-save-settings').addEventListener('click', () => {
this.saveSettings();
modal.remove();
});
document.getElementById('cookie-accept-all-modal').addEventListener('click', () => {
this.acceptAll();
modal.remove();
});
// Close on overlay click
modal.querySelector('.cookie-modal-overlay').addEventListener('click', (e) => {
if (e.target === e.currentTarget) {
modal.remove();
this.showConsentBanner();
}
});
}
saveSettings() {
this.settings.functional = document.getElementById('functional-cookies').checked;
this.settings.analytics = document.getElementById('analytics-cookies').checked;
this.setCookie(this.cookieName, 'true', 365);
this.setCookie(this.cookieSettingsName, JSON.stringify(this.settings), 365);
this.consentGiven = true;
this.applySettings();
}
acceptAll() {
this.settings.functional = true;
this.settings.analytics = true;
this.setCookie(this.cookieName, 'true', 365);
this.setCookie(this.cookieSettingsName, JSON.stringify(this.settings), 365);
this.consentGiven = true;
this.applySettings();
}
acceptNecessary() {
this.settings.functional = false;
this.settings.analytics = false;
this.setCookie(this.cookieName, 'true', 365);
this.setCookie(this.cookieSettingsName, JSON.stringify(this.settings), 365);
this.consentGiven = true;
this.applySettings();
}
applySettings() {
// Remove banner if exists
const banner = document.getElementById('cookie-consent-banner');
if (banner) {
banner.remove();
}
// Apply functional cookies
if (this.settings.functional) {
// Enable functional features
console.log('Functional cookies enabled');
} else {
// Disable functional features
console.log('Functional cookies disabled');
}
// Apply analytics cookies
if (this.settings.analytics) {
// Enable analytics
console.log('Analytics cookies enabled');
this.enableAnalytics();
} else {
// Disable analytics
console.log('Analytics cookies disabled');
this.disableAnalytics();
}
}
enableAnalytics() {
// Enable page tracking
if (typeof trackPageView === 'function') {
trackPageView('main_page_visit');
}
}
disableAnalytics() {
// Disable page tracking
console.log('Analytics disabled by user choice');
}
setCookie(name, value, days) {
const expires = new Date();
expires.setTime(expires.getTime() + (days * 24 * 60 * 60 * 1000));
document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/;SameSite=Lax`;
}
getCookie(name) {
const nameEQ = name + "=";
const ca = document.cookie.split(';');
for (let i = 0; i < ca.length; i++) {
let c = ca[i];
while (c.charAt(0) === ' ') c = c.substring(1, c.length);
if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length);
}
return null;
}
// Public method to check if consent was given
hasConsent() {
return this.consentGiven;
}
// Public method to get current settings
getSettings() {
return { ...this.settings };
}
// Public method to reset consent
resetConsent() {
this.setCookie(this.cookieName, '', -1);
this.setCookie(this.cookieSettingsName, '', -1);
this.consentGiven = false;
this.settings = {
necessary: true,
functional: false,
analytics: false
};
this.showConsentBanner();
}
}
// Initialize cookie consent when DOM is loaded
document.addEventListener('DOMContentLoaded', function() {
window.cookieConsent = new CookieConsent();
});
// Export for use in other scripts
if (typeof module !== 'undefined' && module.exports) {
module.exports = CookieConsent;
}

105
public/js/cookie-utils.js Normal file
View File

@@ -0,0 +1,105 @@
// Cookie Utility Functions
class CookieManager {
// Set a cookie
static setCookie(name, value, days = 30) {
const expires = new Date();
expires.setTime(expires.getTime() + (days * 24 * 60 * 60 * 1000));
document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/;SameSite=Lax`;
}
// Get a cookie
static getCookie(name) {
const nameEQ = name + "=";
const ca = document.cookie.split(';');
for (let i = 0; i < ca.length; i++) {
let c = ca[i];
while (c.charAt(0) === ' ') c = c.substring(1, c.length);
if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length);
}
return null;
}
// Delete a cookie
static deleteCookie(name) {
document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 UTC;path=/;`;
}
// Check if cookies are enabled
static areCookiesEnabled() {
try {
this.setCookie('test', 'test');
const enabled = this.getCookie('test') === 'test';
this.deleteCookie('test');
return enabled;
} catch (e) {
return false;
}
}
}
// Location-specific cookie functions
class LocationCookieManager {
static COOKIE_NAME = 'ninjacross_last_location';
static COOKIE_EXPIRY_DAYS = 90; // 3 months
// Save last selected location
static saveLastLocation(locationId, locationName) {
if (!locationId || !locationName) return;
const locationData = {
id: locationId,
name: locationName,
timestamp: new Date().toISOString()
};
try {
CookieManager.setCookie(
this.COOKIE_NAME,
JSON.stringify(locationData),
this.COOKIE_EXPIRY_DAYS
);
console.log('✅ Location saved to cookie:', locationName);
} catch (error) {
console.error('❌ Failed to save location to cookie:', error);
}
}
// Get last selected location
static getLastLocation() {
try {
const cookieValue = CookieManager.getCookie(this.COOKIE_NAME);
if (!cookieValue) return null;
const locationData = JSON.parse(cookieValue);
// Check if cookie is not too old (optional: 30 days max)
const cookieDate = new Date(locationData.timestamp);
const maxAge = 30 * 24 * 60 * 60 * 1000; // 30 days in milliseconds
if (Date.now() - cookieDate.getTime() > maxAge) {
this.clearLastLocation();
return null;
}
return locationData;
} catch (error) {
console.error('❌ Failed to parse location cookie:', error);
this.clearLastLocation();
return null;
}
}
// Clear last location
static clearLastLocation() {
CookieManager.deleteCookie(this.COOKIE_NAME);
console.log('🗑️ Location cookie cleared');
}
// Check if location cookie exists
static hasLastLocation() {
return this.getLastLocation() !== null;
}
}
// Export for use in other scripts
window.CookieManager = CookieManager;
window.LocationCookieManager = LocationCookieManager;

2163
public/js/dashboard.js Normal file

File diff suppressed because it is too large Load Diff

573
public/js/generator.js Normal file
View File

@@ -0,0 +1,573 @@
// Toggle Token-Felder basierend auf Lizenzstufe
function toggleTokenFields() {
const tierInput = document.getElementById("tier");
const dbConfig = document.getElementById("dbConfig");
const tier = parseInt(tierInput.value);
if (tier >= 3 && !isNaN(tier)) {
dbConfig.innerHTML = `
<h3>🗄️ Token-Informationen (für Stufe 3+)</h3>
<div class="form-group">
<label for="description">Token Beschreibung</label>
<textarea id="description" placeholder="z.B. API-Zugang für Standort München"></textarea>
</div>
<div class="form-group">
<label for="standorte">Standorte</label>
<input type="text" id="standorte" placeholder="z.B. München, Berlin">
</div>
<!-- Neue Standortsuche-Sektion -->
<div class="form-group">
<label for="locationSearch">Standort suchen & auf Karte anzeigen</label>
<div class="location-search-container">
<input type="text" id="locationSearch" placeholder="z.B. München, Marienplatz">
<button onclick="searchLocation(this)" style="padding: 15px 20px; background: #4caf50; color: white; border: none; border-radius: 12px; cursor: pointer; font-weight: 500; transition: all 0.3s ease;">🔍 Suchen</button>
</div>
</div>
<!-- Koordinaten-Anzeige -->
<div id="coordinates" class="coordinates-display" style="display: none;">
<div style="background: #f0f8ff; border: 2px solid #4caf50; border-radius: 8px; padding: 15px; margin-top: 15px;">
<h4 style="margin: 0 0 10px 0; color: #2e7d32;">📍 Gefundene Koordinaten:</h4>
<div style="display: flex; gap: 20px; flex-wrap: wrap;">
<div>
<strong>Breitengrad (LAT):</strong>
<span id="latitude" style="font-family: monospace; color: #1565c0;"></span>
</div>
<div>
<strong>Längengrad (LON):</strong>
<span id="longitude" style="font-family: monospace; color: #1565c0;"></span>
</div>
</div>
<div style="margin-top: 15px; text-align: center;">
<div style="font-size: 0.85em; color: #666; margin-bottom: 10px;">
💡 Der Standort wird automatisch beim Generieren der Lizenz gespeichert
</div>
<button id="saveLocationBtn" onclick="saveLocationToDatabase()" style="padding: 12px 24px; background: #2196f3; color: white; border: none; border-radius: 8px; cursor: pointer; font-weight: 500; transition: all 0.3s ease;">
💾 Standort manuell speichern
</button>
</div>
</div>
</div>
<!-- Karten-Container -->
<div id="mapContainer" class="map-container" style="display: none; margin-top: 20px;">
<h4 style="margin: 0 0 15px 0; color: #333;">🗺️ Standort auf der Karte:</h4>
<div id="mapFrame" style="width: 100%; height: 300px; border: 2px solid #ddd; border-radius: 12px; overflow: hidden;"></div>
</div>
<div class="info-text" style="margin-top: 10px; font-size: 0.8em;">
📝 Standorte werden in der lokalen PostgreSQL-Datenbank gespeichert
</div>
`;
dbConfig.classList.add("show");
} else {
dbConfig.classList.remove("show");
setTimeout(() => {
if (!dbConfig.classList.contains("show")) {
dbConfig.innerHTML = "";
}
}, 400);
}
}
const secret = "542ff224606c61fb3024e22f76ef9ac8";
function isValidMac(mac) {
const pattern = /^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$|^[0-9A-Fa-f]{12}$/;
return pattern.test(mac);
}
function showMessage(elementId, message, isError = false) {
const messageDiv = document.getElementById(elementId);
messageDiv.textContent = message;
messageDiv.classList.add("show");
setTimeout(() => {
messageDiv.classList.remove("show");
}, 4000);
}
function showError(message) {
showMessage("error", message, true);
}
function showSuccess(message) {
showMessage("success", message, false);
}
function setLoading(isLoading) {
const btnText = document.getElementById("btn-text");
const btn = document.querySelector(".generate-btn");
if (isLoading) {
btnText.innerHTML = '<span class="loading"></span>Generiere...';
btn.disabled = true;
btn.style.opacity = '0.7';
} else {
btnText.textContent = 'Lizenz generieren';
btn.disabled = false;
btn.style.opacity = '1';
}
}
async function saveToDatabase(token, tier) {
const description = document.getElementById("description").value.trim();
const standorte = document.getElementById("standorte").value.trim();
try {
const response = await fetch('/api/v1/web/save-token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
token: token,
description: description || `API-Token Stufe ${tier}`,
standorte: standorte
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Fehler beim Speichern in der Datenbank');
}
const result = await response.json();
return result;
} catch (error) {
// Fallback: Zeige dem Benutzer den SQL-Befehl an, den er manuell ausführen kann
const sql = `INSERT INTO api_tokens (token, description, standorte) VALUES ('${token}', '${description || `API-Token Stufe ${tier}`}', '${standorte}');`;
throw new Error(`Automatisches Speichern fehlgeschlagen. Server nicht erreichbar.\n\nFühren Sie folgenden SQL-Befehl manuell aus:\n${sql}`);
}
}
async function generateLicense() {
const macInput = document.getElementById("mac").value.trim();
const tierInput = document.getElementById("tier").value.trim();
const resultDiv = document.getElementById("result");
const licenseOutput = document.getElementById("license-output");
const errorDiv = document.getElementById("error");
const successDiv = document.getElementById("success");
// Reset states
resultDiv.classList.remove("show");
errorDiv.classList.remove("show");
successDiv.classList.remove("show");
setLoading(true);
// Simulate slight delay for better UX
await new Promise(resolve => setTimeout(resolve, 500));
try {
if (!isValidMac(macInput)) {
throw new Error("Ungültige MAC-Adresse. Bitte verwenden Sie das Format 00:1A:2B:3C:4D:5E");
}
const mac = macInput.replace(/[:-]/g, "").toUpperCase();
const tier = parseInt(tierInput);
if (isNaN(tier) || tier < 1 || tier > 4) {
throw new Error("Lizenzstufe muss eine Zahl zwischen 1 und 4 sein.");
}
// Standort automatisch speichern, falls vorhanden
let locationSaved = false;
const locationName = document.getElementById('locationSearch')?.value?.trim();
const latitude = document.getElementById('latitude')?.textContent;
const longitude = document.getElementById('longitude')?.textContent;
if (locationName && latitude && longitude && tier >= 3) {
try {
await saveLocationToDatabase();
locationSaved = true;
} catch (locationError) {
console.warn('Standort konnte nicht gespeichert werden:', locationError);
// Fahre trotzdem mit der Lizenzgenerierung fort
}
}
const data = `${mac}:${tier}`;
const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
enc.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
);
const signature = await crypto.subtle.sign("HMAC", key, enc.encode(data));
const hex = Array.from(new Uint8Array(signature))
.map(b => b.toString(16).padStart(2, "0"))
.join("")
.toUpperCase();
licenseOutput.textContent = hex;
resultDiv.classList.add("show");
// Reset copy button
const copyBtn = document.getElementById("copyButton");
copyBtn.textContent = "📋 In Zwischenablage kopieren";
copyBtn.classList.remove("copied");
// Bei Stufe 3+ in Datenbank speichern
if (tier >= 3) {
try {
await saveToDatabase(hex, tier);
let successMessage = `✅ Lizenzschlüssel generiert und als API-Token gespeichert!`;
if (locationSaved) {
successMessage += ` Standort wurde ebenfalls gespeichert.`;
}
showSuccess(successMessage);
} catch (dbError) {
showError(`⚠️ Lizenz generiert, aber Datenbank-Fehler: ${dbError.message}`);
}
} else {
let successMessage = `✅ Lizenzschlüssel erfolgreich generiert!`;
if (locationSaved) {
successMessage += ` Standort wurde in der Datenbank gespeichert.`;
}
showSuccess(successMessage);
}
} catch (error) {
showError(error.message);
} finally {
setLoading(false);
}
}
async function copyToClipboard() {
const licenseOutput = document.getElementById("license-output");
const copyBtn = document.getElementById("copyButton");
try {
await navigator.clipboard.writeText(licenseOutput.textContent);
copyBtn.textContent = "✅ Kopiert!";
copyBtn.classList.add("copied");
setTimeout(() => {
copyBtn.textContent = "📋 In Zwischenablage kopieren";
copyBtn.classList.remove("copied");
}, 2000);
} catch (err) {
// Fallback for older browsers
const textArea = document.createElement("textarea");
textArea.value = licenseOutput.textContent;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
copyBtn.textContent = "✅ Kopiert!";
copyBtn.classList.add("copied");
setTimeout(() => {
copyBtn.textContent = "📋 In Zwischenablage kopieren";
copyBtn.classList.remove("copied");
}, 2000);
}
}
// Enter key support
document.addEventListener('keypress', function (e) {
if (e.key === 'Enter') {
generateLicense();
}
});
// Input formatting for MAC address
document.getElementById('mac').addEventListener('input', function (e) {
let value = e.target.value.replace(/[^0-9A-Fa-f]/g, '');
if (value.length > 12) value = value.substr(0, 12);
// Add colons every 2 characters
value = value.replace(/(.{2})/g, '$1:').replace(/:$/, '');
e.target.value = value;
});
// Input event listener für Lizenzstufe
document.getElementById('tier').addEventListener('input', toggleTokenFields);
// Standortsuche-Funktionalität
async function searchLocation(buttonElement) {
const locationInput = document.getElementById('locationSearch').value.trim();
const coordinatesDiv = document.getElementById('coordinates');
const mapContainer = document.getElementById('mapContainer');
const latitudeSpan = document.getElementById('latitude');
const longitudeSpan = document.getElementById('longitude');
const mapFrame = document.getElementById('mapFrame');
if (!locationInput) {
showError('Bitte geben Sie einen Standort ein.');
return;
}
let originalText = '';
let searchBtn = null;
try {
// Zeige Ladeanimation
searchBtn = buttonElement || document.querySelector('button[onclick*="searchLocation"]');
if (searchBtn) {
originalText = searchBtn.innerHTML;
searchBtn.innerHTML = '<span class="loading"></span>Suche...';
searchBtn.disabled = true;
}
// API-Abfrage an Nominatim (OpenStreetMap)
const response = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(locationInput)}&limit=1`);
if (!response.ok) {
throw new Error('Fehler bei der API-Abfrage');
}
const data = await response.json();
if (data.length === 0) {
throw new Error('Standort nicht gefunden. Bitte versuchen Sie eine andere Beschreibung.');
}
const location = data[0];
const lat = parseFloat(location.lat);
const lon = parseFloat(location.lon);
// Der Name wird vom User bestimmt - nur Koordinaten aus der API verwenden
// Kein verstecktes Feld nötig, da der User den Namen selbst eingibt
// Koordinaten anzeigen
updateCoordinates(lat, lon);
coordinatesDiv.style.display = 'block';
// Interaktive Karte erstellen
createInteractiveMap(lat, lon);
mapContainer.style.display = 'block';
// Erfolgsmeldung
showSuccess(`✅ Koordinaten für "${locationInput}" erfolgreich gefunden! Klicken Sie auf die Karte, um den Pin zu verschieben.`);
} catch (error) {
showError(`Fehler bei der Standortsuche: ${error.message}`);
coordinatesDiv.style.display = 'none';
mapContainer.style.display = 'none';
} finally {
// Button zurücksetzen
if (searchBtn && originalText) {
searchBtn.innerHTML = originalText;
searchBtn.disabled = false;
}
}
}
// Koordinaten aktualisieren
function updateCoordinates(lat, lon) {
const latitudeSpan = document.getElementById('latitude');
const longitudeSpan = document.getElementById('longitude');
if (latitudeSpan && longitudeSpan) {
latitudeSpan.textContent = lat.toFixed(6);
longitudeSpan.textContent = lon.toFixed(6);
}
}
// Interaktive Karte erstellen
function createInteractiveMap(initialLat, initialLon) {
const mapFrame = document.getElementById('mapFrame');
// Verwende Leaflet.js für interaktive Karte
const mapHtml = `
<div id="interactiveMap" style="width: 100%; height: 100%; position: relative;">
<div id="map" style="width: 100%; height: 100%; border-radius: 10px;"></div>
<div style="position: absolute; top: 10px; right: 10px; background: white; padding: 8px; border-radius: 6px; box-shadow: 0 2px 8px rgba(0,0,0,0.2); font-size: 12px; color: #666;">
📍 Klicken Sie auf die Karte, um den Pin zu verschieben
</div>
</div>
`;
mapFrame.innerHTML = mapHtml;
// Leaflet.js laden und Karte initialisieren
loadLeafletAndCreateMap(initialLat, initialLon);
}
// Leaflet.js laden und Karte erstellen
function loadLeafletAndCreateMap(initialLat, initialLon) {
// Prüfe ob Leaflet bereits geladen ist
if (typeof L !== 'undefined') {
createMap(initialLat, initialLon);
return;
}
// Leaflet CSS laden
const leafletCSS = document.createElement('link');
leafletCSS.rel = 'stylesheet';
leafletCSS.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
document.head.appendChild(leafletCSS);
// Leaflet JavaScript laden
const leafletScript = document.createElement('script');
leafletScript.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js';
leafletScript.onload = () => createMap(initialLat, initialLon);
document.head.appendChild(leafletScript);
}
// Karte mit Leaflet erstellen
function createMap(initialLat, initialLon) {
try {
const map = L.map('map').setView([initialLat, initialLon], 15);
// OpenStreetMap Tile Layer
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map);
// Marker erstellen
const marker = L.marker([initialLat, initialLon], {
draggable: true,
title: 'Standort'
}).addTo(map);
// Marker-Drag Event
marker.on('dragend', function (event) {
const newLat = event.target.getLatLng().lat;
const newLon = event.target.getLatLng().lng;
updateCoordinates(newLat, newLon);
showSuccess(`📍 Pin auf neue Position verschoben: ${newLat.toFixed(6)}, ${newLon.toFixed(6)}`);
});
// Klick-Event auf die Karte
map.on('click', function (event) {
const newLat = event.latlng.lat;
const newLon = event.latlng.lng;
// Marker auf neue Position setzen
marker.setLatLng([newLat, newLon]);
// Koordinaten aktualisieren
updateCoordinates(newLat, newLon);
// Erfolgsmeldung
showSuccess(`📍 Pin auf neue Position gesetzt: ${newLat.toFixed(6)}, ${newLon.toFixed(6)}`);
});
// Zoom-Controls hinzufügen
map.zoomControl.setPosition('bottomright');
} catch (error) {
console.error('Fehler beim Erstellen der Karte:', error);
// Fallback zu iframe
const mapFrame = document.getElementById('mapFrame');
const mapUrl = `https://www.openstreetmap.org/export/embed.html?bbox=${initialLon - 0.01},${initialLat - 0.01},${initialLon + 0.01},${initialLat + 0.01}&layer=mapnik&marker=${initialLat},${initialLon}`;
mapFrame.innerHTML = `<iframe src="${mapUrl}" width="100%" height="100%" frameborder="0" scrolling="no" marginheight="0" marginwidth="0" title="Standort auf der Karte"></iframe>`;
}
}
// Standort in Datenbank speichern
async function saveLocationToDatabase() {
const locationName = document.getElementById('standorte').value.trim();
const latitude = document.getElementById('latitude').textContent;
const longitude = document.getElementById('longitude').textContent;
const saveBtn = document.getElementById('saveLocationBtn');
if (!locationName || !latitude || !longitude) {
showError('Bitte suchen Sie zuerst einen Standort.');
return;
}
try {
// Button-Status ändern
const originalText = saveBtn.innerHTML;
saveBtn.innerHTML = '<span class="loading"></span>Speichere...';
saveBtn.disabled = true;
// Web-authenticated API für Standortverwaltung aufrufen
const response = await fetch('/api/v1/web/create-location', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: locationName,
lat: parseFloat(latitude),
lon: parseFloat(longitude)
})
});
const result = await response.json();
if (result.success) {
showSuccess(`✅ Standort "${locationName}" erfolgreich in der Datenbank gespeichert!`);
saveBtn.innerHTML = '✅ Gespeichert!';
saveBtn.style.background = '#4caf50';
// Button nach 3 Sekunden zurücksetzen
setTimeout(() => {
saveBtn.innerHTML = originalText;
saveBtn.disabled = false;
saveBtn.style.background = '#2196f3';
}, 3000);
} else {
throw new Error(result.message || 'Unbekannter Fehler beim Speichern');
}
} catch (error) {
console.error('Fehler beim Speichern:', error);
showError(`Fehler beim Speichern: ${error.message}`);
// Button zurücksetzen
saveBtn.innerHTML = '💾 Standort in Datenbank speichern';
saveBtn.disabled = false;
}
}
// Zurück zum Dashboard
function goBackToDashboard() {
window.location.href = '/admin-dashboard';
}
// Logout-Funktion
async function logout() {
try {
const response = await fetch('/api/v1/public/logout', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
});
const result = await response.json();
if (result.success) {
window.location.href = '/login';
} else {
console.error('Fehler beim Abmelden:', result.message);
// Trotzdem zur Login-Seite weiterleiten
window.location.href = '/login';
}
} catch (error) {
console.error('Fehler beim Abmelden:', error);
// Bei Fehler trotzdem zur Login-Seite weiterleiten
window.location.href = '/login';
}
}
// Enter-Taste für Standortsuche
document.addEventListener('DOMContentLoaded', function () {
const locationSearch = document.getElementById('locationSearch');
if (locationSearch) {
locationSearch.addEventListener('keypress', function (e) {
if (e.key === 'Enter') {
searchLocation();
}
});
}
// Add cookie settings button functionality
const cookieSettingsBtn = document.getElementById('cookie-settings-footer');
if (cookieSettingsBtn) {
cookieSettingsBtn.addEventListener('click', function () {
if (window.cookieConsent) {
window.cookieConsent.resetConsent();
}
});
}
});

808
public/js/index.js Normal file
View File

@@ -0,0 +1,808 @@
// Supabase configuration
const SUPABASE_URL = 'https://lfxlplnypzvjrhftaoog.supabase.co';
const SUPABASE_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImxmeGxwbG55cHp2anJoZnRhb29nIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDkyMTQ3NzIsImV4cCI6MjA2NDc5MDc3Mn0.XR4preBqWAQ1rT4PFbpkmRdz57BTwIusBI89fIxDHM8';
// Initialize Supabase client
const supabase = window.supabase.createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
// Initialize Socket.IO connection
let socket;
function setupSocketListeners() {
if (!socket) return;
socket.on('connect', () => {
console.log('🔌 WebSocket connected');
});
socket.on('disconnect', () => {
console.log('🔌 WebSocket disconnected');
});
socket.on('newTime', (data) => {
console.log('🏁 New time received:', data);
showNotification(data);
// Reload data to show the new time
loadData();
});
}
function initializeSocket() {
if (typeof io !== 'undefined') {
socket = io();
setupSocketListeners();
} else {
console.error('Socket.IO library not loaded');
}
}
// Try to initialize immediately, fallback to DOMContentLoaded
if (typeof io !== 'undefined') {
initializeSocket();
} else {
document.addEventListener('DOMContentLoaded', initializeSocket);
}
// Global variable to store locations with coordinates
let locationsData = [];
let lastSelectedLocation = null;
// Cookie Functions (inline implementation)
function setCookie(name, value, days = 30) {
const expires = new Date();
expires.setTime(expires.getTime() + (days * 24 * 60 * 60 * 1000));
document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/;SameSite=Lax`;
}
function getCookie(name) {
const nameEQ = name + "=";
const ca = document.cookie.split(';');
for (let i = 0; i < ca.length; i++) {
let c = ca[i];
while (c.charAt(0) === ' ') c = c.substring(1, c.length);
if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length);
}
return null;
}
function loadLastSelectedLocation() {
try {
const cookieValue = getCookie('ninjacross_last_location');
if (cookieValue) {
const lastLocation = JSON.parse(cookieValue);
lastSelectedLocation = lastLocation;
console.log('📍 Last selected location loaded:', lastLocation.name);
return lastLocation;
}
} catch (error) {
console.error('Error loading last location:', error);
}
return null;
}
function saveLocationSelection(locationId, locationName) {
try {
// Remove emoji from location name for storage
const cleanName = locationName.replace(/^📍\s*/, '');
const locationData = {
id: locationId,
name: cleanName,
timestamp: new Date().toISOString()
};
setCookie('ninjacross_last_location', JSON.stringify(locationData), 90);
lastSelectedLocation = { id: locationId, name: cleanName };
console.log('💾 Location saved to cookie:', cleanName);
} catch (error) {
console.error('Error saving location:', error);
}
}
// WebSocket Event Handlers are now in setupSocketListeners() function
// Notification Functions
function showNotification(timeData) {
const notificationBubble = document.getElementById('notificationBubble');
const notificationTitle = document.getElementById('notificationTitle');
const notificationSubtitle = document.getElementById('notificationSubtitle');
// Format the time data
const playerName = timeData.player_name || (currentLanguage === 'de' ? 'Unbekannter Spieler' : 'Unknown Player');
const locationName = timeData.location_name || (currentLanguage === 'de' ? 'Unbekannter Standort' : 'Unknown Location');
const timeString = timeData.recorded_time || '--:--';
// Update notification content
const newTimeText = currentLanguage === 'de' ? 'Neue Zeit von' : 'New time from';
notificationTitle.textContent = `🏁 ${newTimeText} ${playerName}!`;
notificationSubtitle.textContent = `${timeString}${locationName}`;
// Ensure notification is above sticky header
notificationBubble.style.zIndex = '100000';
// Check if we're on mobile and adjust position
if (window.innerWidth <= 768) {
notificationBubble.style.top = '5rem'; // Below sticky header on mobile
} else {
notificationBubble.style.top = '2rem'; // Normal position on desktop
}
// Show notification
notificationBubble.classList.remove('hide');
notificationBubble.classList.add('show');
// Auto-hide after 5 seconds
setTimeout(() => {
hideNotification();
}, 5000);
}
function hideNotification() {
const notificationBubble = document.getElementById('notificationBubble');
notificationBubble.classList.remove('show');
notificationBubble.classList.add('hide');
// Remove hide class after animation
setTimeout(() => {
notificationBubble.classList.remove('hide');
}, 300);
}
// Check authentication status
async function checkAuth() {
try {
const { data: { session } } = await supabase.auth.getSession();
if (session) {
// User is logged in, show dashboard button
document.getElementById('adminLoginBtn').style.display = 'none';
document.getElementById('dashboardBtn').style.display = 'inline-block';
document.getElementById('logoutBtn').style.display = 'inline-block';
} else {
// User is not logged in, show admin login button
document.getElementById('adminLoginBtn').style.display = 'inline-block';
document.getElementById('dashboardBtn').style.display = 'none';
document.getElementById('logoutBtn').style.display = 'none';
}
} catch (error) {
console.error('Error checking auth:', error);
// Fallback: show login button if auth check fails
document.getElementById('adminLoginBtn').style.display = 'inline-block';
document.getElementById('dashboardBtn').style.display = 'none';
document.getElementById('logoutBtn').style.display = 'none';
}
}
// Logout function
async function logout() {
try {
const { error } = await supabase.auth.signOut();
if (error) {
console.error('Error logging out:', error);
} else {
window.location.reload();
}
} catch (error) {
console.error('Error during logout:', error);
window.location.reload();
}
}
// Load locations from database
async function loadLocations() {
try {
const response = await fetch('/api/v1/public/locations');
if (!response.ok) {
throw new Error('Failed to fetch locations');
}
const responseData = await response.json();
const locations = responseData.data || responseData; // Handle both formats
const locationSelect = document.getElementById('locationSelect');
// Store locations globally for distance calculations
locationsData = locations;
// Clear existing options and set default placeholder
const placeholderText = currentLanguage === 'de' ? '📍 Bitte Standort auswählen' : '📍 Please select location';
locationSelect.innerHTML = `<option value="">${placeholderText}</option>`;
// Add locations from database
locations.forEach(location => {
const option = document.createElement('option');
option.value = location.name;
option.textContent = `📍 ${location.name}`;
locationSelect.appendChild(option);
});
// Load and set last selected location
const lastLocation = loadLastSelectedLocation();
if (lastLocation) {
// Find the option that matches the last location name
const matchingOption = Array.from(locationSelect.options).find(option =>
option.textContent === `📍 ${lastLocation.name}` || option.value === lastLocation.name
);
if (matchingOption) {
locationSelect.value = matchingOption.value;
console.log('📍 Last selected location restored:', lastLocation.name);
// Update the current selection display
updateCurrentSelection();
// Load data for the restored location
loadData();
}
}
} catch (error) {
console.error('Error loading locations:', error);
}
}
// Calculate distance between two points using Haversine formula
function calculateDistance(lat1, lon1, lat2, lon2) {
const R = 6371; // Earth's radius in kilometers
const dLat = toRadians(lat2 - lat1);
const dLon = toRadians(lon2 - lon1);
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(toRadians(lat1)) * Math.cos(toRadians(lat2)) *
Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
const distance = R * c; // Distance in kilometers
return distance;
}
function toRadians(degrees) {
return degrees * (Math.PI / 180);
}
// Find nearest location based on user's current position
async function findNearestLocation() {
const btn = document.getElementById('findLocationBtn');
const locationSelect = document.getElementById('locationSelect');
// Check if geolocation is supported
if (!navigator.geolocation) {
const errorMsg = currentLanguage === 'de' ?
'Geolocation wird von diesem Browser nicht unterstützt.' :
'Geolocation is not supported by this browser.';
showLocationError(errorMsg);
return;
}
// Update button state to loading
btn.disabled = true;
btn.classList.add('loading');
btn.textContent = currentLanguage === 'de' ? '🔍 Suche...' : '🔍 Searching...';
try {
// Get user's current position
const position = await new Promise((resolve, reject) => {
navigator.geolocation.getCurrentPosition(
resolve,
reject,
{
enableHighAccuracy: true,
timeout: 10000,
maximumAge: 300000 // 5 minutes
}
);
});
const userLat = position.coords.latitude;
const userLon = position.coords.longitude;
// Calculate distances to all locations
const locationsWithDistance = locationsData.map(location => ({
...location,
distance: calculateDistance(
userLat,
userLon,
parseFloat(location.latitude),
parseFloat(location.longitude)
)
}));
// Find the nearest location
const nearestLocation = locationsWithDistance.reduce((nearest, current) => {
return current.distance < nearest.distance ? current : nearest;
});
// Select the nearest location in the dropdown
locationSelect.value = nearestLocation.name;
// Trigger change event to update the leaderboard
locationSelect.dispatchEvent(new Event('change'));
// Show success notification
showLocationSuccess(nearestLocation.name, nearestLocation.distance);
} catch (error) {
console.error('Error getting location:', error);
let errorMessage = currentLanguage === 'de' ? 'Standort konnte nicht ermittelt werden.' : 'Location could not be determined.';
if (error.code) {
switch (error.code) {
case error.PERMISSION_DENIED:
errorMessage = currentLanguage === 'de' ?
'Standortzugriff wurde verweigert. Bitte erlaube den Standortzugriff in den Browser-Einstellungen.' :
'Location access was denied. Please allow location access in browser settings.';
break;
case error.POSITION_UNAVAILABLE:
errorMessage = currentLanguage === 'de' ?
'Standortinformationen sind nicht verfügbar.' :
'Location information is not available.';
break;
case error.TIMEOUT:
errorMessage = currentLanguage === 'de' ?
'Zeitüberschreitung beim Abrufen des Standorts.' :
'Timeout while retrieving location.';
break;
}
}
showLocationError(errorMessage);
} finally {
// Reset button state
btn.disabled = false;
btn.classList.remove('loading');
btn.textContent = currentLanguage === 'de' ? '📍 Mein Standort' : '📍 My Location';
}
}
// Show success notification for location finding
function showLocationSuccess(locationName, distance) {
const notificationBubble = document.getElementById('notificationBubble');
const notificationTitle = document.getElementById('notificationTitle');
const notificationSubtitle = document.getElementById('notificationSubtitle');
// Update notification content
const locationFoundText = currentLanguage === 'de' ? 'Standort gefunden!' : 'Location found!';
const distanceText = currentLanguage === 'de' ? 'km entfernt' : 'km away';
notificationTitle.textContent = `📍 ${locationFoundText}`;
notificationSubtitle.textContent = `${locationName} (${distance.toFixed(1)} ${distanceText})`;
// Ensure notification is above sticky header
notificationBubble.style.zIndex = '100000';
// Check if we're on mobile and adjust position
if (window.innerWidth <= 768) {
notificationBubble.style.top = '5rem'; // Below sticky header on mobile
} else {
notificationBubble.style.top = '2rem'; // Normal position on desktop
}
// Show notification
notificationBubble.classList.remove('hide');
notificationBubble.classList.add('show');
// Auto-hide after 4 seconds
setTimeout(() => {
hideNotification();
}, 4000);
}
// Show error notification for location finding
function showLocationError(message) {
const notificationBubble = document.getElementById('notificationBubble');
const notificationTitle = document.getElementById('notificationTitle');
const notificationSubtitle = document.getElementById('notificationSubtitle');
// Change notification style to error
notificationBubble.style.background = 'linear-gradient(135deg, #dc3545, #c82333)';
// Update notification content
const errorText = currentLanguage === 'de' ? 'Fehler' : 'Error';
notificationTitle.textContent = `${errorText}`;
notificationSubtitle.textContent = message;
// Ensure notification is above sticky header
notificationBubble.style.zIndex = '100000';
// Check if we're on mobile and adjust position
if (window.innerWidth <= 768) {
notificationBubble.style.top = '5rem'; // Below sticky header on mobile
} else {
notificationBubble.style.top = '2rem'; // Normal position on desktop
}
// Show notification
notificationBubble.classList.remove('hide');
notificationBubble.classList.add('show');
// Auto-hide after 6 seconds
setTimeout(() => {
hideNotification();
// Reset notification style
notificationBubble.style.background = 'linear-gradient(135deg, #00d4ff, #0891b2)';
}, 6000);
}
// Show prompt when no location is selected
function showLocationSelectionPrompt() {
const rankingList = document.getElementById('rankingList');
const emptyTitle = currentLanguage === 'de' ? 'Standort auswählen' : 'Select Location';
const emptyDescription = currentLanguage === 'de' ?
'Bitte wähle einen Standort aus dem Dropdown-Menü aus<br>oder nutze den "📍 Mein Standort" Button, um automatisch<br>den nächstgelegenen Standort zu finden.' :
'Please select a location from the dropdown menu<br>or use the "📍 My Location" button to automatically<br>find the nearest location.';
rankingList.innerHTML = `
<div class="empty-state">
<div class="empty-icon">📍</div>
<div class="empty-title">${emptyTitle}</div>
<div class="empty-description">${emptyDescription}</div>
</div>
`;
// Reset stats to show no data
document.getElementById('totalPlayers').textContent = '0';
document.getElementById('bestTime').textContent = '--:--';
document.getElementById('totalRecords').textContent = '0';
// Update current selection display
updateCurrentSelection();
}
// Load data from local database via MCP
async function loadData() {
try {
const location = document.getElementById('locationSelect').value;
const period = document.querySelector('.time-tab.active').dataset.period;
// Don't load data if no location is selected
if (!location || location === '') {
showLocationSelectionPrompt();
return;
}
// Build query parameters
const params = new URLSearchParams();
if (location && location !== 'all') {
params.append('location', location);
}
if (period && period !== 'all') {
params.append('period', period);
}
// Fetch times with player and location data from local database
const response = await fetch(`/api/v1/public/times-with-details?${params.toString()}`);
if (!response.ok) {
throw new Error('Failed to fetch times');
}
const times = await response.json();
// Convert to the format expected by the leaderboard
const leaderboardData = times.map(time => {
const { minutes, seconds, milliseconds } = time.recorded_time;
const timeString = `${minutes}:${seconds.toString().padStart(2, '0')}.${milliseconds}`;
const playerName = time.player ?
`${time.player.firstname} ${time.player.lastname}` :
(currentLanguage === 'de' ? 'Unbekannter Spieler' : 'Unknown Player');
const locationName = time.location ? time.location.name :
(currentLanguage === 'de' ? 'Unbekannter Standort' : 'Unknown Location');
const date = new Date(time.created_at).toISOString().split('T')[0];
return {
name: playerName,
time: timeString,
date: date,
location: locationName
};
});
// Sort by time (fastest first)
leaderboardData.sort((a, b) => {
const timeA = timeToSeconds(a.time);
const timeB = timeToSeconds(b.time);
return timeA - timeB;
});
updateLeaderboard(leaderboardData);
updateStats(leaderboardData);
updateCurrentSelection();
} catch (error) {
console.error('Error loading data:', error);
// Fallback to sample data if API fails
loadSampleData();
}
}
// Fallback sample data based on real database data
function loadSampleData() {
const sampleData = [
{ name: "Carsten Graf", time: "01:28.945", date: "2025-08-30", location: "Ulm Donaubad" },
{ name: "Carsten Graf", time: "01:30.945", date: "2025-08-30", location: "Ulm Donaubad" },
{ name: "Max Mustermann", time: "01:50.945", date: "2025-08-30", location: "Ulm Donaubad" },
{ name: "Carsten Graf", time: "02:50.945", date: "2025-08-31", location: "Test" },
{ name: "Max Mustermann", time: "02:50.945", date: "2025-08-31", location: "Test" },
{ name: "Carsten Graf", time: "01:10.945", date: "2025-09-02", location: "Test" },
{ name: "Carsten Graf", time: "01:11.945", date: "2025-09-02", location: "Test" },
{ name: "Carsten Graf", time: "01:11.945", date: "2025-09-02", location: "Ulm Donaubad" }
];
updateLeaderboard(sampleData);
updateStats(sampleData);
updateCurrentSelection();
}
function timeToSeconds(timeStr) {
const [minutes, seconds] = timeStr.split(':');
return parseFloat(minutes) * 60 + parseFloat(seconds);
}
function updateStats(data) {
const totalPlayers = new Set(data.map(item => item.name)).size;
const bestTime = data.length > 0 ? data[0].time : '--:--';
const totalRecords = data.length;
document.getElementById('totalPlayers').textContent = totalPlayers;
document.getElementById('bestTime').textContent = bestTime;
document.getElementById('totalRecords').textContent = totalRecords;
}
function updateCurrentSelection() {
const location = document.getElementById('locationSelect').value;
const period = document.querySelector('.time-tab.active').dataset.period;
// Get the display text from the selected option
const locationSelect = document.getElementById('locationSelect');
const selectedLocationOption = locationSelect.options[locationSelect.selectedIndex];
const locationDisplay = selectedLocationOption ? selectedLocationOption.textContent :
(currentLanguage === 'de' ? '📍 Bitte Standort auswählen' : '📍 Please select location');
const periodIcons = currentLanguage === 'de' ? {
'today': '📅 Heute',
'week': '📊 Diese Woche',
'month': '📈 Dieser Monat',
'all': '♾️ Alle Zeiten'
} : {
'today': '📅 Today',
'week': '📊 This Week',
'month': '📈 This Month',
'all': '♾️ All Times'
};
document.getElementById('currentSelection').textContent =
`${locationDisplay}${periodIcons[period]}`;
const lastSyncText = currentLanguage === 'de' ? 'Letzter Sync' : 'Last Sync';
document.getElementById('lastUpdated').textContent =
`${lastSyncText}: ${new Date().toLocaleTimeString(currentLanguage === 'de' ? 'de-DE' : 'en-US')}`;
}
function updateLeaderboard(data) {
const rankingList = document.getElementById('rankingList');
if (data.length === 0) {
const emptyTitle = currentLanguage === 'de' ? 'Keine Rekorde gefunden' : 'No records found';
const emptyDescription = currentLanguage === 'de' ?
'Für diese Filtereinstellungen liegen noch keine Zeiten vor.<br>Versuche es mit einem anderen Zeitraum oder Standort.' :
'No times available for these filter settings.<br>Try a different time period or location.';
rankingList.innerHTML = `
<div class="empty-state">
<div class="empty-icon">🏁</div>
<div class="empty-title">${emptyTitle}</div>
<div class="empty-description">${emptyDescription}</div>
</div>
`;
return;
}
rankingList.innerHTML = data.map((player, index) => {
const rank = index + 1;
let positionClass = '';
let trophy = '';
if (rank === 1) {
positionClass = 'gold';
trophy = '👑';
} else if (rank === 2) {
positionClass = 'silver';
trophy = '🥈';
} else if (rank === 3) {
positionClass = 'bronze';
trophy = '🥉';
} else if (rank <= 10) {
trophy = '⭐';
}
const formatDate = new Date(player.date).toLocaleDateString(currentLanguage === 'de' ? 'de-DE' : 'en-US', {
day: '2-digit',
month: 'short'
});
return `
<div class="rank-entry">
<div class="position ${positionClass}">#${rank}</div>
<div class="player-data">
<div class="player-name">${player.name}</div>
<div class="player-meta">
<span class="location-tag">${player.location}</span>
<span>🗓️ ${formatDate}</span>
</div>
</div>
<div class="time-result">${player.time}</div>
${trophy ? `<div class="trophy-icon">${trophy}</div>` : '<div class="trophy-icon"></div>'}
</div>
`;
}).join('');
}
// Event Listeners Setup
function setupEventListeners() {
// Location select event listener
document.getElementById('locationSelect').addEventListener('change', function () {
// Save location selection to cookie
const selectedOption = this.options[this.selectedIndex];
if (selectedOption.value) {
saveLocationSelection(selectedOption.value, selectedOption.textContent);
}
// Load data
loadData();
});
// Time tab event listeners
document.querySelectorAll('.time-tab').forEach(tab => {
tab.addEventListener('click', function () {
// Remove active class from all tabs
document.querySelectorAll('.time-tab').forEach(t => t.classList.remove('active'));
// Add active class to clicked tab
this.classList.add('active');
// Load data with new period
loadData();
});
});
// Smooth scroll for better UX
const rankingsList = document.querySelector('.rankings-list');
if (rankingsList) {
rankingsList.style.scrollBehavior = 'smooth';
}
}
// Initialize page
async function init() {
await checkAuth();
await loadLocations();
showLocationSelectionPrompt(); // Show prompt instead of loading data initially
setupEventListeners();
}
// Auto-refresh function
function startAutoRefresh() {
setInterval(loadData, 45000);
}
// Language Management
let currentLanguage = 'en'; // Default to English
// Translation function
function translateElement(element, language) {
if (element.dataset[language]) {
// Check if the content contains HTML tags
if (element.dataset[language].includes('<')) {
element.innerHTML = element.dataset[language];
} else {
element.textContent = element.dataset[language];
}
}
}
// Change language function
function changeLanguage() {
const languageSelect = document.getElementById('languageSelect');
currentLanguage = languageSelect.value;
// Save language preference
localStorage.setItem('ninjacross_language', currentLanguage);
// Update flag in select
updateLanguageFlag();
// Translate all elements with data attributes
const elementsToTranslate = document.querySelectorAll('[data-de][data-en]');
elementsToTranslate.forEach(element => {
translateElement(element, currentLanguage);
});
// Update dynamic content
updateDynamicContent();
console.log(`🌐 Language changed to: ${currentLanguage}`);
}
// Update flag in language selector
function updateLanguageFlag() {
const languageSelect = document.getElementById('languageSelect');
if (languageSelect) {
if (currentLanguage === 'de') {
// German flag (black-red-gold)
languageSelect.style.backgroundImage = `url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="20" height="15" viewBox="0 0 20 15"><rect width="20" height="5" fill="%23000000"/><rect y="5" width="20" height="5" fill="%23DD0000"/><rect y="10" width="20" height="5" fill="%23FFCE00"/></svg>')`;
} else {
// USA flag
languageSelect.style.backgroundImage = `url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="20" height="15" viewBox="0 0 20 15"><rect width="20" height="15" fill="%23B22234"/><rect width="20" height="1.15" fill="%23FFFFFF"/><rect y="2.3" width="20" height="1.15" fill="%23FFFFFF"/><rect y="4.6" width="20" height="1.15" fill="%23FFFFFF"/><rect y="6.9" width="20" height="1.15" fill="%23FFFFFF"/><rect y="9.2" width="20" height="1.15" fill="%23FFFFFF"/><rect y="11.5" width="20" height="1.15" fill="%23FFFFFF"/><rect y="13.8" width="20" height="1.15" fill="%23FFFFFF"/><rect width="7.7" height="8.05" fill="%230033A0"/></svg>')`;
}
}
}
// Update dynamic content that's not in HTML
function updateDynamicContent() {
// Update location select placeholder
const locationSelect = document.getElementById('locationSelect');
if (locationSelect && locationSelect.options[0]) {
locationSelect.options[0].textContent = currentLanguage === 'de' ?
'📍 Bitte Standort auswählen' : '📍 Please select location';
}
// Update find location button
const findLocationBtn = document.getElementById('findLocationBtn');
if (findLocationBtn) {
findLocationBtn.textContent = currentLanguage === 'de' ?
'📍 Mein Standort' : '📍 My Location';
findLocationBtn.title = currentLanguage === 'de' ?
'Nächstgelegenen Standort finden' : 'Find nearest location';
}
// Update refresh button
const refreshBtn = document.querySelector('.refresh-btn');
if (refreshBtn) {
refreshBtn.textContent = currentLanguage === 'de' ?
'⚡ Live Update' : '⚡ Live Update';
}
// Update notification elements
const notificationTitle = document.getElementById('notificationTitle');
const notificationSubtitle = document.getElementById('notificationSubtitle');
if (notificationTitle) {
notificationTitle.textContent = currentLanguage === 'de' ? 'Neue Zeit!' : 'New Time!';
}
if (notificationSubtitle) {
notificationSubtitle.textContent = currentLanguage === 'de' ?
'Ein neuer Rekord wurde erstellt' : 'A new record has been created';
}
// Update current selection display
updateCurrentSelection();
// Reload data to update any dynamic content
if (document.getElementById('locationSelect').value) {
loadData();
} else {
showLocationSelectionPrompt();
}
}
// Load saved language preference
function loadLanguagePreference() {
const savedLanguage = localStorage.getItem('ninjacross_language');
if (savedLanguage && (savedLanguage === 'de' || savedLanguage === 'en')) {
currentLanguage = savedLanguage;
const languageSelect = document.getElementById('languageSelect');
if (languageSelect) {
languageSelect.value = currentLanguage;
// Update flag when loading
updateLanguageFlag();
}
}
}
// Start the application when DOM is loaded
document.addEventListener('DOMContentLoaded', function () {
loadLanguagePreference();
changeLanguage(); // Apply saved language
init();
startAutoRefresh();
// Add cookie settings button functionality
const cookieSettingsBtn = document.getElementById('cookie-settings-footer');
if (cookieSettingsBtn) {
cookieSettingsBtn.addEventListener('click', function () {
if (window.cookieConsent) {
window.cookieConsent.resetConsent();
}
});
}
});

291
public/js/login.js Normal file
View File

@@ -0,0 +1,291 @@
// Supabase configuration
const SUPABASE_URL = 'https://lfxlplnypzvjrhftaoog.supabase.co';
const SUPABASE_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImxmeGxwbG55cHp2anJoZnRhb29nIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDkyMTQ3NzIsImV4cCI6MjA2NDc5MDc3Mn0.XR4preBqWAQ1rT4PFbpkmRdz57BTwIusBI89fIxDHM8';
// Initialize Supabase client
const supabase = window.supabase.createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
// Check if user is already logged in
async function checkAuth() {
const { data: { session } } = await supabase.auth.getSession();
if (session) {
// Show a message that user is already logged in
showMessage('Sie sind bereits eingeloggt! Weiterleitung zum Dashboard...', 'success');
setTimeout(() => {
window.location.href = '/';
}, 2000);
}
}
// Check if device is iOS
function isIOS() {
return /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
}
// Google OAuth Sign In
async function signInWithGoogle() {
try {
setLoading(true);
clearMessage();
// iOS-specific handling
if (isIOS()) {
// For iOS, use a different approach with popup
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: `${window.location.origin}/auth/callback`,
queryParams: {
access_type: 'offline',
prompt: 'consent',
},
skipBrowserRedirect: true // Important for iOS
}
});
if (error) {
console.error('Google OAuth error:', error);
showMessage('Fehler bei der Google-Anmeldung: ' + error.message, 'error');
setLoading(false);
return;
}
if (data.url) {
// Open in same window for iOS
window.location.href = data.url;
}
} else {
// Standard handling for other devices
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: `${window.location.origin}/auth/callback`
}
});
if (error) {
console.error('Google OAuth error:', error);
showMessage('Fehler bei der Google-Anmeldung: ' + error.message, 'error');
}
}
} catch (error) {
console.error('Google OAuth error:', error);
showMessage('Fehler bei der Google-Anmeldung: ' + error.message, 'error');
setLoading(false);
}
}
// Discord OAuth Sign In
async function signInWithDiscord() {
try {
setLoading(true);
clearMessage();
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'discord',
options: {
redirectTo: `${window.location.origin}/auth/callback`
}
});
if (error) {
console.error('Discord OAuth error:', error);
showMessage('Fehler bei der Discord-Anmeldung: ' + error.message, 'error');
}
// Note: OAuth redirects the page, so we don't need to handle success here
} catch (error) {
console.error('Discord OAuth error:', error);
showMessage('Fehler bei der Discord-Anmeldung: ' + error.message, 'error');
} finally {
setLoading(false);
}
}
// Toggle between login and register forms
function toggleForm() {
const loginForm = document.getElementById('loginForm');
const registerForm = document.getElementById('registerForm');
if (loginForm.classList.contains('active')) {
loginForm.classList.remove('active');
registerForm.classList.add('active');
} else {
registerForm.classList.remove('active');
loginForm.classList.add('active');
}
clearMessage();
showPasswordReset(false); // Hide password reset when switching forms
}
// Show message
function showMessage(message, type = 'success') {
const messageDiv = document.getElementById('message');
messageDiv.innerHTML = `<div class="message ${type}">${message}</div>`;
setTimeout(() => {
messageDiv.innerHTML = '';
}, 5000);
}
// Clear message
function clearMessage() {
document.getElementById('message').innerHTML = '';
}
// Show/hide password reset container
function showPasswordReset(show) {
const resetContainer = document.getElementById('passwordResetContainer');
if (show) {
resetContainer.classList.add('active');
} else {
resetContainer.classList.remove('active');
}
}
// Show/hide loading
function setLoading(show) {
const loading = document.getElementById('loading');
if (show) {
loading.classList.add('active');
} else {
loading.classList.remove('active');
}
}
// Event Listeners Setup
function setupEventListeners() {
// Handle Google OAuth
document.getElementById('googleSignInBtn').addEventListener('click', signInWithGoogle);
// Handle Discord OAuth
document.getElementById('discordSignInBtn').addEventListener('click', signInWithDiscord);
// Cookie settings button
const cookieSettingsBtn = document.getElementById('cookie-settings-footer');
if (cookieSettingsBtn) {
cookieSettingsBtn.addEventListener('click', function() {
if (window.cookieConsent) {
window.cookieConsent.resetConsent();
}
});
}
// Handle login
document.getElementById('loginFormElement').addEventListener('submit', async (e) => {
e.preventDefault();
setLoading(true);
clearMessage();
showPasswordReset(false); // Hide reset button initially
const email = document.getElementById('loginEmail').value;
const password = document.getElementById('loginPassword').value;
try {
const { data, error } = await supabase.auth.signInWithPassword({
email: email,
password: password
});
if (error) {
showMessage(error.message, 'error');
// Show password reset button on login failure
showPasswordReset(true);
} else {
showMessage('Login successful! Redirecting...', 'success');
setTimeout(() => {
window.location.href = '/';
}, 1000);
}
} catch (error) {
showMessage('An unexpected error occurred', 'error');
showPasswordReset(true);
} finally {
setLoading(false);
}
});
// Handle registration
document.getElementById('registerFormElement').addEventListener('submit', async (e) => {
e.preventDefault();
setLoading(true);
clearMessage();
const email = document.getElementById('registerEmail').value;
const password = document.getElementById('registerPassword').value;
const confirmPassword = document.getElementById('confirmPassword').value;
if (password !== confirmPassword) {
showMessage('Passwords do not match', 'error');
setLoading(false);
return;
}
if (password.length < 6) {
showMessage('Password must be at least 6 characters', 'error');
setLoading(false);
return;
}
try {
const { data, error } = await supabase.auth.signUp({
email: email,
password: password
});
if (error) {
showMessage(error.message, 'error');
} else {
if (data.user && !data.user.email_confirmed_at) {
showMessage('Registration successful! Please check your email to confirm your account.', 'success');
} else {
showMessage('Registration successful! Redirecting...', 'success');
setTimeout(() => {
window.location.href = '/';
}, 1000);
}
}
} catch (error) {
showMessage('An unexpected error occurred', 'error');
} finally {
setLoading(false);
}
});
// Handle password reset
document.getElementById('resetPasswordBtn').addEventListener('click', async () => {
const email = document.getElementById('loginEmail').value;
if (!email) {
showMessage('Bitte geben Sie zuerst Ihre E-Mail-Adresse ein', 'error');
return;
}
setLoading(true);
clearMessage();
try {
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${window.location.origin}/reset-password.html`
});
if (error) {
showMessage('Fehler beim Senden der E-Mail: ' + error.message, 'error');
} else {
showMessage('Passwort-Reset-E-Mail wurde gesendet! Bitte überprüfen Sie Ihr E-Mail-Postfach.', 'success');
showPasswordReset(false);
}
} catch (error) {
showMessage('Ein unerwarteter Fehler ist aufgetreten', 'error');
} finally {
setLoading(false);
}
});
}
// Initialize page
async function init() {
await checkAuth();
setupEventListeners();
}
// Start the application when DOM is loaded
document.addEventListener('DOMContentLoaded', init);

View File

@@ -0,0 +1,33 @@
// Page tracking functionality
function trackPageView(pageName) {
// Get user information
const userAgent = navigator.userAgent;
const referer = document.referrer || '';
// Send tracking data to server
fetch('/api/v1/public/track-page-view', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
page: pageName,
userAgent: userAgent,
ipAddress: null, // Will be determined by server
referer: referer
})
}).catch(error => {
console.log('Page tracking failed:', error);
// Silently fail - don't interrupt user experience
});
}
// Auto-track page on load - only track main page visits
document.addEventListener('DOMContentLoaded', function() {
// Only track the main page (index.html or root path)
const path = window.location.pathname;
if (path === '/' || path === '/index.html') {
trackPageView('main_page_visit');
}
});

202
public/js/reset-password.js Normal file
View File

@@ -0,0 +1,202 @@
// Supabase Konfiguration
const supabaseUrl = 'https://lfxlplnypzvjrhftaoog.supabase.co';
const supabaseKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImxmeGxwbG55cHp2anJoZnRhb29nIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDkyMTQ3NzIsImV4cCI6MjA2NDc5MDc3Mn0.XR4preBqWAQ1rT4PFbpkmRdz57BTwIusBI89fIxDHM8';
const supabase = window.supabase.createClient(supabaseUrl, supabaseKey);
// DOM Elemente
const resetForm = document.getElementById('resetForm');
const newPasswordInput = document.getElementById('newPassword');
const confirmPasswordInput = document.getElementById('confirmPassword');
const resetBtn = document.getElementById('resetBtn');
const loading = document.getElementById('loading');
const messageContainer = document.getElementById('messageContainer');
// Nachricht anzeigen (muss vor anderen Funktionen definiert werden)
function showMessage(type, message) {
messageContainer.innerHTML = `
<div class="message ${type}">
${message}
</div>
`;
}
// URL-Parameter extrahieren
console.log('URL Hash:', window.location.hash);
const urlParams = new URLSearchParams(window.location.hash.substring(1));
const accessToken = urlParams.get('access_token');
const refreshToken = urlParams.get('refresh_token');
const tokenType = urlParams.get('token_type');
console.log('Access Token gefunden:', !!accessToken);
console.log('Refresh Token gefunden:', !!refreshToken);
// Prüfen ob Reset-Token vorhanden ist
if (!accessToken) {
showMessage('error', 'Ungültiger oder fehlender Reset-Link. Bitte fordere einen neuen Reset-Link an.');
resetForm.style.display = 'none';
}
// Session mit Token setzen
async function setSession() {
if (!accessToken || !refreshToken) {
showMessage('error', 'Ungültiger Reset-Link. Tokens fehlen.');
resetForm.style.display = 'none';
return false;
}
try {
console.log('Setze Session mit Tokens...');
const { data, error } = await supabase.auth.setSession({
access_token: accessToken,
refresh_token: refreshToken
});
if (error) {
console.error('Session Error:', error);
throw error;
}
console.log('Session erfolgreich gesetzt:', data.user?.email);
showMessage('success', `Session aktiv für: ${data.user?.email}`);
return true;
} catch (error) {
console.error('Fehler beim Setzen der Session:', error);
showMessage('error', `Fehler beim Laden des Reset-Links: ${error.message}`);
resetForm.style.display = 'none';
return false;
}
}
// Passwort zurücksetzen
async function resetPassword(newPassword) {
try {
console.log('Starte Passwort-Update...');
// Erstmal Session prüfen
const { data: session } = await supabase.auth.getSession();
console.log('Aktuelle Session:', session);
if (!session.session) {
throw new Error('Keine aktive Session gefunden');
}
const { data, error } = await supabase.auth.updateUser({
password: newPassword
});
if (error) {
console.error('Update User Error:', error);
throw error;
}
console.log('Passwort erfolgreich aktualisiert:', data);
return { success: true, data };
} catch (error) {
console.error('Fehler beim Zurücksetzen des Passworts:', error);
return { success: false, error: error.message };
}
}
// Formular-Validierung
function validateForm() {
const newPassword = newPasswordInput.value;
const confirmPassword = confirmPasswordInput.value;
if (newPassword.length < 8) {
showMessage('error', 'Das Passwort muss mindestens 8 Zeichen lang sein.');
return false;
}
if (newPassword !== confirmPassword) {
showMessage('error', 'Die Passwörter stimmen nicht überein.');
return false;
}
return true;
}
// Formular-Submit Handler
resetForm.addEventListener('submit', async (e) => {
e.preventDefault();
if (!validateForm()) {
return;
}
// UI-Status ändern
resetBtn.disabled = true;
loading.style.display = 'block';
resetForm.style.display = 'none';
try {
// Warten bis Session gesetzt ist
const sessionSet = await setSession();
if (!sessionSet) {
throw new Error('Session konnte nicht gesetzt werden');
}
const result = await resetPassword(newPasswordInput.value);
if (result.success) {
showMessage('success', '✅ Passwort erfolgreich zurückgesetzt! Du wirst zur Hauptseite weitergeleitet...');
// Nach 3 Sekunden zur Hauptseite weiterleiten
setTimeout(() => {
window.location.href = '/';
}, 3000);
} else {
showMessage('error', `❌ Fehler beim Zurücksetzen: ${result.error}`);
resetForm.style.display = 'block';
}
} catch (error) {
console.error('Submit Error:', error);
showMessage('error', `❌ Fehler: ${error.message}`);
resetForm.style.display = 'block';
} finally {
resetBtn.disabled = false;
loading.style.display = 'none';
}
});
// Session beim Laden der Seite setzen (nur wenn Token vorhanden)
if (accessToken && refreshToken) {
setSession();
}
// Add cookie settings button functionality
document.addEventListener('DOMContentLoaded', function() {
const cookieSettingsBtn = document.getElementById('cookie-settings-footer');
if (cookieSettingsBtn) {
cookieSettingsBtn.addEventListener('click', function() {
if (window.cookieConsent) {
window.cookieConsent.resetConsent();
}
});
}
});
// Passwort-Sicherheitshinweise
newPasswordInput.addEventListener('input', function() {
const password = this.value;
const hasLength = password.length >= 8;
const hasUpper = /[A-Z]/.test(password);
const hasLower = /[a-z]/.test(password);
const hasNumber = /\d/.test(password);
const hasSpecial = /[!@#$%^&*(),.?":{}|<>]/.test(password);
if (password.length > 0) {
let hints = [];
if (!hasLength) hints.push('Mindestens 8 Zeichen');
if (!hasUpper) hints.push('Großbuchstaben');
if (!hasLower) hints.push('Kleinbuchstaben');
if (!hasNumber) hints.push('Zahlen');
if (!hasSpecial) hints.push('Sonderzeichen');
if (hints.length > 0) {
showMessage('info', `💡 Tipp: Verwende auch ${hints.join(', ')} für ein sicheres Passwort.`);
} else {
showMessage('success', '✅ Starkes Passwort!');
}
}
});

121
public/login.html Normal file
View File

@@ -0,0 +1,121 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ninja Server - Admin Login</title>
<link rel="icon" type="image/x-icon" href="/pictures/favicon.ico">
<script src="https://unpkg.com/@supabase/supabase-js@2"></script>
<link rel="stylesheet" href="/css/login.css">
</head>
<body>
<!-- Back to Home Button -->
<a href="/" class="back-button">Zur Hauptseite</a>
<div class="main-content">
<div class="container">
<div class="logo">
<h1>🥷 NINJACROSS</h1>
<p>Dein Dashboard</p>
</div>
<div id="message"></div>
<div id="loading" class="loading">
<div class="spinner"></div>
<p>Processing...</p>
</div>
<!-- Login Form -->
<div id="loginForm" class="form-container active">
<h2 style="text-align: center; margin-bottom: 1.5rem; color: #e2e8f0; font-weight: 600;">Welcome Back</h2>
<!-- OAuth Buttons -->
<div class="oauth-container">
<button type="button" id="googleSignInBtn" class="btn btn-google">
<svg width="20" height="20" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
Continue with Google
</button>
<button type="button" id="discordSignInBtn" class="btn btn-discord">
<svg width="20" height="20" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path fill="#5865F2" d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/>
</svg>
Continue with Discord
</button>
</div>
<div class="divider">
<span>or</span>
</div>
<form id="loginFormElement">
<div class="form-group">
<label for="loginEmail">Email</label>
<input type="email" id="loginEmail" required>
</div>
<div class="form-group">
<label for="loginPassword">Password</label>
<input type="password" id="loginPassword" required>
</div>
<button type="submit" class="btn btn-primary">Sign In</button>
</form>
<!-- Password Reset Container -->
<div id="passwordResetContainer" class="password-reset-container">
<p>Passwort vergessen? Kein Problem!</p>
<button type="button" id="resetPasswordBtn" class="btn btn-reset">Passwort zurücksetzen</button>
</div>
<div class="toggle-form">
<p>Don't have an account? <button type="button" onclick="toggleForm()">Sign Up</button></p>
</div>
</div>
<!-- Register Form -->
<div id="registerForm" class="form-container">
<h2 style="text-align: center; margin-bottom: 1.5rem; color: #e2e8f0; font-weight: 600;">Create Account</h2>
<form id="registerFormElement">
<div class="form-group">
<label for="registerEmail">Email</label>
<input type="email" id="registerEmail" required>
</div>
<div class="form-group">
<label for="registerPassword">Password</label>
<input type="password" id="registerPassword" required minlength="6">
</div>
<div class="form-group">
<label for="confirmPassword">Confirm Password</label>
<input type="password" id="confirmPassword" required>
</div>
<button type="submit" class="btn btn-primary">Create Account</button>
</form>
<div class="toggle-form">
<p>Already have an account? <button type="button" onclick="toggleForm()">Sign In</button></p>
</div>
</div>
</div>
</div>
<!-- Footer -->
<footer class="footer">
<div class="footer-content">
<div class="footer-links">
<a href="/impressum.html" class="footer-link">Impressum</a>
<a href="/datenschutz.html" class="footer-link">Datenschutz</a>
<button id="cookie-settings-footer" class="footer-link cookie-settings-btn">Cookie-Einstellungen</button>
</div>
<div class="footer-text">
<p>&copy; 2024 NinjaCross. Alle Rechte vorbehalten.</p>
</div>
</div>
</footer>
<!-- Application JavaScript -->
<script src="/js/cookie-consent.js"></script>
<script src="/js/login.js"></script>
</body>
</html>

55
public/manifest.json Normal file
View File

@@ -0,0 +1,55 @@
{
"name": "Ninja Cross Parkour",
"short_name": "NinjaCross",
"description": "Dein persönliches Dashboard für die Ninja Cross Arena",
"start_url": "/index.html",
"display": "standalone",
"background_color": "#667eea",
"theme_color": "#764ba2",
"orientation": "portrait",
"icons": [
{
"src": "/pictures/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/pictures/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"categories": ["sports", "fitness", "entertainment"],
"lang": "de",
"dir": "ltr",
"scope": "/",
"prefer_related_applications": false,
"shortcuts": [
{
"name": "Dashboard",
"short_name": "Dashboard",
"description": "Öffne dein Dashboard",
"url": "/dashboard.html",
"icons": [
{
"src": "/pictures/icon-192.png",
"sizes": "192x192"
}
]
}
],
"screenshots": [
{
"src": "/pictures/screenshot-mobile.png",
"sizes": "390x844",
"type": "image/png",
"form_factor": "narrow"
}
],
"related_applications": [],
"edge_side_panel": {
"preferred_width": 400
}
}

BIN
public/pictures/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

@@ -0,0 +1,81 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Passwort zurücksetzen - NinjaCross</title>
<link rel="icon" type="image/x-icon" href="/pictures/favicon.ico">
<!-- Supabase -->
<script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2"></script>
<link rel="stylesheet" href="/css/reset-password.css">
</head>
<body>
<div class="container">
<div class="logo">🥷 NINJACROSS</div>
<div class="tagline">Die ultimative Timer-Rangliste</div>
<h1 class="title">Passwort zurücksetzen 🔐</h1>
<p class="subtitle">Gib dein neues Passwort ein, um dein Konto zu sichern</p>
<div id="messageContainer"></div>
<form id="resetForm">
<div class="form-group">
<label for="newPassword" class="form-label">Neues Passwort</label>
<input
type="password"
id="newPassword"
name="newPassword"
class="form-input"
placeholder="Mindestens 8 Zeichen"
required
minlength="8"
>
</div>
<div class="form-group">
<label for="confirmPassword" class="form-label">Passwort bestätigen</label>
<input
type="password"
id="confirmPassword"
name="confirmPassword"
class="form-input"
placeholder="Passwort wiederholen"
required
minlength="8"
>
</div>
<button type="submit" class="btn btn-primary" id="resetBtn">
🔄 Passwort zurücksetzen
</button>
</form>
<div class="loading" id="loading">
<div class="spinner"></div>
Passwort wird zurückgesetzt...
</div>
<a href="/" class="back-link">← Zurück zur Hauptseite</a>
</div>
<!-- Footer -->
<footer class="footer">
<div class="footer-content">
<div class="footer-links">
<a href="/impressum.html" class="footer-link">Impressum</a>
<a href="/datenschutz.html" class="footer-link">Datenschutz</a>
<button id="cookie-settings-footer" class="footer-link cookie-settings-btn">Cookie-Einstellungen</button>
</div>
<div class="footer-text">
<p>&copy; 2024 NinjaCross. Alle Rechte vorbehalten.</p>
</div>
</div>
</footer>
<script src="/js/cookie-consent.js"></script>
<script src="/js/reset-password.js"></script>
</body>
</html>

137
public/sw.js Normal file
View File

@@ -0,0 +1,137 @@
// Service Worker für iPhone Notifications
const CACHE_NAME = 'ninjacross-v1';
const urlsToCache = [
'/',
'/test-push.html',
'/sw.js'
];
// Install event
self.addEventListener('install', function(event) {
console.log('Service Worker installing...');
event.waitUntil(
caches.open(CACHE_NAME)
.then(function(cache) {
// Add files one by one to handle failures gracefully
return Promise.allSettled(
urlsToCache.map(url =>
cache.add(url).catch(err => {
console.warn(`Failed to cache ${url}:`, err);
return null; // Continue with other files
})
)
);
})
.then(() => {
console.log('Service Worker installation completed');
// Skip waiting to activate immediately
return self.skipWaiting();
})
.catch(err => {
console.error('Service Worker installation failed:', err);
// Still try to skip waiting
return self.skipWaiting();
})
);
});
// Activate event
self.addEventListener('activate', function(event) {
console.log('Service Worker activating...');
event.waitUntil(
caches.keys().then(function(cacheNames) {
return Promise.all(
cacheNames.map(function(cacheName) {
if (cacheName !== CACHE_NAME) {
return caches.delete(cacheName);
}
})
);
}).then(() => {
// Take control of all clients immediately
return self.clients.claim();
})
);
});
// Listen for skip waiting message
self.addEventListener('message', function(event) {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
// Fetch event
self.addEventListener('fetch', function(event) {
event.respondWith(
caches.match(event.request)
.then(function(response) {
// Return cached version or fetch from network
return response || fetch(event.request);
}
)
);
});
// Push event (für iPhone Notifications)
self.addEventListener('push', function(event) {
console.log('Push received:', event);
const options = {
body: 'Du hast eine neue Notification!',
icon: '/pictures/icon-192.png',
badge: '/pictures/icon-192.png',
vibrate: [100, 50, 100],
data: {
dateOfArrival: Date.now(),
primaryKey: 1
},
actions: [
{
action: 'explore',
title: 'Dashboard öffnen',
icon: '/pictures/icon-192.png'
},
{
action: 'close',
title: 'Schließen',
icon: '/pictures/icon-192.png'
}
]
};
if (event.data) {
const data = event.data.json();
options.body = data.body || options.body;
options.title = data.title || 'Ninja Cross';
}
event.waitUntil(
self.registration.showNotification('Ninja Cross', options)
);
});
// Notification click event
self.addEventListener('notificationclick', function(event) {
console.log('Notification clicked:', event);
event.notification.close();
if (event.action === 'explore') {
event.waitUntil(
clients.openWindow('/dashboard.html')
);
}
});
// Background sync (für offline functionality)
self.addEventListener('sync', function(event) {
if (event.tag === 'background-sync') {
event.waitUntil(doBackgroundSync());
}
});
async function doBackgroundSync() {
// Sync data when back online
console.log('Background sync triggered');
}

560
public/test-push.html Normal file
View File

@@ -0,0 +1,560 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Push Notification Test - Ninja Cross</title>
<link rel="icon" type="image/x-icon" href="/pictures/favicon.ico">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
min-height: 100vh;
}
.container {
background: rgba(255, 255, 255, 0.1);
padding: 30px;
border-radius: 15px;
backdrop-filter: blur(10px);
}
h1 {
text-align: center;
margin-bottom: 30px;
}
.status {
background: rgba(0, 0, 0, 0.2);
padding: 15px;
border-radius: 10px;
margin: 20px 0;
}
.status.success { background: rgba(34, 197, 94, 0.3); }
.status.error { background: rgba(239, 68, 68, 0.3); }
.status.warning { background: rgba(245, 158, 11, 0.3); }
button {
background: linear-gradient(135deg, #00d4ff, #0891b2);
color: white;
border: none;
padding: 12px 24px;
border-radius: 8px;
cursor: pointer;
font-size: 16px;
margin: 10px 5px;
transition: transform 0.2s;
}
button:hover {
transform: translateY(-2px);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
input, textarea {
width: 100%;
padding: 10px;
border: none;
border-radius: 5px;
margin: 10px 0;
font-size: 16px;
}
.log {
background: rgba(0, 0, 0, 0.3);
padding: 15px;
border-radius: 10px;
margin-top: 20px;
max-height: 300px;
overflow-y: auto;
font-family: monospace;
font-size: 14px;
}
</style>
</head>
<body>
<div class="container">
<h1>🧪 Push Notification Test</h1>
<div id="status" class="status">
<strong>Status:</strong> <span id="statusText">Lädt...</span>
</div>
<div>
<h3>1. Service Worker Status</h3>
<button onclick="checkServiceWorker()">Service Worker prüfen</button>
<button onclick="registerServiceWorker()">Service Worker registrieren</button>
</div>
<div>
<h3>2. Notification Permission</h3>
<button onclick="requestPermission()">Berechtigung anfordern</button>
<button onclick="checkPermission()">Berechtigung prüfen</button>
</div>
<div>
<h3>3. Push Subscription</h3>
<button onclick="subscribeToPush()">Push abonnieren</button>
<button onclick="unsubscribeFromPush()">Push abbestellen</button>
<button onclick="checkSubscription()">Subscription prüfen</button>
</div>
<div>
<h3>4. Test Notifications</h3>
<input type="text" id="testMessage" placeholder="Test-Nachricht" value="Das ist eine Test-Push-Notification!">
<button onclick="sendTestPush()">Test-Push senden</button>
<button onclick="sendTestWebNotification()">Web-Notification senden</button>
<button onclick="sendWindowsNotification()">Windows-Notification senden</button>
</div>
<div>
<h3>5. Push Status</h3>
<button onclick="getPushStatus()">Push-Status abrufen</button>
</div>
<div class="log" id="log">
<div>Log wird hier angezeigt...</div>
</div>
</div>
<script>
let currentSubscription = null;
// Convert VAPID key from base64url to Uint8Array
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/\-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
function log(message, type = 'info') {
const logDiv = document.getElementById('log');
const timestamp = new Date().toLocaleTimeString();
const logEntry = document.createElement('div');
logEntry.style.color = type === 'error' ? '#ff6b6b' : type === 'success' ? '#51cf66' : '#ffffff';
logEntry.textContent = `[${timestamp}] ${message}`;
logDiv.appendChild(logEntry);
logDiv.scrollTop = logDiv.scrollHeight;
}
function updateStatus(message, type = 'info') {
const statusDiv = document.getElementById('status');
const statusText = document.getElementById('statusText');
statusText.textContent = message;
statusDiv.className = `status ${type}`;
}
// Service Worker Functions
async function checkServiceWorker() {
if ('serviceWorker' in navigator) {
const registrations = await navigator.serviceWorker.getRegistrations();
log(`Service Worker registriert: ${registrations.length > 0 ? 'Ja' : 'Nein'}`);
if (registrations.length > 0) {
const reg = registrations[0];
log(`SW Scope: ${reg.scope}`);
log(`SW State: ${reg.active ? reg.active.state : 'kein aktiver Worker'}`);
}
} else {
log('Service Worker nicht unterstützt', 'error');
}
}
async function registerServiceWorker() {
if ('serviceWorker' in navigator) {
try {
log('Service Worker wird registriert...', 'info');
const registration = await navigator.serviceWorker.register('/sw.js');
log('Service Worker erfolgreich registriert', 'success');
log(`SW Scope: ${registration.scope}`);
updateStatus('Service Worker registriert', 'success');
} catch (error) {
log(`Service Worker Registrierung fehlgeschlagen: ${error.message}`, 'error');
log(`Error Details: ${JSON.stringify(error)}`, 'error');
updateStatus(`Service Worker Registrierung fehlgeschlagen: ${error.message}`, 'error');
}
} else {
log('Service Worker nicht unterstützt', 'error');
updateStatus('Service Worker nicht unterstützt', 'error');
}
}
// Notification Permission Functions
async function requestPermission() {
if ('Notification' in window) {
const permission = await Notification.requestPermission();
log(`Notification Permission: ${permission}`, permission === 'granted' ? 'success' : 'warning');
updateStatus(`Notification Permission: ${permission}`, permission === 'granted' ? 'success' : 'warning');
} else {
log('Notifications nicht unterstützt', 'error');
}
}
function checkPermission() {
if ('Notification' in window) {
log(`Notification Permission: ${Notification.permission}`);
updateStatus(`Notification Permission: ${Notification.permission}`,
Notification.permission === 'granted' ? 'success' : 'warning');
} else {
log('Notifications nicht unterstützt', 'error');
}
}
// Push Subscription Functions
async function subscribeToPush() {
log('Push Subscription gestartet...', 'info');
// Check basic requirements
if (!('serviceWorker' in navigator)) {
log('Service Worker nicht unterstützt', 'error');
updateStatus('Service Worker nicht unterstützt', 'error');
return;
}
if (!('PushManager' in window)) {
log('Push Manager nicht unterstützt', 'error');
updateStatus('Push Manager nicht unterstützt', 'error');
return;
}
// Check notification permission first
if (Notification.permission !== 'granted') {
log('Notification Permission nicht erteilt. Bitte zuerst "Berechtigung anfordern" klicken!', 'error');
updateStatus('Notification Permission erforderlich', 'error');
return;
}
try {
log('Service Worker wird geladen...', 'info');
// First check if service worker is already registered
let registration;
const existingRegistrations = await navigator.serviceWorker.getRegistrations();
if (existingRegistrations.length > 0) {
log('Service Worker bereits registriert, verwende bestehende...', 'info');
registration = existingRegistrations[0];
} else {
log('Service Worker nicht registriert, registriere jetzt...', 'info');
registration = await navigator.serviceWorker.register('/sw.js');
log('Service Worker registriert', 'success');
}
// Wait for service worker to be ready with timeout
log('Warte auf Service Worker ready...', 'info');
// Check if service worker is active
if (registration.active) {
log('Service Worker ist bereits aktiv', 'success');
} else if (registration.installing && registration.installing.state) {
log('Service Worker wird installiert, warte...', 'info');
await new Promise((resolve) => {
const installingWorker = registration.installing;
if (installingWorker) {
installingWorker.addEventListener('statechange', () => {
if (installingWorker.state === 'installed') {
resolve();
}
});
} else {
resolve();
}
});
log('Service Worker Installation abgeschlossen', 'success');
} else if (registration.waiting && registration.waiting.state) {
log('Service Worker wartet, aktiviere...', 'info');
registration.waiting.postMessage({ type: 'SKIP_WAITING' });
await new Promise((resolve) => {
const waitingWorker = registration.waiting;
if (waitingWorker) {
waitingWorker.addEventListener('statechange', () => {
if (waitingWorker.state === 'activated') {
resolve();
}
});
} else {
resolve();
}
});
log('Service Worker aktiviert', 'success');
} else {
log('Service Worker Status unbekannt, warte auf ready...', 'info');
try {
await navigator.serviceWorker.ready;
log('Service Worker bereit', 'success');
} catch (error) {
log(`Service Worker ready fehlgeschlagen: ${error.message}`, 'error');
throw error;
}
}
// Convert VAPID key from base64url to ArrayBuffer
const vapidPublicKey = 'BJmNVx0C3XeVxeKGTP9c-Z4HcuZNmdk6QdiLocZgCmb-miCS0ESFO3W2TvJlRhhNAShV63pWA5p36BTVSetyTds';
log('VAPID Key wird konvertiert...', 'info');
let applicationServerKey;
try {
applicationServerKey = urlBase64ToUint8Array(vapidPublicKey);
log('VAPID Key konvertiert', 'success');
} catch (error) {
log(`VAPID Key Konvertierung fehlgeschlagen: ${error.message}`, 'error');
throw error;
}
log('Push Subscription wird erstellt...', 'info');
// Check if push manager is available
if (!registration.pushManager) {
throw new Error('Push Manager nicht verfügbar in diesem Service Worker');
}
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: applicationServerKey
});
currentSubscription = subscription;
log('Push Subscription erfolgreich erstellt', 'success');
log(`Endpoint: ${subscription.endpoint.substring(0, 50)}...`);
// Send to server
log('Subscription wird an Server gesendet...', 'info');
const response = await fetch('/api/v1/public/subscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(subscription)
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const result = await response.json();
if (result.success) {
log('Subscription erfolgreich an Server gesendet', 'success');
log(`Player ID: ${result.playerId || 'anonymous'}`, 'success');
// Store the player ID for later use
if (result.playerId) {
localStorage.setItem('pushPlayerId', result.playerId);
}
updateStatus('Push Subscription erfolgreich!', 'success');
// Store the subscription endpoint for later use
localStorage.setItem('pushSubscriptionEndpoint', subscription.endpoint);
} else {
log(`Server-Fehler: ${result.message}`, 'error');
updateStatus(`Server-Fehler: ${result.message}`, 'error');
}
} catch (error) {
log(`Push Subscription fehlgeschlagen: ${error.message}`, 'error');
log(`Error Details: ${JSON.stringify(error)}`, 'error');
updateStatus(`Push Subscription fehlgeschlagen: ${error.message}`, 'error');
}
}
async function unsubscribeFromPush() {
if (currentSubscription) {
try {
await currentSubscription.unsubscribe();
currentSubscription = null;
log('Push Subscription erfolgreich abbestellt', 'success');
} catch (error) {
log(`Push Unsubscribe fehlgeschlagen: ${error.message}`, 'error');
}
} else {
log('Keine aktive Subscription gefunden', 'warning');
}
}
async function checkSubscription() {
log('Überprüfe Push Subscription...', 'info');
if (!('serviceWorker' in navigator)) {
log('Service Worker nicht unterstützt', 'error');
return;
}
try {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
currentSubscription = subscription;
log('Aktive Push Subscription gefunden', 'success');
log(`Endpoint: ${subscription.endpoint.substring(0, 50)}...`);
updateStatus('Push Subscription aktiv', 'success');
} else {
log('Keine Push Subscription gefunden', 'warning');
updateStatus('Keine Push Subscription gefunden', 'warning');
}
} catch (error) {
log(`Subscription Check fehlgeschlagen: ${error.message}`, 'error');
updateStatus(`Subscription Check fehlgeschlagen: ${error.message}`, 'error');
}
}
// Test Functions
async function sendTestPush() {
const message = document.getElementById('testMessage').value;
log('Test-Push wird gesendet...', 'info');
// First check if we have a subscription
if (!currentSubscription) {
log('Keine Push Subscription gefunden. Bitte zuerst "Push abonnieren" klicken!', 'error');
updateStatus('Keine Push Subscription gefunden', 'error');
return;
}
// Use the stored player ID from subscription
const storedPlayerId = localStorage.getItem('pushPlayerId');
let userId = 'test-user';
if (storedPlayerId) {
userId = storedPlayerId;
}
log(`Sende Test-Push an Player ID: ${userId}`, 'info');
try {
const response = await fetch('/api/v1/public/test-push', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
userId: userId,
message: message
})
});
const result = await response.json();
if (result.success) {
log('Test-Push erfolgreich gesendet', 'success');
log(`An User ID: ${userId}`, 'success');
updateStatus('Test-Push erfolgreich gesendet!', 'success');
} else {
log(`Test-Push fehlgeschlagen: ${result.message}`, 'error');
updateStatus(`Test-Push fehlgeschlagen: ${result.message}`, 'error');
}
} catch (error) {
log(`Test-Push Fehler: ${error.message}`, 'error');
updateStatus(`Test-Push Fehler: ${error.message}`, 'error');
}
}
function sendTestWebNotification() {
if ('Notification' in window && Notification.permission === 'granted') {
const message = document.getElementById('testMessage').value;
const notification = new Notification('🧪 Test Web Notification', {
body: message,
icon: '/pictures/icon-192.png',
badge: '/pictures/icon-192.png',
tag: 'test-notification',
requireInteraction: true,
silent: false
});
notification.onclick = function() {
window.focus();
notification.close();
};
// Auto-close after 10 seconds
setTimeout(() => {
notification.close();
}, 10000);
log('Web-Notification gesendet', 'success');
} else {
log('Web-Notifications nicht verfügbar oder nicht erlaubt', 'error');
}
}
// Windows Desktop Notification (falls verfügbar)
function sendWindowsNotification() {
if ('Notification' in window && Notification.permission === 'granted') {
const message = document.getElementById('testMessage').value;
// Erstelle eine Windows-ähnliche Notification
const notification = new Notification('🏆 Ninja Cross - Achievement!', {
body: message,
icon: '/pictures/icon-192.png',
badge: '/pictures/icon-192.png',
tag: 'ninja-cross-achievement',
requireInteraction: true,
silent: false,
data: {
type: 'achievement',
timestamp: Date.now()
}
});
notification.onclick = function() {
window.focus();
notification.close();
};
// Auto-close after 15 seconds
setTimeout(() => {
notification.close();
}, 15000);
log('Windows-ähnliche Notification gesendet', 'success');
} else {
log('Web-Notifications nicht verfügbar oder nicht erlaubt', 'error');
}
}
async function getPushStatus() {
try {
const response = await fetch('/api/v1/public/push-status');
const result = await response.json();
if (result.success) {
log(`Push Status: ${JSON.stringify(result.data, null, 2)}`, 'success');
} else {
log(`Push Status Fehler: ${result.message}`, 'error');
}
} catch (error) {
log(`Push Status Fehler: ${error.message}`, 'error');
}
}
// Initialize
window.addEventListener('load', function() {
console.log('Push Notification Test Seite geladen');
log('Push Notification Test Seite geladen');
// Check if we're on HTTPS
if (location.protocol !== 'https:' && location.hostname !== 'localhost') {
log('WARNUNG: Push Notifications funktionieren nur über HTTPS!', 'error');
updateStatus('HTTPS erforderlich für Push Notifications', 'error');
} else {
log('HTTPS-Verbindung erkannt - Push Notifications möglich', 'success');
}
checkServiceWorker();
checkPermission();
checkSubscription();
});
// Also initialize on DOMContentLoaded as backup
document.addEventListener('DOMContentLoaded', function() {
console.log('DOM Content Loaded');
log('DOM Content Loaded - Initialisierung gestartet');
});
</script>
</body>
</html>

4969
routes/api.js Normal file

File diff suppressed because it is too large Load Diff

176
routes/public.js Normal file
View File

@@ -0,0 +1,176 @@
// routes/public.js
const express = require('express');
const { Pool } = require('pg');
const router = express.Router();
// PostgreSQL Pool mit .env Konfiguration
const pool = new Pool({
host: process.env.DB_HOST,
port: process.env.DB_PORT,
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
ssl: process.env.DB_SSL === 'true' ? { rejectUnauthorized: false } : false
});
// Fehlerbehandlung für Pool
pool.on('error', (err) => {
console.error('PostgreSQL Pool Fehler:', err);
});
// Public endpoint für Standorte (keine Authentifizierung erforderlich)
router.get('/locations', async (req, res) => {
try {
const result = await pool.query('SELECT * FROM "GetLocations"');
res.json({
success: true,
data: result.rows
});
} catch (error) {
console.error('Fehler beim Abrufen der getlocations:', error);
res.status(500).json({
success: false,
message: 'Fehler beim Abrufen der Standorte'
});
}
});
// Public route to get times for location with parameter
router.get('/times', async (req, res) => {
const { location } = req.query;
try {
// First, let's check if the view exists and has data
const viewCheck = await pool.query('SELECT COUNT(*) as count FROM "GetTimesWithPlayerAndLocation"');
// Check what location names are available
const availableLocations = await pool.query('SELECT DISTINCT location_name FROM "GetTimesWithPlayerAndLocation"');
// Now search for the specific location
const result = await pool.query('SELECT * FROM "GetTimesWithPlayerAndLocation" WHERE location_name = $1', [location]);
res.json({
success: true,
data: result.rows,
debug: {
searchedFor: location,
totalRecords: viewCheck.rows[0].count,
availableLocations: availableLocations.rows.map(r => r.location_name),
foundRecords: result.rows.length
}
});
} catch (error) {
console.error('❌ Fehler beim Abrufen der Zeiten:', error);
res.status(500).json({
success: false,
message: 'Fehler beim Abrufen der Zeiten',
error: error.message
});
}
});
// Public route to get all times with player and location details for leaderboard
router.get('/times-with-details', async (req, res) => {
try {
const { location, period } = req.query;
// Build WHERE clause for location filter
let locationFilter = '';
if (location && location !== 'all') {
locationFilter = `AND l.name ILIKE '%${location}%'`;
}
// Build WHERE clause for date filter using PostgreSQL timezone functions
let dateFilter = '';
if (period === 'today') {
// Today in local timezone (UTC+2)
dateFilter = `AND DATE(t.created_at AT TIME ZONE 'UTC' AT TIME ZONE 'Europe/Berlin') = CURRENT_DATE`;
} else if (period === 'week') {
// This week starting from Monday in local timezone
dateFilter = `AND DATE(t.created_at AT TIME ZONE 'UTC' AT TIME ZONE 'Europe/Berlin') >= DATE_TRUNC('week', CURRENT_DATE)`;
} else if (period === 'month') {
// This month starting from 1st in local timezone
dateFilter = `AND DATE(t.created_at AT TIME ZONE 'UTC' AT TIME ZONE 'Europe/Berlin') >= DATE_TRUNC('month', CURRENT_DATE)`;
}
// Get all times with player and location details, ordered by time (fastest first)
// SECURITY: Only return data needed for leaderboard display
const result = await pool.query(`
SELECT
EXTRACT(EPOCH FROM t.recorded_time) as recorded_time_seconds,
t.created_at,
json_build_object(
'firstname', p.firstname,
'lastname', p.lastname
) as player,
json_build_object(
'name', l.name
) as location
FROM times t
LEFT JOIN players p ON t.player_id = p.id
LEFT JOIN locations l ON t.location_id = l.id
WHERE 1=1 ${locationFilter} ${dateFilter}
ORDER BY t.recorded_time ASC
LIMIT 50
`);
// Convert seconds to minutes:seconds.milliseconds format
const formattedResults = result.rows.map(row => {
const totalSeconds = parseFloat(row.recorded_time_seconds);
const minutes = Math.floor(totalSeconds / 60);
const seconds = Math.floor(totalSeconds % 60);
const milliseconds = Math.floor((totalSeconds % 1) * 1000);
return {
...row,
recorded_time: {
minutes: minutes,
seconds: seconds,
milliseconds: milliseconds
}
};
});
res.json(formattedResults);
} catch (error) {
console.error('❌ Fehler beim Abrufen der Zeiten mit Details:', error);
res.status(500).json({
success: false,
message: 'Fehler beim Abrufen der Zeiten mit Details',
error: error.message
});
}
});
// Public route to get all locations for filter dropdown
router.get('/locations', async (req, res) => {
try {
const result = await pool.query(`
SELECT id, name, latitude, longitude
FROM locations
ORDER BY name ASC
`);
res.json({
success: true,
data: result.rows
});
} catch (error) {
console.error('❌ Fehler beim Abrufen der Standorte:', error);
res.status(500).json({
success: false,
message: 'Fehler beim Abrufen der Standorte',
error: error.message
});
}
});
module.exports = router;

View File

@@ -0,0 +1,198 @@
const { Pool } = require('pg');
require('dotenv').config();
const pool = new Pool({
host: process.env.DB_HOST || 'localhost',
port: process.env.DB_PORT || 5432,
database: process.env.DB_NAME || 'ninjacross',
user: process.env.DB_USER || '',
password: process.env.DB_PASSWORD || '',
ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false
});
async function runBestTimeAchievements() {
const client = await pool.connect();
try {
console.log('🏆 Starting best-time achievement check at 19:00...');
const currentHour = new Date().getHours();
const currentDay = new Date().getDay(); // 0 = Sunday
const currentDate = new Date();
const isLastDayOfMonth = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 0).getDate() === currentDate.getDate();
console.log(`Current time: ${currentHour}:00`);
console.log(`Is Sunday: ${currentDay === 0}`);
console.log(`Is last day of month: ${isLastDayOfMonth}`);
// Get all players who have played
const playersResult = await client.query(`
SELECT DISTINCT p.id, p.firstname, p.lastname
FROM players p
INNER JOIN times t ON p.id = t.player_id
`);
console.log(`Found ${playersResult.rows.length} players with times`);
let dailyAwards = 0;
let weeklyAwards = 0;
let monthlyAwards = 0;
// Check best-time achievements for each player
for (const player of playersResult.rows) {
console.log(`Checking best-time achievements for ${player.firstname} ${player.lastname}...`);
// Run best-time achievement check function
await client.query('SELECT check_best_time_achievements_timed($1)', [player.id]);
// Check if new daily achievement was earned today
const dailyResult = await client.query(`
SELECT COUNT(*) as count
FROM player_achievements pa
INNER JOIN achievements a ON pa.achievement_id = a.id
WHERE pa.player_id = $1
AND a.category = 'best_time'
AND a.condition_type = 'daily_best'
AND pa.is_completed = true
AND DATE(pa.earned_at AT TIME ZONE 'Europe/Berlin') = CURRENT_DATE
`, [player.id]);
if (parseInt(dailyResult.rows[0].count) > 0) {
dailyAwards++;
console.log(` 🥇 Daily best achievement earned!`);
}
// Check if new weekly achievement was earned (only on Sunday)
if (currentDay === 0) {
const weeklyResult = await client.query(`
SELECT COUNT(*) as count
FROM player_achievements pa
INNER JOIN achievements a ON pa.achievement_id = a.id
WHERE pa.player_id = $1
AND a.category = 'best_time'
AND a.condition_type = 'weekly_best'
AND pa.is_completed = true
AND DATE(pa.earned_at AT TIME ZONE 'Europe/Berlin') = CURRENT_DATE
`, [player.id]);
if (parseInt(weeklyResult.rows[0].count) > 0) {
weeklyAwards++;
console.log(` 🏆 Weekly best achievement earned!`);
}
}
// Check if new monthly achievement was earned (only on last day of month)
if (isLastDayOfMonth) {
const monthlyResult = await client.query(`
SELECT COUNT(*) as count
FROM player_achievements pa
INNER JOIN achievements a ON pa.achievement_id = a.id
WHERE pa.player_id = $1
AND a.category = 'best_time'
AND a.condition_type = 'monthly_best'
AND pa.is_completed = true
AND DATE(pa.earned_at AT TIME ZONE 'Europe/Berlin') = CURRENT_DATE
`, [player.id]);
if (parseInt(monthlyResult.rows[0].count) > 0) {
monthlyAwards++;
console.log(` 👑 Monthly best achievement earned!`);
}
}
}
console.log(`\n🎉 Best-time achievement check completed!`);
console.log(`Daily awards: ${dailyAwards}`);
console.log(`Weekly awards: ${weeklyAwards}`);
console.log(`Monthly awards: ${monthlyAwards}`);
// Get current best times for today
const bestTimesResult = await client.query(`
SELECT
'daily' as period,
p.firstname || ' ' || p.lastname as player_name,
MIN(t.recorded_time) as best_time
FROM times t
INNER JOIN players p ON t.player_id = p.id
WHERE DATE(t.created_at AT TIME ZONE 'Europe/Berlin') = CURRENT_DATE
GROUP BY p.id, p.firstname, p.lastname
ORDER BY MIN(t.recorded_time) ASC
LIMIT 1
`);
if (bestTimesResult.rows.length > 0) {
const dailyBest = bestTimesResult.rows[0];
console.log(`\n🥇 Today's best time: ${dailyBest.player_name} - ${dailyBest.best_time}`);
}
// Get current best times for this week (if Sunday)
if (currentDay === 0) {
const weekStart = new Date();
weekStart.setDate(weekStart.getDate() - weekStart.getDay());
const weeklyBestResult = await client.query(`
SELECT
'weekly' as period,
p.firstname || ' ' || p.lastname as player_name,
MIN(t.recorded_time) as best_time
FROM times t
INNER JOIN players p ON t.player_id = p.id
WHERE DATE(t.created_at AT TIME ZONE 'Europe/Berlin') >= $1
AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') <= CURRENT_DATE
GROUP BY p.id, p.firstname, p.lastname
ORDER BY MIN(t.recorded_time) ASC
LIMIT 1
`, [weekStart.toISOString().split('T')[0]]);
if (weeklyBestResult.rows.length > 0) {
const weeklyBest = weeklyBestResult.rows[0];
console.log(`🏆 This week's best time: ${weeklyBest.player_name} - ${weeklyBest.best_time}`);
}
}
// Get current best times for this month (if last day of month)
if (isLastDayOfMonth) {
const monthStart = new Date(currentDate.getFullYear(), currentDate.getMonth(), 1);
const monthlyBestResult = await client.query(`
SELECT
'monthly' as period,
p.firstname || ' ' || p.lastname as player_name,
MIN(t.recorded_time) as best_time
FROM times t
INNER JOIN players p ON t.player_id = p.id
WHERE DATE(t.created_at AT TIME ZONE 'Europe/Berlin') >= $1
AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') <= CURRENT_DATE
GROUP BY p.id, p.firstname, p.lastname
ORDER BY MIN(t.recorded_time) ASC
LIMIT 1
`, [monthStart.toISOString().split('T')[0]]);
if (monthlyBestResult.rows.length > 0) {
const monthlyBest = monthlyBestResult.rows[0];
console.log(`👑 This month's best time: ${monthlyBest.player_name} - ${monthlyBest.best_time}`);
}
}
} catch (error) {
console.error('❌ Error running best-time achievements:', error);
throw error;
} finally {
client.release();
}
}
// Run if called directly
if (require.main === module) {
runBestTimeAchievements()
.then(() => {
console.log('✅ Best-time achievements script completed successfully');
process.exit(0);
})
.catch((error) => {
console.error('❌ Best-time achievements script failed:', error);
process.exit(1);
});
}
module.exports = { runBestTimeAchievements };

View File

@@ -0,0 +1,141 @@
const { Pool } = require('pg');
const cron = require('node-cron');
// Database connection
const pool = new Pool({
user: 'postgres',
host: 'localhost',
database: 'ninjacross',
password: 'postgres',
port: 5432,
});
async function checkAndNotifyBestTimes() {
try {
console.log('🔔 Checking best times for notifications...');
const currentDate = new Date();
const today = currentDate.toISOString().split('T')[0];
const weekStart = new Date(currentDate.setDate(currentDate.getDate() - currentDate.getDay()));
const monthStart = new Date(currentDate.getFullYear(), currentDate.getMonth(), 1);
// Check daily best times
const dailyBestQuery = `
WITH daily_best AS (
SELECT
t.player_id,
MIN(t.recorded_time) as best_time,
p.name as player_name,
p.email
FROM times t
JOIN players p ON t.player_id = p.id
WHERE DATE(t.created_at AT TIME ZONE 'Europe/Berlin') = $1
GROUP BY t.player_id, p.name, p.email
),
global_daily_best AS (
SELECT MIN(best_time) as global_best
FROM daily_best
)
SELECT
db.player_id,
db.player_name,
db.email,
db.best_time,
gdb.global_best
FROM daily_best db
CROSS JOIN global_daily_best gdb
WHERE db.best_time = gdb.global_best
`;
const dailyResult = await pool.query(dailyBestQuery, [today]);
for (const row of dailyResult.rows) {
console.log(`🏆 Daily best time: ${row.player_name} with ${row.best_time}`);
// Here we would send the notification
// For now, just log it
}
// Check weekly best times
const weeklyBestQuery = `
WITH weekly_best AS (
SELECT
t.player_id,
MIN(t.recorded_time) as best_time,
p.name as player_name,
p.email
FROM times t
JOIN players p ON t.player_id = p.id
WHERE DATE(t.created_at AT TIME ZONE 'Europe/Berlin') >= $1
AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') <= $2
GROUP BY t.player_id, p.name, p.email
),
global_weekly_best AS (
SELECT MIN(best_time) as global_best
FROM weekly_best
)
SELECT
wb.player_id,
wb.player_name,
wb.email,
wb.best_time,
gwb.global_best
FROM weekly_best wb
CROSS JOIN global_weekly_best gwb
WHERE wb.best_time = gwb.global_best
`;
const weeklyResult = await pool.query(weeklyBestQuery, [weekStart.toISOString().split('T')[0], today]);
for (const row of weeklyResult.rows) {
console.log(`🏆 Weekly best time: ${row.player_name} with ${row.best_time}`);
}
// Check monthly best times
const monthlyBestQuery = `
WITH monthly_best AS (
SELECT
t.player_id,
MIN(t.recorded_time) as best_time,
p.name as player_name,
p.email
FROM times t
JOIN players p ON t.player_id = p.id
WHERE DATE(t.created_at AT TIME ZONE 'Europe/Berlin') >= $1
AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') <= $2
GROUP BY t.player_id, p.name, p.email
),
global_monthly_best AS (
SELECT MIN(best_time) as global_best
FROM monthly_best
)
SELECT
mb.player_id,
mb.player_name,
mb.email,
mb.best_time,
gmb.global_best
FROM monthly_best mb
CROSS JOIN global_monthly_best gmb
WHERE mb.best_time = gmb.global_best
`;
const monthlyResult = await pool.query(monthlyBestQuery, [monthStart.toISOString().split('T')[0], today]);
for (const row of monthlyResult.rows) {
console.log(`🏆 Monthly best time: ${row.player_name} with ${row.best_time}`);
}
console.log('✅ Best time notifications check completed');
} catch (error) {
console.error('❌ Error checking best times:', error);
}
}
// Schedule to run every day at 19:00 (7 PM)
cron.schedule('0 19 * * *', () => {
console.log('🕐 Running best time notifications check...');
checkAndNotifyBestTimes();
});
console.log('📅 Best time notifications scheduler started - runs daily at 19:00');

109
scripts/create-user.js Normal file
View File

@@ -0,0 +1,109 @@
// scripts/create-user.js
const { Pool } = require('pg');
const bcrypt = require('bcrypt');
const readline = require('readline');
require('dotenv').config();
const pool = new Pool({
host: process.env.DB_HOST,
port: process.env.DB_PORT,
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
ssl: process.env.DB_SSL === 'true' ? { rejectUnauthorized: false } : false
});
// Readline Interface für Benutzereingaben
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
// Hilfsfunktion für Benutzereingaben
function askQuestion(question) {
return new Promise((resolve) => {
rl.question(question, (answer) => {
resolve(answer.trim());
});
});
}
async function createUser() {
try {
console.log('👤 Erstelle neuen Benutzer...\n');
// Verbindung testen
await pool.query('SELECT NOW()');
console.log('✅ Datenbankverbindung erfolgreich\n');
// Benutzereingaben abfragen
const username = await askQuestion('Benutzername eingeben: ');
if (!username) {
console.log('❌ Benutzername darf nicht leer sein!');
return;
}
const password = await askQuestion('Passwort eingeben: ');
if (!password) {
console.log('❌ Passwort darf nicht leer sein!');
return;
}
const confirmPassword = await askQuestion('Passwort bestätigen: ');
if (password !== confirmPassword) {
console.log('❌ Passwörter stimmen nicht überein!');
return;
}
console.log('\n🔄 Erstelle Benutzer...');
// Prüfen ob Benutzer bereits existiert
const existingUser = await pool.query(
'SELECT id FROM adminusers WHERE username = $1',
[username]
);
if (existingUser.rows.length > 0) {
console.log(` Benutzer "${username}" existiert bereits`);
const update = await askQuestion('Passwort aktualisieren? (j/n): ');
if (update.toLowerCase() === 'j' || update.toLowerCase() === 'ja' || update.toLowerCase() === 'y' || update.toLowerCase() === 'yes') {
const passwordHash = await bcrypt.hash(password, 10);
await pool.query(
'UPDATE adminusers SET password_hash = $1 WHERE username = $2',
[passwordHash, username]
);
console.log(`✅ Passwort für Benutzer "${username}" aktualisiert`);
} else {
console.log('❌ Vorgang abgebrochen');
return;
}
} else {
// Neuen Benutzer erstellen
const passwordHash = await bcrypt.hash(password, 10);
await pool.query(
'INSERT INTO adminusers (username, password_hash) VALUES ($1, $2)',
[username, passwordHash]
);
console.log(`✅ Benutzer "${username}" erfolgreich erstellt`);
}
console.log('\n📝 Anmeldedaten:');
console.log(` Benutzername: ${username}`);
console.log(` Passwort: ${password}`);
} catch (error) {
console.error('❌ Fehler beim Erstellen des Benutzers:', error);
process.exit(1);
} finally {
rl.close();
await pool.end();
}
}
// Skript ausführen wenn direkt aufgerufen
if (require.main === module) {
createUser();
}
module.exports = { createUser };

View File

@@ -0,0 +1,110 @@
const { Pool } = require('pg');
require('dotenv').config();
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false
});
async function runDailyAchievements() {
const client = await pool.connect();
try {
console.log('🎯 Starting daily achievement check...');
// Get all players who have played today
const playersResult = await client.query(`
SELECT DISTINCT p.id, p.firstname, p.lastname
FROM players p
INNER JOIN times t ON p.id = t.player_id
WHERE DATE(t.created_at AT TIME ZONE 'Europe/Berlin') = CURRENT_DATE
`);
console.log(`Found ${playersResult.rows.length} players who played today`);
let totalAchievements = 0;
// Check achievements for each player
for (const player of playersResult.rows) {
console.log(`Checking achievements for ${player.firstname} ${player.lastname}...`);
// Run achievement check function
await client.query('SELECT check_all_achievements($1)', [player.id]);
// Count new achievements earned today
const newAchievementsResult = await client.query(`
SELECT COUNT(*) as count
FROM player_achievements pa
INNER JOIN achievements a ON pa.achievement_id = a.id
WHERE pa.player_id = $1
AND pa.is_completed = true
AND DATE(pa.earned_at AT TIME ZONE 'Europe/Berlin') = CURRENT_DATE
`, [player.id]);
const newAchievements = parseInt(newAchievementsResult.rows[0].count);
totalAchievements += newAchievements;
if (newAchievements > 0) {
console.log(`${newAchievements} new achievements earned!`);
// Get details of new achievements
const achievementsResult = await client.query(`
SELECT a.name, a.description, a.icon, a.points
FROM player_achievements pa
INNER JOIN achievements a ON pa.achievement_id = a.id
WHERE pa.player_id = $1
AND pa.is_completed = true
AND DATE(pa.earned_at AT TIME ZONE 'Europe/Berlin') = CURRENT_DATE
ORDER BY pa.earned_at DESC
`, [player.id]);
achievementsResult.rows.forEach(achievement => {
console.log(` ${achievement.icon} ${achievement.name} (+${achievement.points} points)`);
});
} else {
console.log(` No new achievements`);
}
}
console.log(`\n🎉 Daily achievement check completed!`);
console.log(`Total new achievements earned: ${totalAchievements}`);
// Log summary statistics
const statsResult = await client.query(`
SELECT
COUNT(DISTINCT pa.player_id) as players_with_achievements,
COUNT(pa.id) as total_achievements_earned,
SUM(a.points) as total_points_earned
FROM player_achievements pa
INNER JOIN achievements a ON pa.achievement_id = a.id
WHERE pa.is_completed = true
`);
const stats = statsResult.rows[0];
console.log(`\n📊 Overall Statistics:`);
console.log(`Players with achievements: ${stats.players_with_achievements}`);
console.log(`Total achievements earned: ${stats.total_achievements_earned}`);
console.log(`Total points earned: ${stats.total_points_earned || 0}`);
} catch (error) {
console.error('❌ Error running daily achievements:', error);
throw error;
} finally {
client.release();
}
}
// Run if called directly
if (require.main === module) {
runDailyAchievements()
.then(() => {
console.log('✅ Daily achievements script completed successfully');
process.exit(0);
})
.catch((error) => {
console.error('❌ Daily achievements script failed:', error);
process.exit(1);
});
}
module.exports = { runDailyAchievements };

View File

@@ -0,0 +1,285 @@
const { Pool } = require('pg');
const webpush = require('web-push');
// Database connection
const pool = new Pool({
user: process.env.DB_USER || 'postgres',
host: process.env.DB_HOST || 'localhost',
database: process.env.DB_NAME || 'ninjacross',
password: process.env.DB_PASSWORD || 'postgres',
port: process.env.DB_PORT || 5432,
});
// VAPID keys (same as push-service.js)
const vapidKeys = {
publicKey: 'BJmNVx0C3XeVxeKGTP9c-Z4HcuZNmdk6QdiLocZgCmb-miCS0ESFO3W2TvJlRhhNAShV63pWA5p36BTVSetyTds',
privateKey: 'HBdRCtmZUAzsWpVjZ2LDaoWliIPHldAb5ExAt8bvDeg'
};
webpush.setVapidDetails(
'mailto:admin@ninjacross.com',
vapidKeys.publicKey,
vapidKeys.privateKey
);
/**
* Send push notification to a specific player
*/
async function sendPushToPlayer(playerId, title, message, data = {}) {
try {
console.log(`📱 Sending push to player ${playerId}: ${title}`);
const result = await pool.query(`
SELECT endpoint, p256dh, auth
FROM player_subscriptions
WHERE player_id = $1
`, [playerId]);
if (result.rows.length === 0) {
console.log(`❌ No subscription found for player ${playerId}`);
return { success: false, reason: 'No subscription' };
}
// Send to all subscriptions for this player
const results = [];
for (const row of result.rows) {
const subscription = {
endpoint: row.endpoint,
keys: {
p256dh: row.p256dh,
auth: row.auth
}
};
const payload = JSON.stringify({
title: title,
body: message,
icon: '/pictures/favicon.ico',
badge: '/pictures/favicon.ico',
data: {
url: '/dashboard.html',
timestamp: Date.now(),
...data
},
actions: [
{
action: 'view',
title: 'Dashboard öffnen'
}
]
});
try {
await webpush.sendNotification(subscription, payload);
results.push({ success: true });
} catch (error) {
console.error(`❌ Error sending to subscription:`, error);
results.push({ success: false, error: error.message });
}
}
const successCount = results.filter(r => r.success).length;
console.log(`✅ Push notification sent to ${successCount}/${result.rows.length} subscriptions for player ${playerId}`);
return {
success: successCount > 0,
sent: successCount,
total: result.rows.length
};
} catch (error) {
console.error(`❌ Error sending push to player ${playerId}:`, error);
return { success: false, error: error.message };
}
}
/**
* Send achievement notification to player
*/
async function sendAchievementNotification(playerId, achievement) {
const title = `🏆 ${achievement.name}`;
const message = achievement.description;
return await sendPushToPlayer(playerId, title, message, {
type: 'achievement',
achievementId: achievement.id,
points: achievement.points
});
}
/**
* Send best time notification to player
*/
async function sendBestTimeNotification(playerId, timeType, locationName, time) {
const title = `🏁 ${timeType} Bestzeit!`;
const message = `Du hast die beste Zeit in ${locationName} mit ${time} erreicht!`;
return await sendPushToPlayer(playerId, title, message, {
type: 'best_time',
timeType: timeType,
location: locationName,
time: time
});
}
/**
* Send daily summary notification to player
*/
async function sendDailySummaryNotification(playerId, summary) {
const title = `📊 Dein Tagesrückblick`;
const message = `Du hattest ${summary.runs} Läufe heute. Beste Zeit: ${summary.bestTime}`;
return await sendPushToPlayer(playerId, title, message, {
type: 'daily_summary',
runs: summary.runs,
bestTime: summary.bestTime
});
}
/**
* Send weekly summary notification to player
*/
async function sendWeeklySummaryNotification(playerId, summary) {
const title = `📈 Deine Wochenzusammenfassung`;
const message = `Diese Woche: ${summary.runs} Läufe, ${summary.improvement}% Verbesserung!`;
return await sendPushToPlayer(playerId, title, message, {
type: 'weekly_summary',
runs: summary.runs,
improvement: summary.improvement
});
}
/**
* Send notification to all logged in players
*/
async function sendNotificationToAllPlayers(title, message, data = {}) {
try {
console.log(`📢 Sending notification to all players: ${title}`);
const result = await pool.query(`
SELECT ps.player_id, ps.endpoint, ps.p256dh, ps.auth, p.firstname, p.lastname
FROM player_subscriptions ps
JOIN players p ON ps.player_id = p.id
WHERE ps.player_id IS NOT NULL
`);
if (result.rows.length === 0) {
console.log('❌ No player subscriptions found');
return { success: false, reason: 'No subscriptions' };
}
const promises = result.rows.map(async (row) => {
const subscription = {
endpoint: row.endpoint,
keys: {
p256dh: row.p256dh,
auth: row.auth
}
};
const payload = JSON.stringify({
title: title,
body: message,
icon: '/pictures/favicon.ico',
badge: '/pictures/favicon.ico',
data: {
url: '/dashboard.html',
timestamp: Date.now(),
...data
}
});
try {
await webpush.sendNotification(subscription, payload);
return {
playerId: row.player_id,
playerName: `${row.firstname} ${row.lastname}`,
success: true
};
} catch (error) {
console.error(`❌ Error sending to player ${row.player_id}:`, error);
return {
playerId: row.player_id,
playerName: `${row.firstname} ${row.lastname}`,
success: false,
error: error.message
};
}
});
const results = await Promise.all(promises);
const successCount = results.filter(r => r.success).length;
console.log(`✅ Sent notifications to ${successCount}/${results.length} players`);
return {
success: true,
sent: successCount,
total: results.length,
results: results
};
} catch (error) {
console.error('❌ Error sending notifications to all players:', error);
return { success: false, error: error.message };
}
}
/**
* Get player statistics for notifications
*/
async function getPlayerStats(playerId) {
try {
const result = await pool.query(`
SELECT
p.firstname,
p.lastname,
COUNT(t.id) as total_runs,
MIN(t.recorded_time) as best_time,
COUNT(DISTINCT t.location_id) as locations_visited
FROM players p
LEFT JOIN times t ON p.id = t.player_id
WHERE p.id = $1
GROUP BY p.id, p.firstname, p.lastname
`, [playerId]);
if (result.rows.length === 0) {
return null;
}
return result.rows[0];
} catch (error) {
console.error('Error getting player stats:', error);
return null;
}
}
/**
* Check if player has push notifications enabled
*/
async function isPlayerPushEnabled(playerId) {
try {
const result = await pool.query(`
SELECT COUNT(*) as count
FROM player_subscriptions
WHERE player_id = $1
`, [playerId]);
return result.rows[0].count > 0;
} catch (error) {
console.error('Error checking push status:', error);
return false;
}
}
module.exports = {
sendPushToPlayer,
sendAchievementNotification,
sendBestTimeNotification,
sendDailySummaryNotification,
sendWeeklySummaryNotification,
sendNotificationToAllPlayers,
getPlayerStats,
isPlayerPushEnabled
};

View File

@@ -0,0 +1,134 @@
#!/usr/bin/env node
/**
* Fix Player Subscriptions Script
*
* This script fixes the player_subscriptions table by:
* 1. Identifying orphaned subscriptions (UUIDs that don't match any player)
* 2. Optionally migrating them to real player IDs
* 3. Cleaning up invalid subscriptions
*/
const { Pool } = require('pg');
// Database connection
const pool = new Pool({
user: process.env.DB_USER || 'postgres',
host: process.env.DB_HOST || 'localhost',
database: process.env.DB_NAME || 'ninjacross',
password: process.env.DB_PASSWORD || 'postgres',
port: process.env.DB_PORT || 5432,
});
async function fixPlayerSubscriptions() {
console.log('🔧 Starting Player Subscriptions Fix...\n');
try {
// 1. Find orphaned subscriptions (UUIDs that don't match any player)
console.log('📊 Step 1: Finding orphaned subscriptions...');
const orphanedQuery = `
SELECT ps.player_id, ps.created_at, ps.endpoint
FROM player_subscriptions ps
LEFT JOIN players p ON ps.player_id = p.id
WHERE p.id IS NULL
`;
const orphanedResult = await pool.query(orphanedQuery);
console.log(`Found ${orphanedResult.rows.length} orphaned subscriptions`);
if (orphanedResult.rows.length > 0) {
console.log('\n📋 Orphaned subscriptions:');
orphanedResult.rows.forEach((sub, index) => {
console.log(` ${index + 1}. Player ID: ${sub.player_id}`);
console.log(` Created: ${sub.created_at}`);
console.log(` Endpoint: ${sub.endpoint.substring(0, 50)}...`);
});
}
// 2. Find players without subscriptions
console.log('\n📊 Step 2: Finding players without subscriptions...');
const playersWithoutSubsQuery = `
SELECT p.id, p.firstname, p.lastname, p.supabase_user_id
FROM players p
LEFT JOIN player_subscriptions ps ON p.id = ps.player_id
WHERE ps.player_id IS NULL
`;
const playersWithoutSubsResult = await pool.query(playersWithoutSubsQuery);
console.log(`Found ${playersWithoutSubsResult.rows.length} players without subscriptions`);
if (playersWithoutSubsResult.rows.length > 0) {
console.log('\n📋 Players without subscriptions:');
playersWithoutSubsResult.rows.forEach((player, index) => {
console.log(` ${index + 1}. ${player.firstname} ${player.lastname} (${player.id})`);
console.log(` Supabase User ID: ${player.supabase_user_id || 'None'}`);
});
}
// 3. Show current subscription statistics
console.log('\n📊 Step 3: Current subscription statistics...');
const statsQuery = `
SELECT
COUNT(*) as total_subscriptions,
COUNT(DISTINCT ps.player_id) as unique_player_ids,
COUNT(p.id) as linked_to_players,
COUNT(*) - COUNT(p.id) as orphaned_count
FROM player_subscriptions ps
LEFT JOIN players p ON ps.player_id = p.id
`;
const statsResult = await pool.query(statsQuery);
const stats = statsResult.rows[0];
console.log(`Total subscriptions: ${stats.total_subscriptions}`);
console.log(`Unique player IDs: ${stats.unique_player_ids}`);
console.log(`Linked to real players: ${stats.linked_to_players}`);
console.log(`Orphaned subscriptions: ${stats.orphaned_count}`);
// 4. Ask user what to do
console.log('\n🔧 Step 4: What would you like to do?');
console.log('1. Clean up orphaned subscriptions (DELETE)');
console.log('2. Keep orphaned subscriptions (no action)');
console.log('3. Show detailed analysis only');
// For now, just show the analysis
console.log('\n✅ Analysis complete. No changes made.');
console.log('\n💡 Recommendations:');
console.log('- Orphaned subscriptions should be cleaned up');
console.log('- New subscriptions will now use real player IDs');
console.log('- Existing valid subscriptions will continue to work');
} catch (error) {
console.error('❌ Error fixing player subscriptions:', error);
} finally {
await pool.end();
}
}
// Clean up orphaned subscriptions
async function cleanupOrphanedSubscriptions() {
console.log('🧹 Cleaning up orphaned subscriptions...');
try {
const deleteQuery = `
DELETE FROM player_subscriptions
WHERE player_id NOT IN (SELECT id FROM players)
`;
const result = await pool.query(deleteQuery);
console.log(`✅ Deleted ${result.rowCount} orphaned subscriptions`);
} catch (error) {
console.error('❌ Error cleaning up subscriptions:', error);
}
}
// Run the script
if (require.main === module) {
fixPlayerSubscriptions();
}
module.exports = {
fixPlayerSubscriptions,
cleanupOrphanedSubscriptions
};

107
scripts/init-db.js Normal file
View File

@@ -0,0 +1,107 @@
// scripts/init-db.js
const { Pool } = require('pg');
const bcrypt = require('bcrypt');
require('dotenv').config();
const pool = new Pool({
host: process.env.DB_HOST,
port: process.env.DB_PORT,
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
ssl: process.env.DB_SSL === 'true' ? { rejectUnauthorized: false } : false
});
async function initDatabase() {
try {
console.log('🚀 Initialisiere Datenbank...');
// Verbindung testen
await pool.query('SELECT NOW()');
console.log('✅ Datenbankverbindung erfolgreich');
// Adminusers Tabelle erstellen
await pool.query(`
CREATE TABLE IF NOT EXISTS adminusers (
id SERIAL PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_login TIMESTAMP
)
`);
console.log('✅ adminusers Tabelle erstellt/überprüft');
// API Tokens Tabelle erstellen
await pool.query(`
CREATE TABLE IF NOT EXISTS api_tokens (
id SERIAL PRIMARY KEY,
token VARCHAR(255) UNIQUE NOT NULL,
description TEXT,
standorte TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP,
is_active BOOLEAN DEFAULT true
)
`);
console.log('✅ api_tokens Tabelle erstellt/überprüft');
// Locations Tabelle erstellen
await pool.query(`
CREATE TABLE IF NOT EXISTS locations (
id SERIAL PRIMARY KEY,
name VARCHAR(255) UNIQUE NOT NULL,
latitude DECIMAL(10, 8) NOT NULL,
longitude DECIMAL(11, 8) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
console.log('✅ locations Tabelle erstellt/überprüft');
// Standardbenutzer erstellen (falls nicht vorhanden)
const existingUser = await pool.query(
'SELECT id FROM adminusers WHERE username = $1',
['admin']
);
if (existingUser.rows.length === 0) {
const passwordHash = await bcrypt.hash('admin123', 10);
await pool.query(
'INSERT INTO adminusers (username, password_hash) VALUES ($1, $2)',
['admin', passwordHash]
);
console.log('✅ Standardbenutzer "admin" mit Passwort "admin123" erstellt');
} else {
console.log(' Standardbenutzer "admin" existiert bereits');
}
// Index für bessere Performance
await pool.query(`
CREATE INDEX IF NOT EXISTS idx_adminusers_username ON adminusers(username);
CREATE INDEX IF NOT EXISTS idx_adminusers_active ON adminusers(is_active);
CREATE INDEX IF NOT EXISTS idx_api_tokens_token ON api_tokens(token);
CREATE INDEX IF NOT EXISTS idx_api_tokens_active ON api_tokens(is_active);
CREATE INDEX IF NOT EXISTS idx_locations_name ON locations(name);
CREATE INDEX IF NOT EXISTS idx_locations_coords ON locations(latitude, longitude);
`);
console.log('✅ Indizes erstellt/überprüft');
console.log('🎉 Datenbank erfolgreich initialisiert!');
console.log('📝 Standardanmeldung: admin / admin123');
console.log('⚠️ Ändern Sie das Standardpasswort in der Produktion!');
} catch (error) {
console.error('❌ Fehler bei der Datenbankinitialisierung:', error);
process.exit(1);
} finally {
await pool.end();
}
}
// Skript ausführen wenn direkt aufgerufen
if (require.main === module) {
initDatabase();
}
module.exports = { initDatabase };

View File

@@ -0,0 +1,129 @@
const { Pool } = require('pg');
const webpush = require('web-push');
// Database connection
const pool = new Pool({
user: 'postgres',
host: 'localhost',
database: 'ninjacross',
password: 'postgres',
port: 5432,
});
// VAPID Keys (generate with: webpush.generateVAPIDKeys())
const vapidKeys = {
publicKey: 'BEl62iUYgUivxIkv69yViEuiBIa40HI6F2B5L4h7Q8Y',
privateKey: 'your-private-key-here'
};
webpush.setVapidDetails(
'mailto:admin@ninjacross.es',
vapidKeys.publicKey,
vapidKeys.privateKey
);
// Store subscription endpoint for each player
async function storePlayerSubscription(playerId, subscription) {
try {
await pool.query(`
INSERT INTO player_subscriptions (player_id, endpoint, p256dh, auth)
VALUES ($1, $2, $3, $4)
`, [
playerId,
subscription.endpoint,
subscription.keys.p256dh,
subscription.keys.auth
]);
console.log(`✅ Subscription stored for player ${playerId}`);
} catch (error) {
console.error('Error storing subscription:', error);
}
}
// Send push notification to player
async function sendPushNotification(playerId, title, message, icon = '🏆') {
try {
const result = await pool.query(`
SELECT endpoint, p256dh, auth
FROM player_subscriptions
WHERE player_id = $1
`, [playerId]);
if (result.rows.length === 0) {
console.log(`No subscription found for player ${playerId}`);
return;
}
const subscription = {
endpoint: result.rows[0].endpoint,
keys: {
p256dh: result.rows[0].p256dh,
auth: result.rows[0].auth
}
};
const payload = JSON.stringify({
title: title,
body: message,
icon: '/pictures/favicon.ico',
badge: '/pictures/favicon.ico',
data: {
url: '/dashboard.html'
}
});
await webpush.sendNotification(subscription, payload);
console.log(`✅ Push notification sent to player ${playerId}`);
} catch (error) {
console.error('Error sending push notification:', error);
}
}
// Send best time notifications
async function sendBestTimeNotifications() {
try {
console.log('🔔 Sending best time notifications...');
// Get daily best
const dailyResult = await pool.query(`
WITH daily_best AS (
SELECT
t.player_id,
MIN(t.recorded_time) as best_time,
CONCAT(p.firstname, ' ', p.lastname) as player_name
FROM times t
JOIN players p ON t.player_id = p.id
WHERE DATE(t.created_at AT TIME ZONE 'Europe/Berlin') = CURRENT_DATE
GROUP BY t.player_id, p.firstname, p.lastname
)
SELECT
player_id,
player_name,
best_time
FROM daily_best
WHERE best_time = (SELECT MIN(best_time) FROM daily_best)
LIMIT 1
`);
if (dailyResult.rows.length > 0) {
const daily = dailyResult.rows[0];
await sendPushNotification(
daily.player_id,
'🏆 Tageskönig!',
`Glückwunsch ${daily.player_name}! Du hast die beste Zeit des Tages mit ${daily.best_time} erreicht!`
);
}
console.log('✅ Best time notifications sent');
} catch (error) {
console.error('❌ Error sending best time notifications:', error);
}
}
module.exports = {
storePlayerSubscription,
sendPushNotification,
sendBestTimeNotifications
};

87
scripts/setup-players.js Normal file
View File

@@ -0,0 +1,87 @@
// scripts/setup-players.js
// Script to set up players table and fix the player name issue
const { Pool } = require('pg');
require('dotenv').config();
const pool = new Pool({
host: process.env.DB_HOST,
port: process.env.DB_PORT,
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
ssl: process.env.DB_SSL === 'true' ? { rejectUnauthorized: false } : false
});
async function setupPlayers() {
try {
console.log('🚀 Setting up players table and views...');
// Test connection
await pool.query('SELECT NOW()');
console.log('✅ Database connection successful');
// Check if players table exists and has the expected structure
const tableCheck = await pool.query(`
SELECT column_name, data_type
FROM information_schema.columns
WHERE table_name = 'players'
`);
if (tableCheck.rows.length === 0) {
console.log('❌ Players table not found. Please create it first with the structure:');
console.log(' - id (UUID)');
console.log(' - firstname (VARCHAR)');
console.log(' - lastname (VARCHAR)');
console.log(' - birthdate (DATE)');
console.log(' - created_at (TIMESTAMP)');
console.log(' - rfiduid (VARCHAR)');
process.exit(1);
}
console.log('✅ Players table structure verified');
console.log('📋 Available columns:', tableCheck.rows.map(r => r.column_name).join(', '));
// Create the updated view using your actual table structure
await pool.query(`
CREATE OR REPLACE VIEW "GetTimesWithPlayerAndLocation" AS
SELECT
gt.*,
l.name as location_name,
l.latitude,
l.longitude,
COALESCE(CONCAT(p.firstname, ' ', p.lastname), 'Unknown Player') as player_name
FROM "gettimes" gt
JOIN locations l ON gt.location_id = l.id
LEFT JOIN players p ON gt.player_id = p.id
`);
console.log('✅ Updated view created');
// Test the view
const testResult = await pool.query('SELECT COUNT(*) as count FROM "GetTimesWithPlayerAndLocation"');
console.log(`✅ View test successful: ${testResult.rows[0].count} records found`);
// Show sample data
const sampleData = await pool.query('SELECT player_name, location_name, recorded_time FROM "GetTimesWithPlayerAndLocation" LIMIT 3');
console.log('📊 Sample data from view:');
sampleData.rows.forEach((row, index) => {
console.log(` ${index + 1}. ${row.player_name} at ${row.location_name}: ${row.recorded_time}`);
});
console.log('\n🎉 Setup completed successfully!');
console.log('📝 Your index.html should now display player names instead of IDs');
console.log('🔄 Restart your server to use the new view');
} catch (error) {
console.error('❌ Error during setup:', error);
process.exit(1);
} finally {
await pool.end();
}
}
// Run if called directly
if (require.main === module) {
setupPlayers();
}
module.exports = { setupPlayers };

108
scripts/setup_cron.js Normal file
View File

@@ -0,0 +1,108 @@
const { exec } = require('child_process');
const path = require('path');
// Cron job setup for achievements
const cronJobs = [
{
name: 'daily_achievements',
// Run daily at 19:00 for best-time achievements
schedule: '0 19 * * *',
command: `cd ${__dirname} && node best_time_achievements.js >> /var/log/ninjaserver_achievements.log 2>&1`,
description: 'Daily best-time achievement check at 19:00'
},
{
name: 'weekly_achievements',
// Run every Sunday at 19:00 for weekly best-time achievements
schedule: '0 19 * * 0',
command: `cd ${__dirname} && node best_time_achievements.js >> /var/log/ninjaserver_achievements.log 2>&1`,
description: 'Weekly best-time achievement check on Sunday at 19:00'
},
{
name: 'monthly_achievements',
// Run on last day of month at 19:00 for monthly best-time achievements
schedule: '0 19 28-31 * * [ $(date -d tomorrow +\\%d) -eq 1 ]',
command: `cd ${__dirname} && node best_time_achievements.js >> /var/log/ninjaserver_achievements.log 2>&1`,
description: 'Monthly best-time achievement check on last day of month at 19:00'
}
];
function setupCronJobs() {
console.log('🕐 Setting up best-time achievement cron jobs...');
let cronEntries = [];
// Create cron job entries
cronJobs.forEach(job => {
const cronEntry = `${job.schedule} ${job.command}`;
cronEntries.push(cronEntry);
console.log(`📅 ${job.name}: ${job.schedule} - ${job.description}`);
});
// Add all cron jobs to crontab
const allCronEntries = cronEntries.join('\n');
exec(`(crontab -l 2>/dev/null; echo "${allCronEntries}") | crontab -`, (error, stdout, stderr) => {
if (error) {
console.error('❌ Error setting up cron jobs:', error);
return;
}
if (stderr) {
console.error('⚠️ Cron job warning:', stderr);
}
console.log('✅ All cron jobs setup successfully!');
console.log('📝 Logs will be written to: /var/log/ninjaserver_achievements.log');
// Show current crontab
exec('crontab -l', (error, stdout, stderr) => {
if (!error) {
console.log('\n📋 Current crontab:');
console.log(stdout);
}
});
});
}
function removeCronJobs() {
console.log('🗑️ Removing best-time achievement cron jobs...');
exec('crontab -l | grep -v "best_time_achievements.js" | crontab -', (error, stdout, stderr) => {
if (error) {
console.error('❌ Error removing cron jobs:', error);
return;
}
console.log('✅ All cron jobs removed successfully!');
});
}
// Command line interface
if (require.main === module) {
const command = process.argv[2];
switch (command) {
case 'setup':
setupCronJobs();
break;
case 'remove':
removeCronJobs();
break;
case 'status':
exec('crontab -l | grep best_time_achievements', (error, stdout, stderr) => {
if (stdout) {
console.log('✅ Best-time achievement cron jobs are active:');
console.log(stdout);
} else {
console.log('❌ No best-time achievement cron jobs found');
}
});
break;
default:
console.log('Usage: node setup_cron.js [setup|remove|status]');
console.log(' setup - Add best-time achievement cron jobs (daily 19:00, Sunday 19:00, last day of month 19:00)');
console.log(' remove - Remove all best-time achievement cron jobs');
console.log(' status - Check if best-time achievement cron jobs are active');
}
}
module.exports = { setupCronJobs, removeCronJobs };

View File

@@ -0,0 +1,94 @@
/**
* Simuliert das Aufzeichnen einer neuen Zeit und testet sofortige Achievements
*
* Dieses Script simuliert, was passiert, wenn ein Spieler eine neue Zeit aufzeichnet
*/
const { Pool } = require('pg');
const AchievementSystem = require('../lib/achievementSystem');
require('dotenv').config();
// Database connection
const pool = new Pool({
host: process.env.DB_HOST,
port: process.env.DB_PORT,
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
ssl: process.env.DB_SSL === 'true' ? { rejectUnauthorized: false } : false
});
async function simulateNewTime() {
console.log('🎮 Simuliere neue Zeit-Aufzeichnung...\n');
try {
// Hole einen Test-Spieler
const playerResult = await pool.query(`
SELECT p.id, p.firstname, p.lastname, p.rfiduid
FROM players p
WHERE p.rfiduid IS NOT NULL
LIMIT 1
`);
if (playerResult.rows.length === 0) {
console.log('❌ Kein Spieler mit RFID gefunden');
return;
}
const player = playerResult.rows[0];
console.log(`👤 Teste mit Spieler: ${player.firstname} ${player.lastname} (${player.rfiduid})`);
// Hole eine Test-Location
const locationResult = await pool.query(`
SELECT id, name FROM locations LIMIT 1
`);
if (locationResult.rows.length === 0) {
console.log('❌ Keine Location gefunden');
return;
}
const location = locationResult.rows[0];
console.log(`📍 Teste mit Location: ${location.name}`);
// Simuliere eine neue Zeit (etwas langsamer als die beste Zeit)
const testTime = '00:01:30.500'; // 1:30.500
console.log(`⏱️ Simuliere Zeit: ${testTime}`);
// Füge die Zeit zur Datenbank hinzu
const timeResult = await pool.query(
`INSERT INTO times (player_id, location_id, recorded_time, created_at)
VALUES ($1, $2, $3, $4)
RETURNING id, player_id, created_at`,
[player.id, location.id, testTime, new Date()]
);
console.log(`✅ Zeit erfolgreich gespeichert (ID: ${timeResult.rows[0].id})`);
// Teste sofortige Achievements
console.log('\n🏆 Prüfe sofortige Achievements...');
const achievementSystem = new AchievementSystem();
await achievementSystem.loadAchievements();
const newAchievements = await achievementSystem.checkImmediateAchievements(player.id);
if (newAchievements.length > 0) {
console.log(`\n🎉 ${newAchievements.length} neue Achievements vergeben:`);
newAchievements.forEach(achievement => {
console.log(` ${achievement.icon} ${achievement.name} (+${achievement.points} Punkte)`);
});
} else {
console.log('\n Keine neuen Achievements vergeben');
}
console.log('\n✅ Simulation erfolgreich abgeschlossen!');
} catch (error) {
console.error('❌ Simulation fehlgeschlagen:', error);
} finally {
await pool.end();
}
}
// Führe Simulation aus
simulateNewTime();

View File

@@ -0,0 +1,50 @@
/**
* Test Script für das JavaScript Achievement System
*
* Testet die Achievement-Logik ohne auf 19:00 Uhr zu warten
*/
const AchievementSystem = require('../lib/achievementSystem');
require('dotenv').config();
async function testAchievementSystem() {
console.log('🧪 Starte Achievement-System Test...\n');
try {
const achievementSystem = new AchievementSystem();
// Lade Achievements
console.log('📋 Lade Achievements...');
await achievementSystem.loadAchievements();
// Führe tägliche Achievement-Prüfung durch
console.log('\n🎯 Führe tägliche Achievement-Prüfung durch...');
const result = await achievementSystem.runDailyAchievementCheck();
// Zeige Ergebnisse
console.log('\n📊 Test-Ergebnisse:');
console.log(` 🏆 ${result.totalNewAchievements} neue Achievements vergeben`);
console.log(` 👥 ${result.playerAchievements.length} Spieler haben neue Achievements erhalten`);
if (result.playerAchievements.length > 0) {
console.log('\n📋 Neue Achievements im Detail:');
result.playerAchievements.forEach(player => {
console.log(` 👤 ${player.player}:`);
player.achievements.forEach(achievement => {
console.log(` ${achievement.icon} ${achievement.name} (+${achievement.points} Punkte)`);
});
});
} else {
console.log('\n Keine neuen Achievements vergeben');
}
console.log('\n✅ Test erfolgreich abgeschlossen!');
} catch (error) {
console.error('❌ Test fehlgeschlagen:', error);
process.exit(1);
}
}
// Führe Test aus
testAchievementSystem();

View File

@@ -0,0 +1,48 @@
/**
* Test Script für sofortige Achievements
*
* Testet die sofortige Achievement-Logik für einen einzelnen Spieler
*/
const AchievementSystem = require('../lib/achievementSystem');
require('dotenv').config();
async function testImmediateAchievements() {
console.log('⚡ Starte sofortige Achievement-Test...\n');
try {
const achievementSystem = new AchievementSystem();
// Lade Achievements
console.log('📋 Lade Achievements...');
await achievementSystem.loadAchievements();
// Teste mit einem spezifischen Spieler (Carsten Graf)
const testPlayerId = '313ceee3-8040-44b4-98d2-e63703579e5d';
console.log(`\n🎯 Teste sofortige Achievements für Spieler ${testPlayerId}...`);
const newAchievements = await achievementSystem.checkImmediateAchievements(testPlayerId);
// Zeige Ergebnisse
console.log('\n📊 Sofortige Achievement-Test Ergebnisse:');
console.log(` 🏆 ${newAchievements.length} neue sofortige Achievements vergeben`);
if (newAchievements.length > 0) {
console.log('\n📋 Neue sofortige Achievements:');
newAchievements.forEach(achievement => {
console.log(` ${achievement.icon} ${achievement.name} (+${achievement.points} Punkte)`);
});
} else {
console.log('\n Keine neuen sofortigen Achievements vergeben');
}
console.log('\n✅ Sofortige Achievement-Test erfolgreich abgeschlossen!');
} catch (error) {
console.error('❌ Test fehlgeschlagen:', error);
process.exit(1);
}
}
// Führe Test aus
testImmediateAchievements();

View File

@@ -0,0 +1,75 @@
/**
* Test Script für mehrfache Achievements
*
* Demonstriert, wie Achievements mehrmals erreicht werden können
* und wie die Gesamtpunkte berechnet werden
*/
const AchievementSystem = require('../lib/achievementSystem');
require('dotenv').config();
async function testMultipleAchievements() {
console.log('🔄 Teste mehrfache Achievements...\n');
try {
const achievementSystem = new AchievementSystem();
// Lade Achievements
console.log('📋 Lade Achievements...');
await achievementSystem.loadAchievements();
// Teste mit Carsten Graf
const testPlayerId = '313ceee3-8040-44b4-98d2-e63703579e5d';
console.log(`\n👤 Teste mit Spieler: ${testPlayerId}`);
// Zeige aktuelle Punkte
console.log('\n📊 Aktuelle Gesamtpunkte:');
const currentPoints = await achievementSystem.getPlayerTotalPoints(testPlayerId);
console.log(` 🏆 Gesamtpunkte: ${currentPoints.totalPoints}`);
console.log(` 🔢 Gesamt-Completions: ${currentPoints.totalCompletions}`);
// Führe Achievement-Check durch
console.log('\n🎯 Führe Achievement-Check durch...');
const newAchievements = await achievementSystem.checkImmediateAchievements(testPlayerId);
if (newAchievements.length > 0) {
console.log(`\n🏆 ${newAchievements.length} neue Achievements vergeben:`);
newAchievements.forEach(achievement => {
console.log(` ${achievement.icon} ${achievement.name} (+${achievement.points} Punkte)`);
});
} else {
console.log('\n Keine neuen Achievements vergeben');
}
// Zeige neue Gesamtpunkte
console.log('\n📊 Neue Gesamtpunkte:');
const newPoints = await achievementSystem.getPlayerTotalPoints(testPlayerId);
console.log(` 🏆 Gesamtpunkte: ${newPoints.totalPoints} (${newPoints.totalPoints - currentPoints.totalPoints > 0 ? '+' : ''}${newPoints.totalPoints - currentPoints.totalPoints})`);
console.log(` 🔢 Gesamt-Completions: ${newPoints.totalCompletions} (${newPoints.totalCompletions - currentPoints.totalCompletions > 0 ? '+' : ''}${newPoints.totalCompletions - currentPoints.totalCompletions})`);
// Zeige alle Achievements mit Completions
console.log('\n📋 Alle Achievements mit Completions:');
await achievementSystem.loadPlayerAchievements(testPlayerId);
const playerAchievements = achievementSystem.playerAchievements.get(testPlayerId);
if (playerAchievements && playerAchievements.size > 0) {
for (const [achievementId, data] of playerAchievements) {
const achievement = Array.from(achievementSystem.achievements.values())
.find(a => a.id === achievementId);
if (achievement) {
console.log(` ${achievement.icon} ${achievement.name}: ${data.completion_count}x (${data.completion_count * achievement.points} Punkte)`);
}
}
}
console.log('\n✅ Mehrfache Achievement-Test erfolgreich abgeschlossen!');
} catch (error) {
console.error('❌ Test fehlgeschlagen:', error);
process.exit(1);
}
}
// Führe Test aus
testMultipleAchievements();

380
server.js Normal file
View File

@@ -0,0 +1,380 @@
/**
* NinjaCross Leaderboard Server
*
* Hauptserver für das NinjaCross Timer-System mit:
* - Express.js Web-Server
* - Socket.IO für Real-time Updates
* - PostgreSQL Datenbankanbindung
* - API-Key Authentifizierung
* - Session-basierte Web-Authentifizierung
*
* @author NinjaCross Team
* @version 1.0.0
*/
// ============================================================================
// DEPENDENCIES & IMPORTS
// ============================================================================
const express = require('express');
const path = require('path');
const session = require('express-session');
const { createServer } = require('http');
const { Server } = require('socket.io');
const swaggerUi = require('swagger-ui-express');
const swaggerSpecs = require('./swagger');
const cron = require('node-cron');
require('dotenv').config();
// Route Imports
const { router: apiRoutes, requireApiKey } = require('./routes/api');
// Achievement System
const AchievementSystem = require('./lib/achievementSystem');
// ============================================================================
// SERVER CONFIGURATION
// ============================================================================
const app = express();
const server = createServer(app);
const port = process.env.PORT || 3000;
// Socket.IO Configuration
const io = new Server(server, {
cors: {
origin: "*",
methods: ["GET", "POST"]
}
});
// ============================================================================
// MIDDLEWARE SETUP
// ============================================================================
// CORS Configuration - Allow all origins for development
app.use((req, res, next) => {
// Allow specific origins when credentials are needed
const origin = req.headers.origin;
if (origin && (origin.includes('ninja.reptilfpv.de') || origin.includes('localhost') || origin.includes('127.0.0.1'))) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true'); // Allow cookies
} else {
res.setHeader('Access-Control-Allow-Origin', '*');
}
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS, PATCH');
res.setHeader('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization, X-API-Key');
res.setHeader('Access-Control-Max-Age', '86400'); // 24 hours
// Handle preflight requests
if (req.method === 'OPTIONS') {
res.status(200).end();
return;
}
next();
});
// Body Parser Middleware
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
// Session Configuration
app.use(session({
secret: process.env.SESSION_SECRET || 'kjhdizr3lhwho8fpjslgf825ß0hsd',
resave: false,
saveUninitialized: false,
cookie: {
secure: false, // Set to true when using HTTPS
maxAge: 24 * 60 * 60 * 1000, // 24 hours
httpOnly: true, // Security: prevent XSS attacks
sameSite: 'lax' // Allow cookies in cross-origin requests
}
}));
// ============================================================================
// AUTHENTICATION MIDDLEWARE
// ============================================================================
/**
* Web Interface Authentication Middleware
* Überprüft ob der Benutzer für das Web-Interface authentifiziert ist
*/
function requireWebAuth(req, res, next) {
if (req.session.userId) {
next();
} else {
res.redirect('/login');
}
}
// ============================================================================
// ROUTE SETUP
// ============================================================================
// Swagger API Documentation
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpecs, {
customCss: '.swagger-ui .topbar { display: none }',
customSiteTitle: 'Ninja Cross Parkour API Documentation'
}));
// Unified API Routes (all under /api/v1/)
// - /api/v1/public/* - Public routes (no authentication)
// - /api/v1/private/* - API-Key protected routes
// - /api/v1/web/* - Session protected routes
// - /api/v1/admin/* - Admin protected routes
app.use('/api', apiRoutes);
// ============================================================================
// WEB INTERFACE ROUTES
// ============================================================================
/**
* Public Landing Page - NinjaCross Leaderboard
* Hauptseite mit dem öffentlichen Leaderboard
*/
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
/**
* Admin Dashboard Page
* Hauptdashboard für Admin-Benutzer
*/
app.get('/admin-dashboard', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'admin-dashboard.html'));
});
/**
* Admin Generator Page
* Geschützte Seite für die Lizenz-Generierung (Level 2 Zugriff erforderlich)
*/
app.get('/generator', requireWebAuth, (req, res) => {
// Prüfe Zugriffslevel für Generator
if (req.session.accessLevel < 2) {
return res.status(403).send(`
<h1>Zugriff verweigert</h1>
<p>Sie benötigen Level 2 Zugriff für den Lizenzgenerator.</p>
<a href="/admin-dashboard">Zurück zum Dashboard</a>
`);
}
res.sendFile(path.join(__dirname, 'public', 'generator.html'));
});
/**
* Login Page
* Authentifizierungsseite für Admin-Benutzer
*/
app.get('/login', (req, res) => {
// Redirect to main page if already authenticated
if (req.session.userId) {
return res.redirect('/');
}
res.sendFile(path.join(__dirname, 'public', 'login.html'));
});
/**
* Admin Dashboard Page
* Dashboard-Seite für eingeloggte Administratoren
* Authentifizierung wird client-side über Supabase gehandhabt
*/
app.get('/dashboard', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'dashboard.html'));
});
/**
* Reset Password Page
* Seite für das Zurücksetzen von Passwörtern über Supabase
* Wird von Supabase E-Mail-Links aufgerufen
*/
app.get('/reset-password.html', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'reset-password.html'));
});
/**
* Admin Login Page
* Lizenzgenerator Login-Seite für Admin-Benutzer
*/
app.get('/adminlogin.html', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'adminlogin.html'));
});
/**
* OAuth Callback Route
* Handles OAuth redirects from Supabase (Google, etc.)
*/
app.get('/auth/callback', (req, res) => {
// Redirect to the main page after OAuth callback
// Supabase handles the OAuth flow and redirects here
res.redirect('/');
});
// ============================================================================
// STATIC FILE SERVING
// ============================================================================
// Serve static files directly from public directory
app.use(express.static('public'));
// Serve static files for public pages (CSS, JS, images) - legacy route
app.use('/public', express.static('public'));
// Serve static files for login page
app.use('/login', express.static('public'));
// ============================================================================
// WEBSOCKET CONFIGURATION
// ============================================================================
/**
* WebSocket Connection Handler
* Verwaltet Real-time Verbindungen für Live-Updates
*/
io.on('connection', (socket) => {
// Client connected - connection is established
socket.on('disconnect', () => {
// Client disconnected - cleanup if needed
});
});
// Make Socket.IO instance available to other modules
app.set('io', io);
// ============================================================================
// ERROR HANDLING
// ============================================================================
// 404 Handler
app.use('*', (req, res) => {
// Check if it's an API request
if (req.originalUrl.startsWith('/api/')) {
res.status(404).json({
success: false,
message: 'Route not found',
path: req.originalUrl
});
} else {
// Serve custom 404 page for non-API requests
res.status(404).sendFile(path.join(__dirname, 'public', '404.html'));
}
});
// Global Error Handler
app.use((err, req, res, next) => {
console.error('Server Error:', err);
res.status(500).json({
success: false,
message: 'Internal server error'
});
});
// ============================================================================
// SERVER STARTUP
// ============================================================================
/**
* Start the server and initialize all services
*/
server.listen(port, () => {
console.log(`🚀 Server läuft auf http://ninja.reptilfpv.de:${port}`);
console.log(`📊 Datenbank: ${process.env.DB_HOST}:${process.env.DB_PORT}/${process.env.DB_NAME}`);
console.log(`🔐 API-Key Authentifizierung aktiviert`);
console.log(`🔌 WebSocket-Server aktiviert`);
console.log(`📁 Static files: /public`);
console.log(`🌐 Unified API: /api/v1/`);
console.log(` 📖 Public: /api/v1/public/`);
console.log(` 🔒 Private: /api/v1/private/`);
console.log(` 🔐 Web: /api/v1/web/`);
console.log(` 👑 Admin: /api/v1/admin/`);
});
// ============================================================================
// SCHEDULED TASKS
// ============================================================================
/**
* Scheduled Function - Runs daily at 7 PM (19:00)
* Führt tägliche Achievement-Prüfung durch
*/
async function scheduledTaskAt7PM() {
const now = new Date();
console.log(`⏰ Geplante Aufgabe ausgeführt um ${now.toLocaleString('de-DE')}`);
try {
// Initialisiere Achievement-System
const achievementSystem = new AchievementSystem();
// Führe tägliche Achievement-Prüfung durch
const result = await achievementSystem.runDailyAchievementCheck();
console.log(`🏆 Achievement-Prüfung abgeschlossen:`);
console.log(` 📊 ${result.totalNewAchievements} neue Achievements vergeben`);
console.log(` 👥 ${result.playerAchievements.length} Spieler haben neue Achievements erhalten`);
// Zeige Details der neuen Achievements
if (result.playerAchievements.length > 0) {
console.log(`\n📋 Neue Achievements im Detail:`);
result.playerAchievements.forEach(player => {
console.log(` 👤 ${player.player}:`);
player.achievements.forEach(achievement => {
console.log(` ${achievement.icon} ${achievement.name} (+${achievement.points} Punkte)`);
});
});
}
console.log('✅ Geplante Aufgabe erfolgreich abgeschlossen');
} catch (error) {
console.error('❌ Fehler bei der geplanten Aufgabe:', error);
}
}
// Cron Job: Täglich um 19:00 Uhr (7 PM)
// Format: Sekunde Minute Stunde Tag Monat Wochentag
cron.schedule('0 0 19 * * *', scheduledTaskAt7PM, {
scheduled: true,
timezone: "Europe/Berlin" // Deutsche Zeitzone
});
console.log('📅 Geplante Aufgabe eingerichtet: Täglich um 19:00 Uhr');
// ============================================================================
// GRACEFUL SHUTDOWN
// ============================================================================
/**
* Handle graceful shutdown on SIGINT (Ctrl+C)
*/
process.on('SIGINT', async () => {
console.log('\n🛑 Server wird heruntergefahren...');
// Close server gracefully
server.close(() => {
console.log('✅ Server erfolgreich heruntergefahren');
process.exit(0);
});
// Force exit after 5 seconds if graceful shutdown fails
setTimeout(() => {
console.log('⚠️ Forced shutdown after timeout');
process.exit(1);
}, 5000);
});
/**
* Handle uncaught exceptions
*/
process.on('uncaughtException', (err) => {
console.error('Uncaught Exception:', err);
process.exit(1);
});
/**
* Handle unhandled promise rejections
*/
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
process.exit(1);
});

89
ssl-setup-guide.md Normal file
View File

@@ -0,0 +1,89 @@
# SSL Setup für NinjaCross Server
## 1. Let's Encrypt SSL Zertifikat installieren
```bash
# Certbot installieren (Ubuntu/Debian)
sudo apt update
sudo apt install certbot python3-certbot-apache
# SSL Zertifikat anfordern
sudo certbot certonly --standalone -d ninja.reptilfpv.de
# Automatische Erneuerung einrichten
sudo crontab -e
# Füge hinzu: 0 12 * * * /usr/bin/certbot renew --quiet
```
## 2. Apache Module aktivieren
```bash
# Notwendige Apache Module aktivieren
sudo a2enmod ssl
sudo a2enmod proxy
sudo a2enmod proxy_http
sudo a2enmod proxy_wstunnel
sudo a2enmod headers
sudo a2enmod rewrite
# Apache neustarten
sudo systemctl restart apache2
```
## 3. VirtualHost konfigurieren
```bash
# SSL Konfiguration kopieren
sudo cp apache-ssl-config.conf /etc/apache2/sites-available/ninjaserver-ssl.conf
# Site aktivieren
sudo a2ensite ninjaserver-ssl.conf
# Standard-Site deaktivieren (optional)
sudo a2dissite 000-default.conf
# Apache Konfiguration testen
sudo apache2ctl configtest
# Apache neuladen
sudo systemctl reload apache2
```
## 4. Node.js Server anpassen (optional)
Ihr Node.js Server läuft weiterhin auf Port 3000 (localhost only).
Optional können Sie den Server auf localhost binden:
```javascript
// In server.js
server.listen(port, 'localhost', () => {
console.log(`🚀 Server läuft auf http://localhost:${port}`);
console.log(`🔒 HTTPS verfügbar über Apache Proxy`);
});
```
## 5. Firewall anpassen
```bash
# HTTPS Port öffnen
sudo ufw allow 443/tcp
sudo ufw allow 80/tcp
# Port 3000 nur für localhost (Sicherheit)
sudo ufw deny 3000
```
## 6. Testen
1. Öffne https://ninja.reptilfpv.de
2. Teste QR-Scanner → Kamera sollte funktionieren
3. Prüfe SSL-Rating: https://www.ssllabs.com/ssltest/
## Vorteile dieser Lösung:
**Automatische SSL-Erneuerung** mit Let's Encrypt
**Hohe Performance** durch Apache SSL-Terminierung
**Security Headers** für zusätzlichen Schutz
**WebSocket Support** für Live-Updates
**HTTP → HTTPS Redirect** automatisch
**Kamera-Zugriff** funktioniert in allen Browsern

285
swagger.js Normal file
View File

@@ -0,0 +1,285 @@
const swaggerJsdoc = require('swagger-jsdoc');
const options = {
definition: {
openapi: '3.0.0',
info: {
title: 'Ninja Cross Parkour API',
version: '1.0.0',
description: 'API für das Ninja Cross Parkour System im Schwimmbad',
contact: {
name: 'Ninja Cross Parkour',
email: 'admin@ninjacross.com'
},
license: {
name: 'MIT',
url: 'https://opensource.org/licenses/MIT'
}
},
servers: [
{
url: 'https://ninja.reptilfpv.de',
description: 'Production server'
}
],
components: {
securitySchemes: {
ApiKeyAuth: {
type: 'apiKey',
in: 'header',
name: 'X-API-Key',
description: 'API Key für Authentifizierung'
},
BearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'JWT Token für Supabase Authentifizierung'
}
},
schemas: {
Player: {
type: 'object',
properties: {
id: {
type: 'string',
format: 'uuid',
description: 'Eindeutige Spieler-ID'
},
firstname: {
type: 'string',
description: 'Vorname des Spielers'
},
lastname: {
type: 'string',
description: 'Nachname des Spielers'
},
birthdate: {
type: 'string',
format: 'date',
description: 'Geburtsdatum des Spielers'
},
rfiduid: {
type: 'string',
description: 'RFID UID der Karte (Format: XX:XX:XX:XX)'
},
supabase_user_id: {
type: 'string',
format: 'uuid',
description: 'Supabase User ID für Verknüpfung'
},
created_at: {
type: 'string',
format: 'date-time',
description: 'Erstellungsdatum'
}
}
},
Time: {
type: 'object',
properties: {
id: {
type: 'string',
format: 'uuid',
description: 'Eindeutige Zeit-ID'
},
player_id: {
type: 'string',
format: 'uuid',
description: 'ID des Spielers'
},
location_id: {
type: 'string',
format: 'uuid',
description: 'ID des Standorts'
},
recorded_time: {
type: 'object',
description: 'Aufgezeichnete Zeit als Intervall',
properties: {
seconds: { type: 'number' },
minutes: { type: 'number' },
milliseconds: { type: 'number' }
}
},
created_at: {
type: 'string',
format: 'date-time',
description: 'Erstellungsdatum'
}
}
},
Location: {
type: 'object',
properties: {
id: {
type: 'string',
format: 'uuid',
description: 'Eindeutige Standort-ID'
},
name: {
type: 'string',
description: 'Name des Standorts'
},
latitude: {
type: 'number',
format: 'float',
description: 'Breitengrad'
},
longitude: {
type: 'number',
format: 'float',
description: 'Längengrad'
},
time_threshold: {
type: 'object',
description: 'Zeitschwelle für den Standort',
properties: {
seconds: { type: 'number' },
minutes: { type: 'number' }
}
},
created_at: {
type: 'string',
format: 'date-time',
description: 'Erstellungsdatum'
}
}
},
Achievement: {
type: 'object',
properties: {
id: {
type: 'string',
format: 'uuid',
description: 'Eindeutige Achievement-ID'
},
name: {
type: 'string',
description: 'Name des Achievements'
},
description: {
type: 'string',
description: 'Beschreibung des Achievements'
},
category: {
type: 'string',
enum: ['consistency', 'improvement', 'seasonal', 'monthly'],
description: 'Kategorie des Achievements'
},
condition_type: {
type: 'string',
description: 'Typ der Bedingung'
},
condition_value: {
type: 'integer',
description: 'Wert der Bedingung'
},
icon: {
type: 'string',
description: 'Emoji-Icon für das Achievement'
},
points: {
type: 'integer',
description: 'Punkte für das Achievement'
},
is_active: {
type: 'boolean',
description: 'Ob das Achievement aktiv ist'
}
}
},
PlayerAchievement: {
type: 'object',
properties: {
id: {
type: 'string',
format: 'uuid',
description: 'Eindeutige Player-Achievement-ID'
},
player_id: {
type: 'string',
format: 'uuid',
description: 'ID des Spielers'
},
achievement_id: {
type: 'string',
format: 'uuid',
description: 'ID des Achievements'
},
progress: {
type: 'integer',
description: 'Aktueller Fortschritt'
},
is_completed: {
type: 'boolean',
description: 'Ob das Achievement abgeschlossen ist'
},
earned_at: {
type: 'string',
format: 'date-time',
description: 'Wann das Achievement erreicht wurde'
}
}
},
Error: {
type: 'object',
properties: {
success: {
type: 'boolean',
example: false
},
message: {
type: 'string',
description: 'Fehlermeldung'
}
}
},
Success: {
type: 'object',
properties: {
success: {
type: 'boolean',
example: true
},
message: {
type: 'string',
description: 'Erfolgsmeldung'
},
data: {
type: 'object',
description: 'Daten der Antwort'
}
}
}
}
},
tags: [
{
name: 'Public API',
description: 'Öffentliche API-Endpoints ohne Authentifizierung'
},
{
name: 'Private API',
description: 'Private API-Endpoints mit API-Key Authentifizierung'
},
{
name: 'Web API',
description: 'Web-API-Endpoints für das Frontend'
},
{
name: 'Admin API',
description: 'Admin-API-Endpoints für Verwaltung'
},
{
name: 'Achievements',
description: 'Achievement-System Endpoints'
}
]
},
apis: ['./routes/api.js'] // Pfad zu deiner API-Datei
};
const specs = swaggerJsdoc(options);
module.exports = specs;

46
test-achievements.js Normal file
View File

@@ -0,0 +1,46 @@
const AchievementSystem = require('./lib/achievementSystem');
async function testAchievements() {
console.log('=== Testing Achievement System ===');
const achievementSystem = new AchievementSystem();
const playerId = '08476bfc-5f48-486c-9f0b-90b81e5ccd8d';
try {
// Test 1: Load achievements
console.log('\n1. Loading achievements...');
await achievementSystem.loadAchievements();
console.log(`✅ Loaded ${achievementSystem.achievements.size} achievements`);
// Test 2: Load player achievements
console.log('\n2. Loading player achievements...');
await achievementSystem.loadPlayerAchievements(playerId);
const playerAchievements = achievementSystem.playerAchievements.get(playerId);
console.log(`✅ Player has ${playerAchievements ? playerAchievements.size : 0} achievements`);
// Test 3: Check first time achievement
console.log('\n3. Checking first time achievement...');
const firstTimeAchievement = Array.from(achievementSystem.achievements.values())
.find(a => a.category === 'consistency' && a.condition_type === 'first_time');
console.log('First time achievement:', firstTimeAchievement ? firstTimeAchievement.name : 'NOT FOUND');
// Test 4: Check September achievement
console.log('\n4. Checking September achievement...');
const septemberAchievement = Array.from(achievementSystem.achievements.values())
.find(a => a.category === 'monthly' && a.condition_type === 'september');
console.log('September achievement:', septemberAchievement ? septemberAchievement.name : 'NOT FOUND');
// Test 5: Check immediate achievements
console.log('\n5. Running immediate achievement check...');
const newAchievements = await achievementSystem.checkImmediateAchievements(playerId);
console.log(`✅ Found ${newAchievements.length} new achievements`);
newAchievements.forEach(achievement => {
console.log(` 🏆 ${achievement.name} (+${achievement.points} points)`);
});
} catch (error) {
console.error('❌ Error:', error);
}
}
testAchievements();

752
wiki/API-Referenz.md Normal file
View File

@@ -0,0 +1,752 @@
# 📡 API Referenz
Vollständige Dokumentation der REST API des Ninja Cross Parkour Systems.
## 📋 Inhaltsverzeichnis
- [🔐 Authentifizierung](#-authentifizierung)
- [🌐 Public API](#-public-api)
- [🔒 Private API](#-private-api)
- [🖥️ Web API](#-web-api)
- [👑 Admin API](#-admin-api)
- [🏆 Achievements API](#-achievements-api)
- [📊 Datenmodelle](#-datenmodelle)
- [❌ Fehlerbehandlung](#-fehlerbehandlung)
## 🔐 Authentifizierung
### API-Key Authentifizierung
Für private Endpoints wird ein API-Key im Authorization Header benötigt:
```http
Authorization: Bearer YOUR_API_KEY_HERE
```
### Session-basierte Authentifizierung
Für Web-Endpoints wird eine Session-basierte Authentifizierung verwendet.
### Admin-Authentifizierung
Für Admin-Endpoints wird eine erweiterte Authentifizierung mit Admin-Rechten benötigt.
## 🌐 Public API
Öffentliche Endpoints ohne Authentifizierung.
### 🔑 Authentifizierung
#### Login
```http
POST /api/v1/public/login
Content-Type: application/json
{
"username": "admin",
"password": "admin123"
}
```
**Response:**
```json
{
"success": true,
"message": "Login erfolgreich",
"user": {
"id": 1,
"username": "admin",
"is_active": true
}
}
```
#### Logout
```http
POST /api/v1/public/logout
```
**Response:**
```json
{
"success": true,
"message": "Logout erfolgreich"
}
```
### 👥 Spieler Management
#### Spieler erstellen
```http
POST /api/v1/public/players
Content-Type: application/json
{
"firstname": "Max",
"lastname": "Mustermann",
"birthdate": "1990-01-01",
"rfiduid": "AA:BB:CC:DD"
}
```
**Response:**
```json
{
"success": true,
"data": {
"id": "uuid",
"firstname": "Max",
"lastname": "Mustermann",
"birthdate": "1990-01-01",
"rfiduid": "AA:BB:CC:DD",
"created_at": "2024-01-01T00:00:00Z"
}
}
```
#### Spieler verknüpfen
```http
POST /api/v1/public/link-player
Content-Type: application/json
{
"rfiduid": "AA:BB:CC:DD",
"supabase_user_id": "uuid-here"
}
```
#### Spieler per RFID verknüpfen
```http
POST /api/v1/public/link-by-rfid
Content-Type: application/json
{
"rfiduid": "AA:BB:CC:DD",
"supabase_user_id": "uuid-here"
}
```
### 📍 Standorte
#### Alle Standorte abrufen
```http
GET /api/v1/public/locations
```
**Response:**
```json
{
"success": true,
"data": [
{
"id": "uuid",
"name": "Standort 1",
"latitude": 48.1351,
"longitude": 11.5820,
"time_threshold": {
"seconds": 120
},
"created_at": "2024-01-01T00:00:00Z"
}
]
}
```
### ⏱️ Zeiten
#### Alle Zeiten abrufen
```http
GET /api/v1/public/times
```
#### Zeiten mit Details abrufen
```http
GET /api/v1/public/times-with-details
```
#### Beste Zeiten abrufen
```http
GET /api/v1/public/best-times
```
#### Benutzer-Zeiten abrufen
```http
GET /api/v1/public/user-times/{supabase_user_id}
```
#### Benutzer-Spieler abrufen
```http
GET /api/v1/public/user-player/{supabase_user_id}
```
### 📊 Statistiken
#### Seitenaufruf verfolgen
```http
POST /api/v1/public/track-page-view
Content-Type: application/json
{
"page": "/dashboard",
"user_agent": "Mozilla/5.0...",
"ip_address": "192.168.1.1"
}
```
### 🔔 Push Notifications
#### Push-Benachrichtigung abonnieren
```http
POST /api/v1/public/subscribe
Content-Type: application/json
{
"endpoint": "https://fcm.googleapis.com/fcm/send/...",
"keys": {
"p256dh": "key-here",
"auth": "auth-key-here"
}
}
```
#### Push-Benachrichtigung testen
```http
POST /api/v1/public/test-push
Content-Type: application/json
{
"title": "Test",
"body": "Test-Nachricht",
"icon": "/icon.png"
}
```
#### Push-Status abrufen
```http
GET /api/v1/public/push-status
```
## 🔒 Private API
Private Endpoints mit API-Key Authentifizierung.
### 🎫 Token Management
#### Token speichern
```http
POST /api/v1/private/save-token
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json
{
"token": "GENERATED_TOKEN",
"description": "Beschreibung",
"standorte": "München, Berlin"
}
```
#### Alle Token abrufen
```http
GET /api/v1/private/tokens
Authorization: Bearer YOUR_API_KEY
```
#### Token validieren
```http
POST /api/v1/private/validate-token
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json
{
"token": "TOKEN_TO_VALIDATE"
}
```
### 📍 Standort Management
#### Standort erstellen
```http
POST /api/v1/private/create-location
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json
{
"name": "München",
"latitude": 48.1351,
"longitude": 11.5820
}
```
#### Alle Standorte abrufen
```http
GET /api/v1/private/locations
Authorization: Bearer YOUR_API_KEY
```
#### Standort-Schwelle aktualisieren
```http
PUT /api/v1/private/locations/{id}/threshold
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json
{
"threshold_seconds": 120
}
```
### 👥 Spieler Management
#### Spieler erstellen
```http
POST /api/v1/private/create-player
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json
{
"firstname": "Max",
"lastname": "Mustermann",
"birthdate": "1990-01-01",
"rfiduid": "AA:BB:CC:DD"
}
```
#### Benutzer suchen
```http
POST /api/v1/private/users/find
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json
{
"search_term": "Max"
}
```
### ⏱️ Zeit Management
#### Zeit erstellen
```http
POST /api/v1/private/create-time
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json
{
"player_id": "RFIDUID",
"location_id": "Name",
"recorded_time": "01:23.456"
}
```
## 🖥️ Web API
Web-API-Endpoints für das Frontend.
### 🔑 API-Key generieren
```http
POST /api/v1/web/generate-api-key
Content-Type: application/json
{
"description": "Mein API Key",
"standorte": "München, Berlin"
}
```
### 📍 Standort erstellen
```http
POST /api/v1/web/create-location
Content-Type: application/json
{
"name": "München",
"latitude": 48.1351,
"longitude": 11.5820
}
```
### 🎫 Token speichern
```http
POST /api/v1/web/save-token
Content-Type: application/json
{
"token": "GENERATED_TOKEN",
"description": "Beschreibung",
"standorte": "München, Berlin"
}
```
### 🔍 Session prüfen
```http
GET /api/v1/web/check-session
```
## 👑 Admin API
Admin-API-Endpoints für Verwaltung.
### 📊 Statistiken
#### Admin-Statistiken
```http
GET /api/v1/admin/stats
Authorization: Bearer ADMIN_TOKEN
```
#### Seiten-Statistiken
```http
GET /api/v1/admin/page-stats
Authorization: Bearer ADMIN_TOKEN
```
### 👥 Spieler Verwaltung
#### Alle Spieler abrufen
```http
GET /api/v1/admin/players
Authorization: Bearer ADMIN_TOKEN
```
#### Spieler erstellen
```http
POST /api/v1/admin/players
Authorization: Bearer ADMIN_TOKEN
Content-Type: application/json
{
"firstname": "Max",
"lastname": "Mustermann",
"birthdate": "1990-01-01",
"rfiduid": "AA:BB:CC:DD"
}
```
#### Spieler aktualisieren
```http
PUT /api/v1/admin/players/{id}
Authorization: Bearer ADMIN_TOKEN
Content-Type: application/json
{
"firstname": "Max",
"lastname": "Mustermann",
"birthdate": "1990-01-01",
"rfiduid": "AA:BB:CC:DD"
}
```
#### Spieler löschen
```http
DELETE /api/v1/admin/players/{id}
Authorization: Bearer ADMIN_TOKEN
```
### 🏃‍♂️ Läufe Verwaltung
#### Alle Läufe abrufen
```http
GET /api/v1/admin/runs
Authorization: Bearer ADMIN_TOKEN
```
#### Lauf abrufen
```http
GET /api/v1/admin/runs/{id}
Authorization: Bearer ADMIN_TOKEN
```
#### Lauf erstellen
```http
POST /api/v1/admin/runs
Authorization: Bearer ADMIN_TOKEN
Content-Type: application/json
{
"player_id": "uuid",
"location_id": "uuid",
"recorded_time": "01:23.456"
}
```
#### Lauf aktualisieren
```http
PUT /api/v1/admin/runs/{id}
Authorization: Bearer ADMIN_TOKEN
Content-Type: application/json
{
"recorded_time": "01:20.000"
}
```
#### Lauf löschen
```http
DELETE /api/v1/admin/runs/{id}
Authorization: Bearer ADMIN_TOKEN
```
### 📍 Standort Verwaltung
#### Alle Standorte abrufen
```http
GET /api/v1/admin/locations
Authorization: Bearer ADMIN_TOKEN
```
#### Standort erstellen
```http
POST /api/v1/admin/locations
Authorization: Bearer ADMIN_TOKEN
Content-Type: application/json
{
"name": "München",
"latitude": 48.1351,
"longitude": 11.5820,
"time_threshold": 120
}
```
#### Standort aktualisieren
```http
PUT /api/v1/admin/locations/{id}
Authorization: Bearer ADMIN_TOKEN
Content-Type: application/json
{
"name": "München Updated",
"latitude": 48.1351,
"longitude": 11.5820,
"time_threshold": 120
}
```
#### Standort löschen
```http
DELETE /api/v1/admin/locations/{id}
Authorization: Bearer ADMIN_TOKEN
```
### 👤 Admin-Benutzer Verwaltung
#### Alle Admin-Benutzer abrufen
```http
GET /api/v1/admin/adminusers
Authorization: Bearer ADMIN_TOKEN
```
#### Admin-Benutzer erstellen
```http
POST /api/v1/admin/adminusers
Authorization: Bearer ADMIN_TOKEN
Content-Type: application/json
{
"username": "newadmin",
"password": "securepassword"
}
```
#### Admin-Benutzer aktualisieren
```http
PUT /api/v1/admin/adminusers/{id}
Authorization: Bearer ADMIN_TOKEN
Content-Type: application/json
{
"username": "updatedadmin",
"is_active": true
}
```
#### Admin-Benutzer löschen
```http
DELETE /api/v1/admin/adminusers/{id}
Authorization: Bearer ADMIN_TOKEN
```
## 🏆 Achievements API
Achievement-System Endpoints.
### 🏆 Achievements abrufen
```http
GET /api/achievements
```
**Response:**
```json
{
"success": true,
"data": [
{
"id": "uuid",
"name": "Erster Lauf",
"description": "Absolviere deinen ersten Lauf",
"category": "consistency",
"condition_type": "runs_count",
"condition_value": 1,
"icon": "🏃‍♂️",
"points": 10,
"is_active": true
}
]
}
```
### 👤 Spieler-Achievements
```http
GET /api/achievements/player/{playerId}
```
### 📊 Spieler-Statistiken
```http
GET /api/achievements/player/{playerId}/stats
```
### ✅ Achievement prüfen
```http
POST /api/achievements/check/{playerId}
Content-Type: application/json
{
"achievement_id": "uuid"
}
```
### 📅 Tägliche Prüfung
```http
POST /api/achievements/daily-check
```
### 🏃‍♂️ Beste Zeit prüfen
```http
POST /api/achievements/best-time-check
```
### 🏅 Leaderboard
```http
GET /api/achievements/leaderboard
```
## 📊 Datenmodelle
### Player
```json
{
"id": "string (uuid)",
"firstname": "string",
"lastname": "string",
"birthdate": "string (date)",
"rfiduid": "string (XX:XX:XX:XX)",
"supabase_user_id": "string (uuid)",
"created_at": "string (date-time)"
}
```
### Time
```json
{
"id": "string (uuid)",
"player_id": "string (uuid)",
"location_id": "string (uuid)",
"recorded_time": {
"seconds": "number",
"minutes": "number",
"milliseconds": "number"
},
"created_at": "string (date-time)"
}
```
### Location
```json
{
"id": "string (uuid)",
"name": "string",
"latitude": "number (float)",
"longitude": "number (float)",
"time_threshold": {
"seconds": "number",
"minutes": "number"
},
"created_at": "string (date-time)"
}
```
### Achievement
```json
{
"id": "string (uuid)",
"name": "string",
"description": "string",
"category": "string (consistency|improvement|seasonal|monthly)",
"condition_type": "string",
"condition_value": "integer",
"icon": "string (emoji)",
"points": "integer",
"is_active": "boolean"
}
```
### PlayerAchievement
```json
{
"id": "string (uuid)",
"player_id": "string (uuid)",
"achievement_id": "string (uuid)",
"progress": "integer",
"is_completed": "boolean",
"earned_at": "string (date-time)"
}
```
## ❌ Fehlerbehandlung
### Standard-Fehlerantwort
```json
{
"success": false,
"message": "Fehlermeldung",
"error": "DETAILED_ERROR_INFO"
}
```
### HTTP-Status-Codes
- **200 OK** - Erfolgreiche Anfrage
- **201 Created** - Ressource erfolgreich erstellt
- **400 Bad Request** - Ungültige Anfrage
- **401 Unauthorized** - Nicht authentifiziert
- **403 Forbidden** - Keine Berechtigung
- **404 Not Found** - Ressource nicht gefunden
- **500 Internal Server Error** - Serverfehler
### Häufige Fehlermeldungen
- `"API-Key erforderlich"` - Fehlender oder ungültiger API-Key
- `"Ungültige Anmeldedaten"` - Falsche Login-Daten
- `"Ressource nicht gefunden"` - Angeforderte Ressource existiert nicht
- `"Ungültige Daten"` - Validierungsfehler bei Eingabedaten
- `"Keine Berechtigung"` - Unzureichende Rechte für die Aktion
## 🔧 Entwicklung
### Lokale Entwicklung
```bash
# Server starten
npm run dev
# API-Dokumentation anzeigen
# Swagger UI verfügbar unter: http://localhost:3000/api-docs
```
### Produktionsumgebung
```bash
# Server starten
npm start
# API-Dokumentation: https://ninja.reptilfpv.de/api-docs
```
---
**Version:** 1.0.0
**Base URL:** `https://ninja.reptilfpv.de/api`
**Autor:** Carsten Graf

479
wiki/Achievement-System.md Normal file
View File

@@ -0,0 +1,479 @@
# 🏆 Achievement System
Umfassende Dokumentation des Achievement-Systems für das Ninja Cross Parkour System.
## 📊 System-Übersicht
Das Achievement-System besteht aus:
- **32 verschiedene Achievements** in 4 Kategorien
- **Automatische tägliche Vergabe** am Ende des Tages
- **REST API Endpoints** für Frontend-Integration
- **PostgreSQL Funktionen** für effiziente Verarbeitung
## 🎯 Achievement-Kategorien
### 1. Konsistenz-basierte Achievements
- **Erste Schritte** 👶 - Erste Zeit aufgezeichnet (5 Punkte)
- **Durchhalter** 💪 - 3 Versuche an einem Tag (10 Punkte)
- **Fleißig** 🔥 - 5 Versuche an einem Tag (15 Punkte)
- **Besessen** 😤 - 10 Versuche an einem Tag (25 Punkte)
- **Regelmäßig** 📅 - 5 verschiedene Tage gespielt (20 Punkte)
- **Stammgast** ⭐ - 10 verschiedene Tage gespielt (30 Punkte)
- **Treue** 💎 - 20 verschiedene Tage gespielt (50 Punkte)
- **Veteran** 🏆 - 50 verschiedene Tage gespielt (100 Punkte)
### 2. Verbesserungs-basierte Achievements
- **Fortschritt** 📈 - Persönliche Bestzeit um 5 Sekunden verbessert (15 Punkte)
- **Durchbruch** ⚡ - Persönliche Bestzeit um 10 Sekunden verbessert (25 Punkte)
- **Transformation** 🔄 - Persönliche Bestzeit um 15 Sekunden verbessert (40 Punkte)
- **Perfektionist** ✨ - Persönliche Bestzeit um 20 Sekunden verbessert (60 Punkte)
### 3. Saisonale Achievements
- **Wochenend-Krieger** 🏁 - Am Wochenende gespielt (10 Punkte)
- **Nachmittags-Sportler** ☀️ - Zwischen 14-18 Uhr gespielt (10 Punkte)
- **Frühaufsteher** 🌅 - Vor 10 Uhr gespielt (15 Punkte)
- **Abend-Sportler** 🌙 - Nach 18 Uhr gespielt (10 Punkte)
### 4. Monatliche Achievements
- **Januar-Krieger** ❄️ bis **Dezember-Dynamo** 🎄 (je 20 Punkte)
### 5. Jahreszeiten-Achievements
- **Frühjahrs-Fighter** 🌱 - Im Frühling gespielt (30 Punkte)
- **Sommer-Sportler** ☀️ - Im Sommer gespielt (30 Punkte)
- **Herbst-Held** 🍂 - Im Herbst gespielt (30 Punkte)
- **Winter-Warrior** ❄️ - Im Winter gespielt (30 Punkte)
## 🗄️ Datenbank-Schema
### Tabelle: `achievements`
```sql
- id (uuid, PK)
- name (varchar) - Achievement-Name
- description (text) - Beschreibung
- category (varchar) - Kategorie
- condition_type (varchar) - Bedingungstyp
- condition_value (integer) - Bedingungswert
- icon (varchar) - Emoji-Icon
- points (integer) - Punkte
- is_active (boolean) - Aktiv
- created_at (timestamp)
```
### Tabelle: `player_achievements`
```sql
- id (uuid, PK)
- player_id (uuid, FK) - Verweis auf players.id
- achievement_id (uuid, FK) - Verweis auf achievements.id
- earned_at (timestamp) - Wann erreicht
- progress (integer) - Fortschritt
- is_completed (boolean) - Abgeschlossen
- created_at (timestamp)
```
## 🔧 PostgreSQL Funktionen
### `check_consistency_achievements(player_uuid)`
Überprüft alle Konsistenz-basierten Achievements für einen Spieler.
**Logik:**
- Zählt Gesamtläufe des Spielers
- Zählt Läufe pro Tag
- Zählt verschiedene Spieltage
- Vergibt entsprechende Achievements
### `check_improvement_achievements(player_uuid)`
Überprüft alle Verbesserungs-basierten Achievements für einen Spieler.
**Logik:**
- Ermittelt persönliche Bestzeit
- Berechnet Verbesserung seit erster Zeit
- Vergibt entsprechende Achievements
### `check_seasonal_achievements(player_uuid)`
Überprüft alle saisonalen und monatlichen Achievements für einen Spieler.
**Logik:**
- Prüft Wochentag (Wochenende)
- Prüft Tageszeit (morgens, nachmittags, abends)
- Prüft Monat (Januar bis Dezember)
- Prüft Jahreszeit (Frühling, Sommer, Herbst, Winter)
### `check_all_achievements(player_uuid)`
Führt alle Achievement-Überprüfungen für einen Spieler aus.
## 🚀 API Endpoints
### GET `/api/achievements`
Alle verfügbaren Achievements abrufen.
**Response:**
```json
{
"success": true,
"data": [
{
"id": "uuid",
"name": "Erste Schritte",
"description": "Absolviere deinen ersten Lauf",
"category": "consistency",
"condition_type": "runs_count",
"condition_value": 1,
"icon": "👶",
"points": 5,
"is_active": true
}
]
}
```
### GET `/api/achievements/player/:playerId`
Achievements eines bestimmten Spielers abrufen.
**Response:**
```json
{
"success": true,
"data": [
{
"id": "uuid",
"achievement_id": "uuid",
"name": "Erste Schritte",
"description": "Absolviere deinen ersten Lauf",
"icon": "👶",
"points": 5,
"progress": 1,
"is_completed": true,
"earned_at": "2024-01-01T00:00:00Z"
}
]
}
```
### GET `/api/achievements/player/:playerId/stats`
Achievement-Statistiken eines Spielers abrufen.
**Response:**
```json
{
"success": true,
"data": {
"total_achievements": 32,
"completed_achievements": 5,
"total_points": 150,
"earned_points": 75,
"completion_percentage": 15.6
}
}
```
### POST `/api/achievements/check/:playerId`
Achievements für einen Spieler manuell überprüfen.
**Request:**
```json
{
"achievement_id": "uuid"
}
```
### POST `/api/achievements/daily-check`
Tägliche Achievement-Überprüfung für alle Spieler ausführen.
**Response:**
```json
{
"success": true,
"message": "Daily achievement check completed",
"players_checked": 150,
"achievements_awarded": 25
}
```
### GET `/api/achievements/leaderboard?limit=10`
Bestenliste der Spieler nach Achievement-Punkten.
**Response:**
```json
{
"success": true,
"data": [
{
"player_id": "uuid",
"firstname": "Max",
"lastname": "Mustermann",
"total_points": 500,
"completed_achievements": 15,
"rank": 1
}
]
}
```
## 📅 Automatisierung
### Tägliches Script
```bash
# Manuell ausführen
node scripts/daily_achievements.js
# Cron-Job einrichten
node scripts/setup_cron.js setup
# Cron-Job Status prüfen
node scripts/setup_cron.js status
# Cron-Job entfernen
node scripts/setup_cron.js remove
```
### Cron-Schedule
- **Zeit**: Täglich um 23:59 Uhr
- **Log**: `/var/log/ninjaserver_achievements.log`
### Script-Details
```javascript
// scripts/daily_achievements.js
const { checkAllAchievements } = require('../models/Achievement');
async function dailyCheck() {
try {
// Alle Spieler abrufen
const players = await getAllPlayers();
let totalAwarded = 0;
for (const player of players) {
const awarded = await checkAllAchievements(player.id);
totalAwarded += awarded;
}
console.log(`Daily check completed: ${totalAwarded} achievements awarded`);
} catch (error) {
console.error('Daily check failed:', error);
}
}
```
## 🎮 Frontend-Integration
### Beispiel: Achievement-Liste laden
```javascript
async function loadAchievements(playerId) {
try {
const response = await fetch(`/api/achievements/player/${playerId}`);
const data = await response.json();
if (data.success) {
data.data.forEach(achievement => {
const status = achievement.is_completed ? '✅' : '❌';
console.log(`${achievement.icon} ${achievement.name}: ${status}`);
});
}
} catch (error) {
console.error('Error loading achievements:', error);
}
}
```
### Beispiel: Statistiken anzeigen
```javascript
async function loadStats(playerId) {
try {
const response = await fetch(`/api/achievements/player/${playerId}/stats`);
const data = await response.json();
if (data.success) {
const stats = data.data;
document.getElementById('total-points').textContent = stats.total_points;
document.getElementById('completed').textContent =
`${stats.completed_achievements}/${stats.total_achievements}`;
document.getElementById('percentage').textContent =
`${stats.completion_percentage}%`;
}
} catch (error) {
console.error('Error loading stats:', error);
}
}
```
### Beispiel: Achievement-Animation
```javascript
function showAchievementNotification(achievement) {
const notification = document.createElement('div');
notification.className = 'achievement-notification';
notification.innerHTML = `
<div class="achievement-icon">${achievement.icon}</div>
<div class="achievement-text">
<h3>${achievement.name}</h3>
<p>${achievement.description}</p>
<span class="points">+${achievement.points} Punkte</span>
</div>
`;
document.body.appendChild(notification);
// Animation
setTimeout(() => {
notification.classList.add('show');
}, 100);
// Entfernen nach 5 Sekunden
setTimeout(() => {
notification.remove();
}, 5000);
}
```
## 🔍 Monitoring
### Logs überwachen
```bash
# Live-Logs anzeigen
tail -f /var/log/ninjaserver_achievements.log
# Letzte Ausführung prüfen
grep "Daily achievement check completed" /var/log/ninjaserver_achievements.log | tail -1
# Fehler-Logs anzeigen
grep "ERROR" /var/log/ninjaserver_achievements.log
```
### Datenbank-Status prüfen
```sql
-- Achievement-Statistiken
SELECT
COUNT(*) as total_achievements,
COUNT(CASE WHEN is_active = true THEN 1 END) as active_achievements
FROM achievements;
-- Spieler-Statistiken
SELECT
COUNT(DISTINCT player_id) as players_with_achievements,
COUNT(*) as total_earned_achievements
FROM player_achievements
WHERE is_completed = true;
-- Top-Spieler
SELECT
p.firstname,
p.lastname,
COUNT(pa.id) as achievements,
SUM(a.points) as total_points
FROM players p
JOIN player_achievements pa ON p.id = pa.player_id
JOIN achievements a ON pa.achievement_id = a.id
WHERE pa.is_completed = true
GROUP BY p.id, p.firstname, p.lastname
ORDER BY total_points DESC
LIMIT 10;
```
## 🛠️ Wartung
### Neue Achievements hinzufügen
1. Achievement in `achievements` Tabelle einfügen:
```sql
INSERT INTO achievements (name, description, category, condition_type, condition_value, icon, points)
VALUES ('Neues Achievement', 'Beschreibung', 'consistency', 'runs_count', 5, '🏆', 25);
```
2. Logik in entsprechenden PostgreSQL Funktionen erweitern
3. API Endpoints testen
### Achievement deaktivieren
```sql
UPDATE achievements SET is_active = false WHERE name = 'Achievement-Name';
```
### Daten zurücksetzen
```sql
-- Alle Spieler-Achievements löschen
DELETE FROM player_achievements;
-- Achievement-Statistiken zurücksetzen
UPDATE achievements SET created_at = NOW();
```
### Achievement-Import/Export
```bash
# Export
pg_dump -t achievements -t player_achievements ninjaserver > achievements_backup.sql
# Import
psql ninjaserver < achievements_backup.sql
```
## 📈 Performance
### Indizierung
```sql
-- Performance-Indizes
CREATE INDEX CONCURRENTLY idx_player_achievements_player_id
ON player_achievements(player_id);
CREATE INDEX CONCURRENTLY idx_player_achievements_achievement_id
ON player_achievements(achievement_id);
CREATE INDEX CONCURRENTLY idx_player_achievements_completed
ON player_achievements(is_completed) WHERE is_completed = true;
```
### Batch-Processing
- **Effiziente Verarbeitung** aller Spieler in einem Durchgang
- **Transaktionale Sicherheit** für Datenkonsistenz
- **Fehlerbehandlung** für einzelne Spieler
### Caching
- **Achievement-Definitionen** werden gecacht
- **Spieler-Statistiken** werden bei Änderungen neu berechnet
- **Leaderboard** wird periodisch aktualisiert
### Zeitzone-Behandlung
- **Korrekte Zeitzone** (Europe/Berlin) für alle Zeitberechnungen
- **Saisonale Achievements** berücksichtigen lokale Zeit
- **Tägliche Prüfung** erfolgt zur richtigen Zeit
## 🔒 Sicherheit
### API-Schutz
- **Alle Endpoints** über bestehende Authentifizierung
- **Admin-Endpoints** erfordern erweiterte Berechtigung
- **Rate Limiting** für häufige Anfragen
### SQL-Injection
- **Parametrisierte Queries** in allen Funktionen
- **Input-Validierung** vor Datenbankzugriff
- **Escape-Funktionen** für dynamische Inhalte
### Datenvalidierung
- **Eingabe-Validierung** in allen API-Endpoints
- **Typ-Überprüfung** für alle Parameter
- **Bereichs-Validierung** für numerische Werte
### Fehlerbehandlung
- **Umfassende Error-Handling** in allen Funktionen
- **Logging** aller Fehler und Warnungen
- **Graceful Degradation** bei Systemfehlern
## 🎯 Best Practices
### Achievement-Design
- **Klare Bedingungen** für alle Achievements
- **Angemessene Punkte** basierend auf Schwierigkeit
- **Motivierende Beschreibungen** für Spieler
### Performance-Optimierung
- **Batch-Processing** für große Datenmengen
- **Indizierung** für häufige Abfragen
- **Caching** für statische Daten
### Wartbarkeit
- **Modulare Struktur** für einfache Erweiterungen
- **Dokumentation** aller Funktionen
- **Tests** für kritische Komponenten
---
**Erstellt am**: $(date)
**Version**: 1.0.0
**Autor**: Ninja Cross Parkour System

261
wiki/Benutzerhandbuch.md Normal file
View File

@@ -0,0 +1,261 @@
# 📖 Benutzerhandbuch
Anleitung für Endbenutzer des Ninja Cross Parkour Systems.
## 🎯 Übersicht
Das Ninja Cross Parkour System ermöglicht es Schwimmbadbesuchern, ihre Parkour-Zeiten zu messen, zu verfolgen und sich mit anderen zu vergleichen.
## 🚀 Erste Schritte
### 1. Registrierung
1. Öffnen Sie das Web-Interface
2. Klicken Sie auf "Registrieren"
3. Füllen Sie das Formular aus:
- Vorname
- Nachname
- Geburtsdatum
- RFID-Karten-ID (falls vorhanden)
### 2. RFID-Karte verknüpfen
Falls Sie eine RFID-Karte haben:
1. Melden Sie sich an
2. Gehen Sie zu "Mein Profil"
3. Klicken Sie auf "RFID-Karte verknüpfen"
4. Halten Sie Ihre Karte an den Reader
### 3. Erste Zeit messen
1. Wählen Sie einen Standort aus
2. Halten Sie Ihre RFID-Karte an den Start-Reader
3. Laufen Sie den Parkour
4. Halten Sie Ihre Karte an den Ziel-Reader
5. Ihre Zeit wird automatisch aufgezeichnet
## 🏠 Dashboard
### Übersicht
Das Dashboard zeigt:
- **Aktuelle Zeit** - Ihre letzte gemessene Zeit
- **Beste Zeit** - Ihr persönlicher Rekord
- **Achievements** - Ihre Erfolge
- **Statistiken** - Fortschritt und Trends
### Navigation
- **🏠 Home** - Dashboard und Übersicht
- **⏱️ Zeiten** - Alle Ihre gemessenen Zeiten
- **🏆 Achievements** - Erfolge und Fortschritt
- **📊 Statistiken** - Detaillierte Analysen
- **👤 Profil** - Persönliche Einstellungen
## ⏱️ Zeitmessung
### Wie funktioniert es?
1. **Start:** RFID-Karte an Start-Reader halten
2. **Parkour:** Den Parcours absolvieren
3. **Ziel:** RFID-Karte an Ziel-Reader halten
4. **Ergebnis:** Zeit wird automatisch berechnet und gespeichert
### Zeitformat
Zeiten werden im Format `MM:SS.mmm` angezeigt:
- **Minuten:SSekunden.Millisekunden**
- Beispiel: `01:23.456` = 1 Minute, 23 Sekunden, 456 Millisekunden
### Gültige Zeiten
- **Minimum:** 30 Sekunden
- **Maximum:** 10 Minuten
- **Schwelle:** Konfigurierbar pro Standort
## 🏆 Achievement System
### Was sind Achievements?
Achievements sind Erfolge, die Sie durch verschiedene Aktivitäten freischalten können.
### Kategorien
#### 🎯 Konsistenz-basierte Achievements
- **Erste Schritte** 👶 - Erste Zeit aufgezeichnet (5 Punkte)
- **Durchhalter** 💪 - 3 Versuche an einem Tag (10 Punkte)
- **Fleißig** 🔥 - 5 Versuche an einem Tag (15 Punkte)
- **Besessen** 😤 - 10 Versuche an einem Tag (25 Punkte)
- **Regelmäßig** 📅 - 5 verschiedene Tage gespielt (20 Punkte)
- **Stammgast** ⭐ - 10 verschiedene Tage gespielt (30 Punkte)
- **Treue** 💎 - 20 verschiedene Tage gespielt (50 Punkte)
- **Veteran** 🏆 - 50 verschiedene Tage gespielt (100 Punkte)
#### 📈 Verbesserungs-basierte Achievements
- **Fortschritt** 📈 - Persönliche Bestzeit um 5 Sekunden verbessert (15 Punkte)
- **Durchbruch** ⚡ - Persönliche Bestzeit um 10 Sekunden verbessert (25 Punkte)
- **Transformation** 🔄 - Persönliche Bestzeit um 15 Sekunden verbessert (40 Punkte)
- **Perfektionist** ✨ - Persönliche Bestzeit um 20 Sekunden verbessert (60 Punkte)
#### 🌍 Saisonale Achievements
- **Wochenend-Krieger** 🏁 - Am Wochenende gespielt (10 Punkte)
- **Nachmittags-Sportler** ☀️ - Zwischen 14-18 Uhr gespielt (10 Punkte)
- **Frühaufsteher** 🌅 - Vor 10 Uhr gespielt (15 Punkte)
- **Abend-Sportler** 🌙 - Nach 18 Uhr gespielt (10 Punkte)
#### 📅 Monatliche Achievements
- **Januar-Krieger** ❄️ bis **Dezember-Dynamo** 🎄 (je 20 Punkte)
#### 🌸 Jahreszeiten-Achievements
- **Frühjahrs-Fighter** 🌱 - Im Frühling gespielt (30 Punkte)
- **Sommer-Sportler** ☀️ - Im Sommer gespielt (30 Punkte)
- **Herbst-Held** 🍂 - Im Herbst gespielt (30 Punkte)
- **Winter-Warrior** ❄️ - Im Winter gespielt (30 Punkte)
### Achievement-Status
- **✅ Abgeschlossen** - Achievement erreicht
- **🔄 In Bearbeitung** - Fortschritt wird gemacht
- **❌ Nicht freigeschaltet** - Noch nicht begonnen
## 📊 Statistiken
### Persönliche Statistiken
- **Gesamtzeiten** - Anzahl aller gemessenen Zeiten
- **Beste Zeit** - Schnellste gemessene Zeit
- **Durchschnittszeit** - Durchschnittliche Zeit
- **Verbesserung** - Zeitverbesserung seit dem ersten Lauf
- **Aktivitätstage** - Anzahl der Tage mit Aktivität
### Fortschritts-Tracking
- **Wöchentlicher Fortschritt** - Zeiten der letzten 7 Tage
- **Monatlicher Fortschritt** - Zeiten des aktuellen Monats
- **Jährlicher Fortschritt** - Zeiten des aktuellen Jahres
### Vergleiche
- **Persönliche Bestenliste** - Ihre eigenen Top-Zeiten
- **Standort-Vergleich** - Zeiten an verschiedenen Standorten
- **Zeitverlauf** - Entwicklung Ihrer Zeiten über die Zeit
## 🗺️ Standorte
### Verfügbare Standorte
Das System unterstützt mehrere Standorte:
- **Hauptstandort** - Hauptparkour
- **Training** - Übungsbereich
- **Wettkampf** - Wettkampfbereich
### Standort-Informationen
Jeder Standort zeigt:
- **Name** - Standortbezeichnung
- **Schwelle** - Mindestzeit für gültige Zeiten
- **Beste Zeit** - Rekordzeit an diesem Standort
- **Karte** - Geografische Position
## 🔔 Benachrichtigungen
### Push-Benachrichtigungen
Aktivieren Sie Push-Benachrichtigungen für:
- **Neue Rekorde** - Persönliche Bestzeiten
- **Achievements** - Neue Erfolge
- **System-Updates** - Wichtige Ankündigungen
### E-Mail-Benachrichtigungen
Konfigurieren Sie E-Mail-Benachrichtigungen für:
- **Wöchentliche Zusammenfassung** - Ihre Aktivitäten
- **Monatliche Statistiken** - Detaillierte Berichte
- **System-Updates** - Wichtige Änderungen
## 👤 Profil verwalten
### Persönliche Daten
- **Name** - Vor- und Nachname
- **Geburtsdatum** - Für Alterskategorien
- **E-Mail** - Für Benachrichtigungen
- **RFID-Karte** - Verknüpfte Karten
### Einstellungen
- **Zeitzone** - Für korrekte Zeitstempel
- **Sprache** - Interface-Sprache
- **Benachrichtigungen** - Push und E-Mail Einstellungen
- **Datenschutz** - Sichtbarkeit Ihrer Daten
### Datenschutz
- **Öffentliche Profile** - Sichtbar für andere Benutzer
- **Private Profile** - Nur für Sie sichtbar
- **Datenexport** - Ihre Daten herunterladen
- **Konto löschen** - Alle Daten entfernen
## 🏅 Bestenlisten
### Globale Bestenlisten
- **Schnellste Zeiten** - Alle Zeiten aller Benutzer
- **Meiste Achievements** - Benutzer mit den meisten Erfolgen
- **Aktivste Spieler** - Benutzer mit den meisten Läufen
### Kategorien
- **Gesamt** - Alle Altersgruppen
- **Jugend** - Unter 18 Jahren
- **Erwachsene** - 18-65 Jahre
- **Senioren** - Über 65 Jahre
### Zeiträume
- **Heute** - Beste Zeiten des Tages
- **Diese Woche** - Beste Zeiten der Woche
- **Dieser Monat** - Beste Zeiten des Monats
- **Dieses Jahr** - Beste Zeiten des Jahres
- **Alle Zeiten** - Historische Bestenliste
## 🔧 Troubleshooting
### Häufige Probleme
#### RFID-Karte wird nicht erkannt
1. Karte richtig positionieren
2. Reader auf Verschmutzung prüfen
3. Karte auf Beschädigungen prüfen
4. Administrator kontaktieren
#### Zeit wird nicht gespeichert
1. Gültige Zeit prüfen (innerhalb der Schwelle)
2. Standort korrekt ausgewählt
3. Internetverbindung prüfen
4. Seite neu laden
#### Achievements werden nicht vergeben
1. Tägliche Prüfung abwarten
2. Bedingungen erfüllt prüfen
3. System-Status prüfen
4. Administrator kontaktieren
### Support kontaktieren
- **E-Mail** - support@ninjaparkour.de
- **Telefon** - +49 (0) 123 456 789
- **Chat** - Verfügbar im Web-Interface
## 📱 Mobile Nutzung
### Responsive Design
Das System ist für alle Geräte optimiert:
- **Desktop** - Vollständige Funktionalität
- **Tablet** - Touch-optimierte Bedienung
- **Smartphone** - Kompakte Ansicht
### Mobile App (geplant)
- **Native App** - Für iOS und Android
- **Offline-Modus** - Zeiten ohne Internet
- **Push-Benachrichtigungen** - Sofortige Updates
## 🎓 Tipps und Tricks
### Bessere Zeiten erzielen
1. **Regelmäßig trainieren** - Konsistenz ist wichtig
2. **Technik verbessern** - Effiziente Bewegungen
3. **Kondition aufbauen** - Ausdauer trainieren
4. **Mental vorbereiten** - Konzentration und Fokus
### Achievements sammeln
1. **Verschiedene Zeiten** - Morgens, mittags, abends
2. **Wochenenden** - Zusätzliche Aktivität
3. **Konsistent bleiben** - Regelmäßige Teilnahme
4. **Verbesserungen** - Persönliche Bestzeiten brechen
### System optimal nutzen
1. **Profil vollständig** - Alle Daten ausfüllen
2. **Benachrichtigungen aktivieren** - Updates erhalten
3. **Statistiken verfolgen** - Fortschritt beobachten
4. **Community nutzen** - Mit anderen vergleichen
---
**Hinweis:** Bei technischen Problemen wenden Sie sich an den Systemadministrator oder konsultieren Sie die [Troubleshooting](Troubleshooting)-Seite.

588
wiki/Datenbank.md Normal file
View File

@@ -0,0 +1,588 @@
# 🗄️ Datenbank
Dokumentation der PostgreSQL-Datenbank des Ninja Cross Parkour Systems.
## 📋 Inhaltsverzeichnis
- [🏗️ Schema-Übersicht](#-schema-übersicht)
- [📊 Tabellen](#-tabellen)
- [🔗 Beziehungen](#-beziehungen)
- [📈 Indizes](#-indizes)
- [🔧 Funktionen](#-funktionen)
- [📊 Statistiken](#-statistiken)
- [🛠️ Wartung](#-wartung)
## 🏗️ Schema-Übersicht
### Datenbank-Name
`ninjaserver`
### Zeichensatz
`UTF-8`
### Zeitzone
`Europe/Berlin`
### Version
PostgreSQL 12 oder höher
## 📊 Tabellen
### `players` - Spieler
```sql
CREATE TABLE players (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
firstname VARCHAR(50) NOT NULL,
lastname VARCHAR(50) NOT NULL,
birthdate DATE NOT NULL,
rfiduid VARCHAR(20) UNIQUE,
supabase_user_id UUID,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
**Beschreibung:** Speichert alle Spieler-Informationen.
**Felder:**
- `id` - Eindeutige UUID
- `firstname` - Vorname (max. 50 Zeichen)
- `lastname` - Nachname (max. 50 Zeichen)
- `birthdate` - Geburtsdatum
- `rfiduid` - RFID-Karten-ID (eindeutig)
- `supabase_user_id` - Verknüpfung zu Supabase
- `created_at` - Erstellungszeitpunkt
- `updated_at` - Letzte Aktualisierung
### `locations` - Standorte
```sql
CREATE TABLE locations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) UNIQUE NOT NULL,
latitude DECIMAL(10, 8) NOT NULL,
longitude DECIMAL(11, 8) NOT NULL,
time_threshold JSONB DEFAULT '{"seconds": 120}',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
**Beschreibung:** Speichert alle Parkour-Standorte.
**Felder:**
- `id` - Eindeutige UUID
- `name` - Standortname (eindeutig)
- `latitude` - Breitengrad (10,8 Dezimalstellen)
- `longitude` - Längengrad (11,8 Dezimalstellen)
- `time_threshold` - Zeit-Schwelle als JSON
- `created_at` - Erstellungszeitpunkt
- `updated_at` - Letzte Aktualisierung
### `times` - Zeiten
```sql
CREATE TABLE times (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
player_id UUID REFERENCES players(id) ON DELETE CASCADE,
location_id UUID REFERENCES locations(id) ON DELETE CASCADE,
recorded_time JSONB NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
**Beschreibung:** Speichert alle gemessenen Zeiten.
**Felder:**
- `id` - Eindeutige UUID
- `player_id` - Verweis auf Spieler
- `location_id` - Verweis auf Standort
- `recorded_time` - Zeit als JSON (Sekunden, Minuten, Millisekunden)
- `created_at` - Erstellungszeitpunkt
### `achievements` - Achievements
```sql
CREATE TABLE achievements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
description TEXT,
category VARCHAR(50) NOT NULL,
condition_type VARCHAR(50) NOT NULL,
condition_value INTEGER NOT NULL,
icon VARCHAR(10),
points INTEGER DEFAULT 0,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
**Beschreibung:** Definiert alle verfügbaren Achievements.
**Felder:**
- `id` - Eindeutige UUID
- `name` - Achievement-Name
- `description` - Beschreibung
- `category` - Kategorie (consistency, improvement, seasonal, monthly)
- `condition_type` - Bedingungstyp
- `condition_value` - Bedingungswert
- `icon` - Emoji-Icon
- `points` - Punkte
- `is_active` - Aktiv-Status
- `created_at` - Erstellungszeitpunkt
- `updated_at` - Letzte Aktualisierung
### `player_achievements` - Spieler-Achievements
```sql
CREATE TABLE player_achievements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
player_id UUID REFERENCES players(id) ON DELETE CASCADE,
achievement_id UUID REFERENCES achievements(id) ON DELETE CASCADE,
earned_at TIMESTAMP,
progress INTEGER DEFAULT 0,
is_completed BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(player_id, achievement_id)
);
```
**Beschreibung:** Verknüpft Spieler mit ihren Achievements.
**Felder:**
- `id` - Eindeutige UUID
- `player_id` - Verweis auf Spieler
- `achievement_id` - Verweis auf Achievement
- `earned_at` - Zeitpunkt der Verleihung
- `progress` - Fortschritt (0-100)
- `is_completed` - Abgeschlossen-Status
- `created_at` - Erstellungszeitpunkt
### `adminusers` - Admin-Benutzer
```sql
CREATE TABLE adminusers (
id SERIAL PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_login TIMESTAMP
);
```
**Beschreibung:** Speichert Admin-Benutzer für das System.
**Felder:**
- `id` - Auto-increment ID
- `username` - Benutzername (eindeutig)
- `password_hash` - Gehashtes Passwort
- `is_active` - Aktiv-Status
- `created_at` - Erstellungszeitpunkt
- `last_login` - Letzter Login
### `api_tokens` - API-Tokens
```sql
CREATE TABLE api_tokens (
id SERIAL PRIMARY KEY,
token VARCHAR(255) UNIQUE NOT NULL,
description TEXT,
standorte TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP,
is_active BOOLEAN DEFAULT true
);
```
**Beschreibung:** Speichert API-Tokens für Authentifizierung.
**Felder:**
- `id` - Auto-increment ID
- `token` - API-Token (eindeutig)
- `description` - Beschreibung
- `standorte` - Zugewiesene Standorte
- `created_at` - Erstellungszeitpunkt
- `expires_at` - Ablaufzeitpunkt
- `is_active` - Aktiv-Status
### `page_views` - Seitenaufrufe
```sql
CREATE TABLE page_views (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
page VARCHAR(255) NOT NULL,
user_agent TEXT,
ip_address INET,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
**Beschreibung:** Verfolgt Seitenaufrufe für Statistiken.
**Felder:**
- `id` - Eindeutige UUID
- `page` - Seitenname
- `user_agent` - Browser-Informationen
- `ip_address` - IP-Adresse
- `created_at` - Zeitpunkt des Aufrufs
## 🔗 Beziehungen
### Foreign Key Constraints
```sql
-- times -> players
ALTER TABLE times
ADD CONSTRAINT fk_times_player
FOREIGN KEY (player_id) REFERENCES players(id) ON DELETE CASCADE;
-- times -> locations
ALTER TABLE times
ADD CONSTRAINT fk_times_location
FOREIGN KEY (location_id) REFERENCES locations(id) ON DELETE CASCADE;
-- player_achievements -> players
ALTER TABLE player_achievements
ADD CONSTRAINT fk_player_achievements_player
FOREIGN KEY (player_id) REFERENCES players(id) ON DELETE CASCADE;
-- player_achievements -> achievements
ALTER TABLE player_achievements
ADD CONSTRAINT fk_player_achievements_achievement
FOREIGN KEY (achievement_id) REFERENCES achievements(id) ON DELETE CASCADE;
```
### Beziehungsdiagramm
```
players (1) -----> (N) times
players (1) -----> (N) player_achievements
locations (1) ---> (N) times
achievements (1) -> (N) player_achievements
```
## 📈 Indizes
### Primäre Indizes
```sql
-- Primärschlüssel (automatisch)
CREATE UNIQUE INDEX idx_players_pkey ON players(id);
CREATE UNIQUE INDEX idx_locations_pkey ON locations(id);
CREATE UNIQUE INDEX idx_times_pkey ON times(id);
CREATE UNIQUE INDEX idx_achievements_pkey ON achievements(id);
CREATE UNIQUE INDEX idx_player_achievements_pkey ON player_achievements(id);
```
### Performance-Indizes
```sql
-- Zeiten-Indizes
CREATE INDEX idx_times_player_id ON times(player_id);
CREATE INDEX idx_times_location_id ON times(location_id);
CREATE INDEX idx_times_created_at ON times(created_at);
CREATE INDEX idx_times_player_created ON times(player_id, created_at DESC);
-- Achievement-Indizes
CREATE INDEX idx_player_achievements_player_id ON player_achievements(player_id);
CREATE INDEX idx_player_achievements_achievement_id ON player_achievements(achievement_id);
CREATE INDEX idx_player_achievements_completed ON player_achievements(is_completed) WHERE is_completed = true;
-- Standort-Indizes
CREATE INDEX idx_locations_name ON locations(name);
CREATE INDEX idx_locations_coordinates ON locations(latitude, longitude);
-- Spieler-Indizes
CREATE INDEX idx_players_rfiduid ON players(rfiduid);
CREATE INDEX idx_players_supabase_user_id ON players(supabase_user_id);
CREATE INDEX idx_players_name ON players(firstname, lastname);
-- API-Token-Indizes
CREATE INDEX idx_api_tokens_token ON api_tokens(token);
CREATE INDEX idx_api_tokens_active ON api_tokens(is_active) WHERE is_active = true;
-- Seitenaufruf-Indizes
CREATE INDEX idx_page_views_page ON page_views(page);
CREATE INDEX idx_page_views_created_at ON page_views(created_at);
```
### Composite-Indizes
```sql
-- Für häufige Abfragen
CREATE INDEX idx_times_player_location_time ON times(player_id, location_id, created_at DESC);
CREATE INDEX idx_player_achievements_player_completed ON player_achievements(player_id, is_completed);
CREATE INDEX idx_achievements_category_active ON achievements(category, is_active);
```
## 🔧 Funktionen
### Achievement-Funktionen
```sql
-- Konsistenz-basierte Achievements prüfen
CREATE OR REPLACE FUNCTION check_consistency_achievements(player_uuid UUID)
RETURNS INTEGER AS $$
DECLARE
awarded_count INTEGER := 0;
total_runs INTEGER;
runs_today INTEGER;
unique_days INTEGER;
BEGIN
-- Gesamtläufe zählen
SELECT COUNT(*) INTO total_runs
FROM times WHERE player_id = player_uuid;
-- Läufe heute zählen
SELECT COUNT(*) INTO runs_today
FROM times
WHERE player_id = player_uuid
AND DATE(created_at) = CURRENT_DATE;
-- Verschiedene Tage zählen
SELECT COUNT(DISTINCT DATE(created_at)) INTO unique_days
FROM times WHERE player_id = player_uuid;
-- Achievements vergeben
-- (Detaillierte Logik hier...)
RETURN awarded_count;
END;
$$ LANGUAGE plpgsql;
-- Verbesserungs-basierte Achievements prüfen
CREATE OR REPLACE FUNCTION check_improvement_achievements(player_uuid UUID)
RETURNS INTEGER AS $$
-- (Implementierung...)
$$ LANGUAGE plpgsql;
-- Saisonale Achievements prüfen
CREATE OR REPLACE FUNCTION check_seasonal_achievements(player_uuid UUID)
RETURNS INTEGER AS $$
-- (Implementierung...)
$$ LANGUAGE plpgsql;
-- Alle Achievements prüfen
CREATE OR REPLACE FUNCTION check_all_achievements(player_uuid UUID)
RETURNS INTEGER AS $$
DECLARE
total_awarded INTEGER := 0;
BEGIN
total_awarded := total_awarded + check_consistency_achievements(player_uuid);
total_awarded := total_awarded + check_improvement_achievements(player_uuid);
total_awarded := total_awarded + check_seasonal_achievements(player_uuid);
RETURN total_awarded;
END;
$$ LANGUAGE plpgsql;
```
### Utility-Funktionen
```sql
-- Beste Zeit eines Spielers ermitteln
CREATE OR REPLACE FUNCTION get_best_time(player_uuid UUID)
RETURNS JSONB AS $$
DECLARE
best_time JSONB;
BEGIN
SELECT recorded_time INTO best_time
FROM times
WHERE player_id = player_uuid
ORDER BY (recorded_time->>'seconds')::INTEGER ASC
LIMIT 1;
RETURN COALESCE(best_time, '{"seconds": 0, "minutes": 0, "milliseconds": 0}');
END;
$$ LANGUAGE plpgsql;
-- Spieler-Statistiken berechnen
CREATE OR REPLACE FUNCTION get_player_stats(player_uuid UUID)
RETURNS JSONB AS $$
DECLARE
stats JSONB;
total_runs INTEGER;
best_time JSONB;
avg_time JSONB;
BEGIN
SELECT COUNT(*) INTO total_runs FROM times WHERE player_id = player_uuid;
SELECT get_best_time(player_uuid) INTO best_time;
-- Durchschnittszeit berechnen
SELECT jsonb_build_object(
'seconds', AVG((recorded_time->>'seconds')::INTEGER),
'minutes', AVG((recorded_time->>'minutes')::INTEGER),
'milliseconds', AVG((recorded_time->>'milliseconds')::INTEGER)
) INTO avg_time
FROM times WHERE player_id = player_uuid;
stats := jsonb_build_object(
'total_runs', total_runs,
'best_time', best_time,
'average_time', avg_time
);
RETURN stats;
END;
$$ LANGUAGE plpgsql;
```
## 📊 Statistiken
### Datenbank-Größe
```sql
-- Gesamtgröße der Datenbank
SELECT pg_size_pretty(pg_database_size('ninjaserver')) as database_size;
-- Größe der einzelnen Tabellen
SELECT
schemaname,
tablename,
pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) as size
FROM pg_tables
WHERE schemaname = 'public'
ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC;
```
### Tabellen-Statistiken
```sql
-- Anzahl der Datensätze pro Tabelle
SELECT
'players' as table_name, COUNT(*) as record_count FROM players
UNION ALL
SELECT 'locations', COUNT(*) FROM locations
UNION ALL
SELECT 'times', COUNT(*) FROM times
UNION ALL
SELECT 'achievements', COUNT(*) FROM achievements
UNION ALL
SELECT 'player_achievements', COUNT(*) FROM player_achievements
UNION ALL
SELECT 'adminusers', COUNT(*) FROM adminusers
UNION ALL
SELECT 'api_tokens', COUNT(*) FROM api_tokens
UNION ALL
SELECT 'page_views', COUNT(*) FROM page_views;
```
### Performance-Statistiken
```sql
-- Langsamste Queries
SELECT
query,
calls,
total_time,
mean_time,
rows
FROM pg_stat_statements
ORDER BY mean_time DESC
LIMIT 10;
-- Index-Nutzung
SELECT
schemaname,
tablename,
indexname,
idx_scan,
idx_tup_read,
idx_tup_fetch
FROM pg_stat_user_indexes
ORDER BY idx_scan DESC;
```
## 🛠️ Wartung
### Backup
```bash
# Vollständiges Backup
pg_dump -h localhost -U username -d ninjaserver > ninjaserver_backup.sql
# Nur Schema
pg_dump -h localhost -U username -d ninjaserver --schema-only > schema_backup.sql
# Nur Daten
pg_dump -h localhost -U username -d ninjaserver --data-only > data_backup.sql
# Komprimiertes Backup
pg_dump -h localhost -U username -d ninjaserver | gzip > ninjaserver_backup.sql.gz
```
### Wiederherstellung
```bash
# Vollständige Wiederherstellung
psql -h localhost -U username -d ninjaserver < ninjaserver_backup.sql
# Schema wiederherstellen
psql -h localhost -U username -d ninjaserver < schema_backup.sql
# Daten wiederherstellen
psql -h localhost -U username -d ninjaserver < data_backup.sql
```
### Wartungsaufgaben
```sql
-- Tabellen analysieren
ANALYZE;
-- Indizes neu aufbauen
REINDEX DATABASE ninjaserver;
-- Vakuum durchführen
VACUUM ANALYZE;
-- Speicher freigeben
VACUUM FULL;
```
### Monitoring
```sql
-- Aktive Verbindungen
SELECT
pid,
usename,
application_name,
client_addr,
state,
query_start,
query
FROM pg_stat_activity
WHERE state = 'active';
-- Locks
SELECT
pid,
mode,
locktype,
relation::regclass,
granted
FROM pg_locks
WHERE NOT granted;
-- Wartende Queries
SELECT
pid,
usename,
application_name,
state,
query_start,
query
FROM pg_stat_activity
WHERE state = 'waiting';
```
### Sicherheit
```sql
-- Benutzerrechte prüfen
SELECT
usename,
usesuper,
usecreatedb,
usebypassrls
FROM pg_user;
-- Tabellenrechte prüfen
SELECT
schemaname,
tablename,
tableowner
FROM pg_tables
WHERE schemaname = 'public';
-- Verbindungslimits
SELECT
usename,
connlimit
FROM pg_user;
```
---
**Hinweis:** Für detaillierte API-Dokumentation siehe [API Referenz](API-Referenz) und für Achievement-Details siehe [Achievement System](Achievement-System).

642
wiki/Deployment.md Normal file
View File

@@ -0,0 +1,642 @@
# 🚀 Deployment
Anleitung für das Deployment des Ninja Cross Parkour Systems in verschiedenen Umgebungen.
## 📋 Inhaltsverzeichnis
- [🏗️ Deployment-Übersicht](#-deployment-übersicht)
- [🔧 Vorbereitung](#-vorbereitung)
- [🐳 Docker-Deployment](#-docker-deployment)
- [☁️ Cloud-Deployment](#-cloud-deployment)
- [🖥️ VPS-Deployment](#-vps-deployment)
- [🔧 Konfiguration](#-konfiguration)
- [📊 Monitoring](#-monitoring)
- [🔄 CI/CD](#-cicd)
## 🏗️ Deployment-Übersicht
### Deployment-Optionen
- **Docker** - Containerisierte Bereitstellung
- **Cloud** - AWS, Azure, Google Cloud
- **VPS** - Virtuelle private Server
- **On-Premise** - Lokale Server
### System-Anforderungen
- **CPU:** 2+ Kerne
- **RAM:** 4+ GB
- **Storage:** 50+ GB SSD
- **Network:** 100+ Mbps
## 🔧 Vorbereitung
### Code vorbereiten
```bash
# Repository klonen
git clone <repository-url>
cd ninjaserver
# Abhängigkeiten installieren
npm install
# Produktions-Build erstellen
npm run build
# Tests ausführen
npm test
```
### Umgebungsvariablen
```bash
# .env.production erstellen
cp .env.example .env.production
# Produktionswerte setzen
NODE_ENV=production
PORT=3000
DB_HOST=production-db-host
DB_PORT=5432
DB_NAME=ninjaserver
DB_USER=ninja_user
DB_PASSWORD=secure_password
JWT_SECRET=your_jwt_secret_here
SESSION_SECRET=your_session_secret_here
```
### Datenbank vorbereiten
```sql
-- Produktionsdatenbank erstellen
CREATE DATABASE ninjaserver;
CREATE USER ninja_user WITH PASSWORD 'secure_password';
GRANT ALL PRIVILEGES ON DATABASE ninjaserver TO ninja_user;
-- Schema initialisieren
\c ninjaserver
\i scripts/init-db.sql
```
## 🐳 Docker-Deployment
### Dockerfile
```dockerfile
# Multi-stage build
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
FROM node:18-alpine AS runtime
# Sicherheitsupdates
RUN apk update && apk upgrade
# Nicht-root Benutzer erstellen
RUN addgroup -g 1001 -S nodejs
RUN adduser -S ninja -u 1001
WORKDIR /app
# Abhängigkeiten kopieren
COPY --from=builder /app/node_modules ./node_modules
COPY . .
# Berechtigungen setzen
RUN chown -R ninja:nodejs /app
USER ninja
# Port freigeben
EXPOSE 3000
# Health Check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost:3000/health || exit 1
# Anwendung starten
CMD ["npm", "start"]
```
### Docker Compose
```yaml
version: '3.8'
services:
app:
build: .
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- DB_HOST=postgres
- DB_PORT=5432
- DB_NAME=ninjaserver
- DB_USER=ninja_user
- DB_PASSWORD=secure_password
depends_on:
- postgres
- redis
volumes:
- ./logs:/app/logs
restart: unless-stopped
postgres:
image: postgres:15-alpine
environment:
- POSTGRES_DB=ninjaserver
- POSTGRES_USER=ninja_user
- POSTGRES_PASSWORD=secure_password
volumes:
- postgres_data:/var/lib/postgresql/data
- ./scripts/init-db.sql:/docker-entrypoint-initdb.d/init-db.sql
ports:
- "5432:5432"
restart: unless-stopped
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis_data:/data
restart: unless-stopped
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf
- ./ssl:/etc/nginx/ssl
depends_on:
- app
restart: unless-stopped
volumes:
postgres_data:
redis_data:
```
### Deployment-Skript
```bash
#!/bin/bash
# deploy.sh
set -e
echo "🚀 Starting deployment..."
# Docker Images bauen
echo "📦 Building Docker images..."
docker-compose build
# Alte Container stoppen
echo "🛑 Stopping old containers..."
docker-compose down
# Neue Container starten
echo "▶️ Starting new containers..."
docker-compose up -d
# Health Check
echo "🔍 Checking health..."
sleep 30
curl -f http://localhost:3000/health || exit 1
echo "✅ Deployment completed successfully!"
```
## ☁️ Cloud-Deployment
### AWS Deployment
#### EC2-Instanz
```bash
# EC2-Instanz starten
aws ec2 run-instances \
--image-id ami-0c02fb55956c7d316 \
--instance-type t3.medium \
--key-name ninja-key \
--security-groups ninja-sg \
--user-data file://user-data.sh
```
#### RDS-Datenbank
```bash
# RDS-Instanz erstellen
aws rds create-db-instance \
--db-instance-identifier ninja-db \
--db-instance-class db.t3.micro \
--engine postgres \
--master-username ninja_user \
--master-user-password secure_password \
--allocated-storage 20
```
#### Load Balancer
```bash
# Application Load Balancer erstellen
aws elbv2 create-load-balancer \
--name ninja-alb \
--subnets subnet-12345 subnet-67890 \
--security-groups sg-12345
```
### Azure Deployment
#### App Service
```bash
# App Service erstellen
az webapp create \
--resource-group ninja-rg \
--plan ninja-plan \
--name ninja-app \
--runtime "NODE|18-lts"
```
#### PostgreSQL
```bash
# PostgreSQL-Server erstellen
az postgres flexible-server create \
--resource-group ninja-rg \
--name ninja-db \
--admin-user ninja_user \
--admin-password secure_password \
--sku-name Standard_B1ms
```
### Google Cloud Deployment
#### Cloud Run
```yaml
# cloudbuild.yaml
steps:
- name: 'gcr.io/cloud-builders/docker'
args: ['build', '-t', 'gcr.io/$PROJECT_ID/ninja-app', '.']
- name: 'gcr.io/cloud-builders/docker'
args: ['push', 'gcr.io/$PROJECT_ID/ninja-app']
- name: 'gcr.io/cloud-builders/gcloud'
args: ['run', 'deploy', 'ninja-app', '--image', 'gcr.io/$PROJECT_ID/ninja-app', '--region', 'europe-west1']
```
## 🖥️ VPS-Deployment
### Server-Setup
```bash
#!/bin/bash
# server-setup.sh
# System aktualisieren
sudo apt update && sudo apt upgrade -y
# Node.js installieren
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
sudo apt-get install -y nodejs
# PostgreSQL installieren
sudo apt install postgresql postgresql-contrib -y
# Nginx installieren
sudo apt install nginx -y
# PM2 installieren
sudo npm install -g pm2
# Firewall konfigurieren
sudo ufw allow 22
sudo ufw allow 80
sudo ufw allow 443
sudo ufw enable
```
### Anwendung deployen
```bash
#!/bin/bash
# deploy-vps.sh
# Code aktualisieren
git pull origin main
# Abhängigkeiten installieren
npm install --production
# Datenbank migrieren
npm run migrate
# Anwendung starten
pm2 start ecosystem.config.js
# Nginx konfigurieren
sudo cp nginx.conf /etc/nginx/sites-available/ninja
sudo ln -s /etc/nginx/sites-available/ninja /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
```
### PM2-Konfiguration
```javascript
// ecosystem.config.js
module.exports = {
apps: [{
name: 'ninja-app',
script: 'server.js',
instances: 'max',
exec_mode: 'cluster',
env: {
NODE_ENV: 'production',
PORT: 3000
},
error_file: './logs/err.log',
out_file: './logs/out.log',
log_file: './logs/combined.log',
time: true
}]
};
```
## 🔧 Konfiguration
### Nginx-Konfiguration
```nginx
# nginx.conf
upstream ninja_app {
server 127.0.0.1:3000;
}
server {
listen 80;
server_name ninja.reptilfpv.de;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name ninja.reptilfpv.de;
ssl_certificate /etc/nginx/ssl/cert.pem;
ssl_certificate_key /etc/nginx/ssl/key.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256;
ssl_prefer_server_ciphers off;
# Security Headers
add_header X-Frame-Options DENY;
add_header X-Content-Type-Options nosniff;
add_header X-XSS-Protection "1; mode=block";
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Rate Limiting
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
limit_req zone=api burst=20 nodelay;
location / {
proxy_pass http://ninja_app;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
# Static Files
location /static/ {
alias /var/www/ninja/public/;
expires 1y;
add_header Cache-Control "public, immutable";
}
}
```
### SSL-Zertifikat
```bash
# Let's Encrypt
sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d ninja.reptilfpv.de
# Automatische Erneuerung
echo "0 12 * * * /usr/bin/certbot renew --quiet" | sudo crontab -
```
### Datenbank-Backup
```bash
#!/bin/bash
# backup.sh
# Tägliches Backup
pg_dump -h localhost -U ninja_user -d ninjaserver | gzip > backup_$(date +%Y%m%d).sql.gz
# Alte Backups löschen (älter als 30 Tage)
find /backups -name "backup_*.sql.gz" -mtime +30 -delete
# Backup nach S3 hochladen
aws s3 cp backup_$(date +%Y%m%d).sql.gz s3://ninja-backups/
```
## 📊 Monitoring
### Application Monitoring
```javascript
// monitoring.js
const prometheus = require('prom-client');
// Metriken definieren
const httpRequestDuration = new prometheus.Histogram({
name: 'http_request_duration_seconds',
help: 'Duration of HTTP requests in seconds',
labelNames: ['method', 'route', 'status_code']
});
const activeConnections = new prometheus.Gauge({
name: 'active_connections',
help: 'Number of active connections'
});
// Metriken-Endpoint
app.get('/metrics', (req, res) => {
res.set('Content-Type', prometheus.register.contentType);
res.end(prometheus.register.metrics());
});
```
### Log-Monitoring
```bash
# Logstash-Konfiguration
input {
file {
path => "/var/log/ninja/*.log"
type => "ninja-logs"
}
}
filter {
if [type] == "ninja-logs" {
grok {
match => { "message" => "%{TIMESTAMP_ISO8601:timestamp} %{LOGLEVEL:level} %{GREEDYDATA:message}" }
}
}
}
output {
elasticsearch {
hosts => ["localhost:9200"]
index => "ninja-logs-%{+YYYY.MM.dd}"
}
}
```
### Alerting
```yaml
# alertmanager.yml
global:
smtp_smarthost: 'localhost:587'
smtp_from: 'alerts@ninjaparkour.de'
route:
group_by: ['alertname']
group_wait: 10s
group_interval: 10s
repeat_interval: 1h
receiver: 'web.hook'
receivers:
- name: 'web.hook'
email_configs:
- to: 'admin@ninjaparkour.de'
subject: 'Ninja Parkour Alert: {{ .GroupLabels.alertname }}'
body: '{{ range .Alerts }}{{ .Annotations.description }}{{ end }}'
```
## 🔄 CI/CD
### GitHub Actions
```yaml
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '18'
- run: npm ci
- run: npm test
deploy:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Deploy to production
run: |
ssh user@server 'cd /var/www/ninja && git pull && npm install && pm2 restart ninja-app'
```
### GitLab CI
```yaml
# .gitlab-ci.yml
stages:
- test
- deploy
test:
stage: test
script:
- npm ci
- npm test
deploy:
stage: deploy
script:
- ssh user@server 'cd /var/www/ninja && git pull && npm install && pm2 restart ninja-app'
only:
- main
```
### Jenkins Pipeline
```groovy
// Jenkinsfile
pipeline {
agent any
stages {
stage('Test') {
steps {
sh 'npm ci'
sh 'npm test'
}
}
stage('Deploy') {
steps {
sh 'ssh user@server "cd /var/www/ninja && git pull && npm install && pm2 restart ninja-app"'
}
}
}
}
```
## 🔧 Wartung
### Automatische Updates
```bash
#!/bin/bash
# auto-update.sh
# System-Updates
sudo apt update && sudo apt upgrade -y
# Anwendung-Updates
cd /var/www/ninja
git pull origin main
npm install --production
pm2 restart ninja-app
# Datenbank-Updates
npm run migrate
```
### Health Checks
```bash
#!/bin/bash
# health-check.sh
# Anwendung prüfen
curl -f http://localhost:3000/health || exit 1
# Datenbank prüfen
psql -d ninjaserver -c "SELECT NOW();" || exit 1
# Speicher prüfen
if [ $(df / | awk 'NR==2{print $5}' | sed 's/%//') -gt 80 ]; then
echo "Disk space low"
exit 1
fi
```
### Rollback-Strategie
```bash
#!/bin/bash
# rollback.sh
# Vorherige Version wiederherstellen
cd /var/www/ninja
git checkout HEAD~1
npm install --production
pm2 restart ninja-app
# Datenbank-Rollback
psql -d ninjaserver < backup_previous.sql
```
---
**Hinweis:** Diese Deployment-Anleitung sollte an Ihre spezifischen Anforderungen angepasst werden. Testen Sie alle Schritte in einer Staging-Umgebung vor der Produktionsbereitstellung.

589
wiki/Entwicklerhandbuch.md Normal file
View File

@@ -0,0 +1,589 @@
# 🔧 Entwicklerhandbuch
Technische Dokumentation für Entwickler des Ninja Cross Parkour Systems.
## 📋 Inhaltsverzeichnis
- [🏗️ System-Architektur](#-system-architektur)
- [🛠️ Entwicklungsumgebung](#-entwicklungsumgebung)
- [📡 API-Integration](#-api-integration)
- [🗄️ Datenbank-Schema](#-datenbank-schema)
- [🔐 Authentifizierung](#-authentifizierung)
- [🧪 Testing](#-testing)
- [🚀 Deployment](#-deployment)
- [📊 Monitoring](#-monitoring)
- [🔧 Wartung](#-wartung)
## 🏗️ System-Architektur
### Übersicht
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Frontend │ │ Backend │ │ Database │
│ (Web UI) │◄──►│ (Node.js) │◄──►│ (PostgreSQL) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │ │
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ RFID Reader │ │ API Endpoints │ │ Achievement │
│ (Hardware) │ │ (REST) │ │ System │
└─────────────────┘ └─────────────────┘ └─────────────────┘
```
### Technologie-Stack
- **Backend:** Node.js + Express.js
- **Datenbank:** PostgreSQL
- **Frontend:** HTML5 + CSS3 + JavaScript
- **Authentifizierung:** JWT + bcrypt
- **API:** RESTful API
- **Maps:** Leaflet.js + OpenStreetMap
- **RFID:** Hardware-Integration
### Projektstruktur
```
ninjaserver/
├── server.js # Hauptserver-Datei
├── routes/
│ ├── api.js # API-Routen
│ ├── public.js # Öffentliche Routen
│ ├── private.js # Private Routen
│ ├── web.js # Web-Routen
│ └── admin.js # Admin-Routen
├── middleware/
│ ├── auth.js # Authentifizierung
│ ├── validation.js # Eingabe-Validierung
│ └── logging.js # Logging
├── models/
│ ├── Player.js # Spieler-Modell
│ ├── Time.js # Zeit-Modell
│ ├── Location.js # Standort-Modell
│ └── Achievement.js # Achievement-Modell
├── scripts/
│ ├── init-db.js # Datenbankinitialisierung
│ ├── create-user.js # Benutzer-Erstellung
│ └── daily_achievements.js # Tägliche Achievements
├── public/
│ ├── index.html # Hauptanwendung
│ ├── login.html # Login-Seite
│ ├── css/ # Stylesheets
│ └── js/ # JavaScript
├── test/
│ ├── api.test.js # API-Tests
│ ├── unit.test.js # Unit-Tests
│ └── integration.test.js # Integration-Tests
└── docs/
├── API.md # API-Dokumentation
├── ACHIEVEMENTS.md # Achievement-Dokumentation
└── wiki/ # Wiki-Dokumentation
```
## 🛠️ Entwicklungsumgebung
### Voraussetzungen
- **Node.js** v16 oder höher
- **PostgreSQL** 12 oder höher
- **Git** für Versionskontrolle
- **npm** oder **yarn** für Paketverwaltung
### Setup
```bash
# Repository klonen
git clone <repository-url>
cd ninjaserver
# Abhängigkeiten installieren
npm install
# Umgebungsvariablen konfigurieren
cp .env.example .env
# .env-Datei bearbeiten
# Datenbank initialisieren
npm run init-db
# Entwicklungsserver starten
npm run dev
```
### Entwicklungsskripte
```bash
# Entwicklungsserver mit Auto-Reload
npm run dev
# Tests ausführen
npm test
# Linting
npm run lint
# Datenbank zurücksetzen
npm run reset-db
# API-Dokumentation generieren
npm run docs
```
### IDE-Empfehlungen
- **Visual Studio Code** mit Extensions:
- ES6 code snippets
- PostgreSQL
- REST Client
- GitLens
## 📡 API-Integration
### Authentifizierung
```javascript
// API-Key Authentifizierung
const headers = {
'Authorization': 'Bearer YOUR_API_KEY',
'Content-Type': 'application/json'
};
// Session-basierte Authentifizierung
const session = await authenticateUser(username, password);
```
### API-Client Beispiel
```javascript
class NinjaParkourAPI {
constructor(apiKey, baseURL = 'http://localhost:3000') {
this.apiKey = apiKey;
this.baseURL = baseURL;
}
async request(endpoint, options = {}) {
const url = `${this.baseURL}${endpoint}`;
const config = {
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
...options.headers
},
...options
};
const response = await fetch(url, config);
return response.json();
}
// Spieler erstellen
async createPlayer(playerData) {
return this.request('/api/v1/public/players', {
method: 'POST',
body: JSON.stringify(playerData)
});
}
// Zeit messen
async recordTime(timeData) {
return this.request('/api/v1/private/create-time', {
method: 'POST',
body: JSON.stringify(timeData)
});
}
// Achievements abrufen
async getAchievements(playerId) {
return this.request(`/api/achievements/player/${playerId}`);
}
}
// Verwendung
const api = new NinjaParkourAPI('your-api-key');
const player = await api.createPlayer({
firstname: 'Max',
lastname: 'Mustermann',
birthdate: '1990-01-01',
rfiduid: 'AA:BB:CC:DD'
});
```
### WebSocket Integration
```javascript
// Real-time Updates
const socket = io('http://localhost:3000');
socket.on('timeRecorded', (data) => {
console.log('Neue Zeit:', data);
updateLeaderboard(data);
});
socket.on('achievementEarned', (data) => {
console.log('Neues Achievement:', data);
showNotification(data);
});
```
## 🗄️ Datenbank-Schema
### Tabellen-Übersicht
```sql
-- Spieler
CREATE TABLE players (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
firstname VARCHAR(50) NOT NULL,
lastname VARCHAR(50) NOT NULL,
birthdate DATE NOT NULL,
rfiduid VARCHAR(20) UNIQUE,
supabase_user_id UUID,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Standorte
CREATE TABLE locations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) UNIQUE NOT NULL,
latitude DECIMAL(10, 8) NOT NULL,
longitude DECIMAL(11, 8) NOT NULL,
time_threshold JSONB DEFAULT '{"seconds": 120}',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Zeiten
CREATE TABLE times (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
player_id UUID REFERENCES players(id),
location_id UUID REFERENCES locations(id),
recorded_time JSONB NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Achievements
CREATE TABLE achievements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
description TEXT,
category VARCHAR(50) NOT NULL,
condition_type VARCHAR(50) NOT NULL,
condition_value INTEGER NOT NULL,
icon VARCHAR(10),
points INTEGER DEFAULT 0,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Spieler-Achievements
CREATE TABLE player_achievements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
player_id UUID REFERENCES players(id),
achievement_id UUID REFERENCES achievements(id),
earned_at TIMESTAMP,
progress INTEGER DEFAULT 0,
is_completed BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
### Indizes
```sql
-- Performance-Indizes
CREATE INDEX idx_times_player_id ON times(player_id);
CREATE INDEX idx_times_location_id ON times(location_id);
CREATE INDEX idx_times_created_at ON times(created_at);
CREATE INDEX idx_player_achievements_player_id ON player_achievements(player_id);
CREATE INDEX idx_player_achievements_achievement_id ON player_achievements(achievement_id);
```
### PostgreSQL Funktionen
```sql
-- Achievement-Prüfung
CREATE OR REPLACE FUNCTION check_all_achievements(player_uuid UUID)
RETURNS VOID AS $$
BEGIN
PERFORM check_consistency_achievements(player_uuid);
PERFORM check_improvement_achievements(player_uuid);
PERFORM check_seasonal_achievements(player_uuid);
END;
$$ LANGUAGE plpgsql;
```
## 🔐 Authentifizierung
### API-Key Authentifizierung
```javascript
// Middleware für API-Key
const authenticateAPIKey = (req, res, next) => {
const authHeader = req.headers.authorization;
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'API-Key erforderlich' });
}
// Token validieren
const isValid = validateAPIKey(token);
if (!isValid) {
return res.status(401).json({ error: 'Ungültiger API-Key' });
}
req.apiKey = token;
next();
};
```
### Session-basierte Authentifizierung
```javascript
// Session-Middleware
const authenticateSession = (req, res, next) => {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Nicht authentifiziert' });
}
req.userId = req.session.userId;
next();
};
```
### JWT-Token
```javascript
// JWT-Token generieren
const generateJWT = (user) => {
return jwt.sign(
{ userId: user.id, username: user.username },
process.env.JWT_SECRET,
{ expiresIn: '24h' }
);
};
// JWT-Token validieren
const validateJWT = (token) => {
try {
return jwt.verify(token, process.env.JWT_SECRET);
} catch (error) {
return null;
}
};
```
## 🧪 Testing
### Unit-Tests
```javascript
// test/unit/Player.test.js
const { Player } = require('../../models/Player');
describe('Player Model', () => {
test('should create player with valid data', () => {
const playerData = {
firstname: 'Max',
lastname: 'Mustermann',
birthdate: '1990-01-01',
rfiduid: 'AA:BB:CC:DD'
};
const player = new Player(playerData);
expect(player.firstname).toBe('Max');
expect(player.lastname).toBe('Mustermann');
});
});
```
### Integration-Tests
```javascript
// test/integration/api.test.js
const request = require('supertest');
const app = require('../../server');
describe('API Endpoints', () => {
test('POST /api/v1/public/players', async () => {
const playerData = {
firstname: 'Max',
lastname: 'Mustermann',
birthdate: '1990-01-01',
rfiduid: 'AA:BB:CC:DD'
};
const response = await request(app)
.post('/api/v1/public/players')
.send(playerData)
.expect(201);
expect(response.body.success).toBe(true);
expect(response.body.data.firstname).toBe('Max');
});
});
```
### API-Tests ausführen
```bash
# Alle Tests
npm test
# Unit-Tests
npm run test:unit
# Integration-Tests
npm run test:integration
# Coverage-Report
npm run test:coverage
```
## 🚀 Deployment
### Produktionsumgebung
```bash
# Abhängigkeiten installieren
npm install --production
# Umgebungsvariablen setzen
export NODE_ENV=production
export DB_HOST=production-db-host
export DB_PASSWORD=secure-password
# Server starten
npm start
```
### Docker-Container
```dockerfile
# Dockerfile
FROM node:16-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --production
COPY . .
EXPOSE 3000
CMD ["npm", "start"]
```
### Nginx-Konfiguration
```nginx
server {
listen 80;
server_name ninja.reptilfpv.de;
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}
```
### SSL-Zertifikat
```bash
# Let's Encrypt
certbot --nginx -d ninja.reptilfpv.de
```
## 📊 Monitoring
### Logging
```javascript
// Winston Logger
const winston = require('winston');
const logger = winston.createLogger({
level: 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json()
),
transports: [
new winston.transports.File({ filename: 'logs/error.log', level: 'error' }),
new winston.transports.File({ filename: 'logs/combined.log' })
]
});
```
### Health-Checks
```javascript
// Health-Check Endpoint
app.get('/health', async (req, res) => {
try {
// Datenbank-Verbindung prüfen
await db.query('SELECT 1');
res.json({
status: 'healthy',
timestamp: new Date().toISOString(),
uptime: process.uptime()
});
} catch (error) {
res.status(500).json({
status: 'unhealthy',
error: error.message
});
}
});
```
### Metriken
```javascript
// Prometheus-Metriken
const prometheus = require('prom-client');
const httpRequestDuration = new prometheus.Histogram({
name: 'http_request_duration_seconds',
help: 'Duration of HTTP requests in seconds',
labelNames: ['method', 'route', 'status_code']
});
const activeConnections = new prometheus.Gauge({
name: 'active_connections',
help: 'Number of active connections'
});
```
## 🔧 Wartung
### Datenbank-Backup
```bash
# Backup erstellen
pg_dump -h localhost -U username -d ninjaserver > backup.sql
# Backup wiederherstellen
psql -h localhost -U username -d ninjaserver < backup.sql
```
### Log-Rotation
```bash
# Logrotate-Konfiguration
/var/log/ninjaserver/*.log {
daily
missingok
rotate 30
compress
delaycompress
notifempty
create 644 node node
postrotate
systemctl reload ninjaserver
endscript
}
```
### Performance-Optimierung
```sql
-- Query-Performance analysieren
EXPLAIN ANALYZE SELECT * FROM times
WHERE player_id = 'uuid'
ORDER BY created_at DESC;
-- Indizes hinzufügen
CREATE INDEX CONCURRENTLY idx_times_player_created
ON times(player_id, created_at DESC);
```
### Sicherheits-Updates
```bash
# Abhängigkeiten aktualisieren
npm audit
npm audit fix
# Sicherheits-Updates
npm update
```
---
**Hinweis:** Für detaillierte API-Dokumentation siehe [API Referenz](API-Referenz) und für Achievement-Details siehe [Achievement System](Achievement-System).

380
wiki/FAQ.md Normal file
View File

@@ -0,0 +1,380 @@
# ❓ FAQ - Häufige Fragen
Antworten auf häufig gestellte Fragen zum Ninja Cross Parkour System.
## 🚀 Installation und Setup
### Wie installiere ich das System?
Siehe [Schnellstart](Schnellstart) für eine detaillierte Anleitung. Kurz gesagt:
1. Repository klonen
2. `npm install` ausführen
3. `.env`-Datei konfigurieren
4. `npm run init-db` ausführen
5. `npm start` starten
### Welche Voraussetzungen benötige ich?
- Node.js v16 oder höher
- PostgreSQL 12 oder höher
- npm oder yarn
- Git für die Installation
### Wie konfiguriere ich die Datenbank?
Bearbeiten Sie die `.env`-Datei mit Ihren Datenbankdaten:
```env
DB_HOST=localhost
DB_PORT=5432
DB_NAME=ninjaserver
DB_USER=your_username
DB_PASSWORD=your_password
```
### Wie erstelle ich den ersten Admin-Benutzer?
```bash
npm run create-user
```
Standard-Anmeldedaten: `admin` / `admin123`
## 🔐 Authentifizierung
### Wie funktioniert die API-Authentifizierung?
Das System verwendet API-Keys für die Authentifizierung. Generieren Sie einen Key:
```bash
curl -X POST http://localhost:3000/api/v1/web/generate-api-key \
-H "Content-Type: application/json" \
-d '{"description": "Mein API Key"}'
```
### Wie verwende ich den API-Key?
Fügen Sie den Key in den Authorization Header ein:
```http
Authorization: Bearer YOUR_API_KEY_HERE
```
### Wie lange sind API-Keys gültig?
API-Keys sind standardmäßig unbegrenzt gültig, können aber mit einem Ablaufdatum versehen werden.
### Kann ich mehrere API-Keys haben?
Ja, Sie können beliebig viele API-Keys erstellen, z.B. für verschiedene Anwendungen oder Benutzer.
## 🏃‍♂️ Spieler und Zeiten
### Wie registriere ich einen neuen Spieler?
```bash
curl -X POST http://localhost:3000/api/v1/public/players \
-H "Content-Type: application/json" \
-d '{"firstname": "Max", "lastname": "Mustermann", "birthdate": "1990-01-01", "rfiduid": "AA:BB:CC:DD"}'
```
### Wie verknüpfe ich eine RFID-Karte mit einem Spieler?
```bash
curl -X POST http://localhost:3000/api/v1/public/link-by-rfid \
-H "Content-Type: application/json" \
-d '{"rfiduid": "AA:BB:CC:DD", "supabase_user_id": "uuid-here"}'
```
### Wie messe ich eine Zeit?
```bash
curl -X POST http://localhost:3000/api/v1/private/create-time \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"player_id": "AA:BB:CC:DD", "location_id": "Standort-Name", "recorded_time": "01:23.456"}'
```
### Welches Zeitformat wird verwendet?
Zeiten werden im Format `MM:SS.mmm` gespeichert:
- Minuten:Sekunden.Millisekunden
- Beispiel: `01:23.456` = 1 Minute, 23 Sekunden, 456 Millisekunden
### Was ist eine gültige Zeit?
- **Minimum:** 30 Sekunden
- **Maximum:** 10 Minuten
- **Schwelle:** Konfigurierbar pro Standort (Standard: 120 Sekunden)
## 🏆 Achievements
### Wie funktioniert das Achievement-System?
Das System überprüft automatisch täglich alle Spieler auf neue Achievements. Siehe [Achievement System](Achievement-System) für Details.
### Wann werden Achievements vergeben?
- **Automatisch:** Täglich um 23:59 Uhr
- **Manuell:** Über API-Endpoints
- **Sofort:** Bei bestimmten Aktionen
### Wie viele Achievements gibt es?
Das System hat 32 verschiedene Achievements in 4 Kategorien:
- Konsistenz-basierte (8)
- Verbesserungs-basierte (4)
- Saisonale (4)
- Monatliche (12)
- Jahreszeiten (4)
### Wie rufe ich Achievements ab?
```bash
# Alle Achievements
curl http://localhost:3000/api/achievements
# Spieler-Achievements
curl http://localhost:3000/api/achievements/player/{playerId}
# Spieler-Statistiken
curl http://localhost:3000/api/achievements/player/{playerId}/stats
```
## 📍 Standorte
### Wie erstelle ich einen neuen Standort?
```bash
curl -X POST http://localhost:3000/api/v1/private/create-location \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "München", "latitude": 48.1351, "longitude": 11.5820}'
```
### Wie ändere ich die Zeit-Schwelle eines Standorts?
```bash
curl -X PUT http://localhost:3000/api/v1/private/locations/{id}/threshold \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"threshold_seconds": 120}'
```
### Wie viele Standorte kann ich haben?
Es gibt keine Begrenzung der Anzahl der Standorte.
## 🔧 Technische Fragen
### Wie starte ich den Server?
```bash
# Entwicklung
npm run dev
# Produktion
npm start
```
### Wie überwache ich den Server?
```bash
# Logs anzeigen
tail -f logs/server.log
# Achievement-Logs
tail -f /var/log/ninjaserver_achievements.log
# Datenbank-Status
psql -d ninjaserver -c "SELECT NOW();"
```
### Wie teste ich die API?
```bash
# Test-Skript ausführen
node test-api.js
# Einzelne Endpoints testen
curl http://localhost:3000/api/v1/public/locations
```
### Wie sichere ich die Datenbank?
```bash
# Vollständiges Backup
pg_dump -h localhost -U username -d ninjaserver > backup.sql
# Wiederherstellung
psql -h localhost -U username -d ninjaserver < backup.sql
```
## 🐛 Fehlerbehebung
### "Port 3000 bereits belegt"
```bash
# Port freigeben
sudo lsof -ti:3000 | xargs kill -9
# Oder anderen Port verwenden
PORT=3001 npm start
```
### "Datenbank-Verbindung fehlgeschlagen"
1. PostgreSQL-Service prüfen: `sudo systemctl status postgresql`
2. Datenbank-Credentials in `.env` prüfen
3. Firewall-Einstellungen prüfen
### "API-Key funktioniert nicht"
1. API-Key neu generieren
2. Authorization Header prüfen: `Bearer YOUR_API_KEY`
3. Token-Ablaufzeit prüfen
### "Zeit wird nicht gespeichert"
1. Gültige Zeit prüfen (innerhalb der Schwelle)
2. Standort korrekt ausgewählt
3. Internetverbindung prüfen
4. Seite neu laden
### "Achievements werden nicht vergeben"
1. Tägliche Prüfung abwarten
2. Bedingungen erfüllt prüfen
3. System-Status prüfen
4. Administrator kontaktieren
## 📊 Statistiken und Monitoring
### Wie rufe ich Statistiken ab?
```bash
# Admin-Statistiken
curl -H "Authorization: Bearer ADMIN_TOKEN" http://localhost:3000/api/v1/admin/stats
# Seiten-Statistiken
curl -H "Authorization: Bearer ADMIN_TOKEN" http://localhost:3000/api/v1/admin/page-stats
```
### Wie überwache ich die Performance?
```sql
-- Langsamste Queries
SELECT query, calls, total_time, mean_time
FROM pg_stat_statements
ORDER BY mean_time DESC LIMIT 10;
-- Index-Nutzung
SELECT schemaname, tablename, indexname, idx_scan
FROM pg_stat_user_indexes
ORDER BY idx_scan DESC;
```
### Wie prüfe ich die Datenbank-Größe?
```sql
-- Gesamtgröße
SELECT pg_size_pretty(pg_database_size('ninjaserver'));
-- Tabellen-Größen
SELECT tablename, pg_size_pretty(pg_total_relation_size(tablename)) as size
FROM pg_tables WHERE schemaname = 'public'
ORDER BY pg_total_relation_size(tablename) DESC;
```
## 🔒 Sicherheit
### Wie sichere ich das System?
- Standardpasswörter ändern
- HTTPS in der Produktion verwenden
- Regelmäßige Backups
- API-Keys regelmäßig rotieren
- Firewall konfigurieren
### Wie ändere ich das Admin-Passwort?
```bash
# Neuen Admin-Benutzer erstellen
npm run create-user
# Oder direkt in der Datenbank
UPDATE adminusers SET password_hash = '$2b$10$...' WHERE username = 'admin';
```
### Wie deaktiviere ich einen API-Key?
```sql
UPDATE api_tokens SET is_active = false WHERE token = 'YOUR_TOKEN';
```
## 🌐 Deployment
### Wie deploye ich in die Produktion?
1. Server vorbereiten (Node.js, PostgreSQL)
2. Code deployen
3. Umgebungsvariablen setzen
4. Datenbank initialisieren
5. Nginx konfigurieren
6. SSL-Zertifikat einrichten
### Wie konfiguriere ich Nginx?
```nginx
server {
listen 80;
server_name ninja.reptilfpv.de;
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}
```
### Wie richte ich SSL ein?
```bash
# Let's Encrypt
certbot --nginx -d ninja.reptilfpv.de
```
## 📱 Frontend
### Wie integriere ich das System in meine Website?
Verwenden Sie die REST API oder das Web-Interface. Siehe [API Referenz](API-Referenz) für Details.
### Gibt es eine mobile App?
Eine native mobile App ist geplant, aber noch nicht verfügbar. Das Web-Interface ist responsive und funktioniert auf allen Geräten.
### Wie passe ich das Design an?
Bearbeiten Sie die CSS-Dateien im `public/css/` Verzeichnis oder erstellen Sie ein eigenes Frontend mit der API.
## 🔄 Updates und Wartung
### Wie aktualisiere ich das System?
```bash
# Code aktualisieren
git pull origin main
# Abhängigkeiten aktualisieren
npm install
# Datenbank-Migrationen (falls vorhanden)
npm run migrate
# Server neu starten
npm restart
```
### Wie führe ich Wartungsaufgaben durch?
```sql
-- Tabellen analysieren
ANALYZE;
-- Indizes neu aufbauen
REINDEX DATABASE ninjaserver;
-- Vakuum durchführen
VACUUM ANALYZE;
```
### Wie lösche ich alte Daten?
```sql
-- Alte Zeiten löschen (älter als 1 Jahr)
DELETE FROM times WHERE created_at < NOW() - INTERVAL '1 year';
-- Alte Seitenaufrufe löschen (älter als 6 Monate)
DELETE FROM page_views WHERE created_at < NOW() - INTERVAL '6 months';
```
## 📞 Support
### Wo bekomme ich Hilfe?
- 📖 Konsultieren Sie diese Dokumentation
- 🔍 Schauen Sie in [Troubleshooting](Troubleshooting)
- 📧 Kontaktieren Sie den Systemadministrator
- 🐛 Melden Sie Bugs über das Issue-System
### Wie melde ich einen Bug?
1. Beschreiben Sie das Problem
2. Fügen Sie Logs hinzu
3. Geben Sie Schritte zur Reproduktion an
4. Erwähnen Sie Ihre Systemkonfiguration
### Wie schlage ich eine Verbesserung vor?
1. Beschreiben Sie die gewünschte Funktion
2. Erklären Sie den Nutzen
3. Geben Sie Beispiele an
4. Erwähnen Sie mögliche Implementierung
---
**Hinweis:** Diese FAQ wird regelmäßig aktualisiert. Bei Fragen, die hier nicht beantwortet werden, wenden Sie sich an den Support.

92
wiki/Home.md Normal file
View File

@@ -0,0 +1,92 @@
# 🏊‍♂️ Ninja Cross Parkour System Wiki
Willkommen zum **Ninja Cross Parkour System** - einem interaktiven Zeitmessungssystem für das Schwimmbad!
## 📋 Inhaltsverzeichnis
- [🏠 Home](Home) - Diese Seite
- [🚀 Schnellstart](Schnellstart) - Installation und erste Schritte
- [📖 Benutzerhandbuch](Benutzerhandbuch) - Anleitung für Endbenutzer
- [🔧 Entwicklerhandbuch](Entwicklerhandbuch) - Technische Dokumentation
- [📡 API Referenz](API-Referenz) - Vollständige API-Dokumentation
- [🏆 Achievement System](Achievement-System) - Gamification Features
- [🗄️ Datenbank](Datenbank) - Schema und Struktur
- [🔒 Sicherheit](Sicherheit) - Authentifizierung und Berechtigungen
- [🚀 Deployment](Deployment) - Produktionsumgebung
- [❓ FAQ](FAQ) - Häufige Fragen
- [🐛 Troubleshooting](Troubleshooting) - Problembehandlung
## 🎯 Was ist das Ninja Cross Parkour System?
Das **Ninja Cross Parkour System** ist ein innovatives Zeitmessungssystem, das speziell für Schwimmbäder entwickelt wurde. Es ermöglicht es Besuchern, ihre Parkour-Zeiten zu messen, zu verfolgen und sich mit anderen zu vergleichen.
### ✨ Hauptfunktionen
- **⏱️ Präzise Zeitmessung** mit RFID-Technologie
- **🗺️ Interaktive Karte** mit Standortverwaltung
- **🏆 Achievement-System** mit 32 verschiedenen Erfolgen
- **📊 Statistiken** und Bestenlisten
- **🔔 Push-Benachrichtigungen** für neue Rekorde
- **🌐 REST API** für Integrationen
- **📱 Responsive Web-Interface** für alle Geräte
### 🎮 Wie funktioniert es?
1. **Spieler registrieren** sich über das Web-Interface
2. **RFID-Karten** werden mit Spielerprofilen verknüpft
3. **Zeitmessung** erfolgt automatisch beim Start/Stopp
4. **Achievements** werden automatisch vergeben
5. **Statistiken** werden in Echtzeit aktualisiert
## 🏗️ System-Architektur
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Frontend │ │ Backend │ │ Database │
│ (Web UI) │◄──►│ (Node.js) │◄──►│ (PostgreSQL) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │ │
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ RFID Reader │ │ API Endpoints │ │ Achievement │
│ (Hardware) │ │ (REST) │ │ System │
└─────────────────┘ └─────────────────┘ └─────────────────┘
```
## 🎯 Zielgruppen
### 👥 Endbenutzer (Schwimmbadbesucher)
- Zeitmessung und -verfolgung
- Achievement-Sammlung
- Statistiken und Fortschritt
- Bestenlisten
### 👨‍💼 Administratoren
- Spieler- und Standortverwaltung
- System-Monitoring
- Statistiken und Berichte
- API-Key Management
### 👨‍💻 Entwickler
- API-Integration
- Custom Frontend
- Datenbank-Zugriff
- System-Erweiterungen
## 🚀 Schnellstart
Für einen schnellen Einstieg siehe [Schnellstart](Schnellstart).
## 📞 Support
Bei Fragen oder Problemen:
- 📖 Konsultieren Sie die [FAQ](FAQ)
- 🔍 Schauen Sie in [Troubleshooting](Troubleshooting)
- 📧 Kontaktieren Sie den Systemadministrator
---
**Version:** 1.0.0
**Letzte Aktualisierung:** $(date)
**Autor:** Carsten Graf

Some files were not shown because too many files have changed in this diff Show More