Update
This commit is contained in:
1
.dockerignore
Normal file
1
.dockerignore
Normal file
@@ -0,0 +1 @@
|
||||
.env
|
||||
107
.gitignore
vendored
Normal file
107
.gitignore
vendored
Normal 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
217
ACHIEVEMENTS.md
Normal 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
779
API.md
Normal 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
222
README.md
Normal 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
51
apache-ssl-config.conf
Normal 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
335
config/blacklist-db.js
Normal 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
176
config/blacklist.js
Normal 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
336
config/levenshtein.js
Normal 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
20
docker-compose.yml
Normal 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
18
dockerfile
Normal 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
880
lib/achievementSystem.js
Normal 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
162
lib/push-service.js
Normal 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();
|
||||
13
ninjacross-8a77c8ef79a7.json
Normal file
13
ninjacross-8a77c8ef79a7.json
Normal 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
7461
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
47
package.json
Normal file
47
package.json
Normal 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
187
pentest/enumerate.py
Normal 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")
|
||||
312
pentest/realistic_enumeration.py
Normal file
312
pentest/realistic_enumeration.py
Normal 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
336
public/404.html
Normal 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
254
public/admin-dashboard.html
Normal 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">×</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">×</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')">×</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>© 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
55
public/adminlogin.html
Normal 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>© 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
244
public/agb.html
Normal 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>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
753
public/css/admin-dashboard.css
Normal file
753
public/css/admin-dashboard.css
Normal 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
294
public/css/adminlogin.css
Normal 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
1981
public/css/dashboard.css
Normal file
File diff suppressed because it is too large
Load Diff
500
public/css/generator.css
Normal file
500
public/css/generator.css
Normal 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
1289
public/css/index.css
Normal file
File diff suppressed because it is too large
Load Diff
486
public/css/login.css
Normal file
486
public/css/login.css
Normal 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;
|
||||
}
|
||||
}
|
||||
305
public/css/reset-password.css
Normal file
305
public/css/reset-password.css
Normal 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
902
public/dashboard.html
Normal 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')">×</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')">×</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="© 2024 NinjaCross. Alle Rechte vorbehalten." data-en="© 2024 NinjaCross. All rights reserved.">© 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
362
public/datenschutz.html
Normal 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>© 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>
|
||||
181
public/email-templates/EMAIL-COMPATIBILITY-GUIDE.md
Normal file
181
public/email-templates/EMAIL-COMPATIBILITY-GUIDE.md
Normal 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! 📧✨
|
||||
157
public/email-templates/README.md
Normal file
157
public/email-templates/README.md
Normal 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** 🥷
|
||||
119
public/email-templates/TEMPLATES-OVERVIEW.md
Normal file
119
public/email-templates/TEMPLATES-OVERVIEW.md
Normal 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!** ✨
|
||||
174
public/email-templates/URL-CONFIGURATION-GUIDE.md
Normal file
174
public/email-templates/URL-CONFIGURATION-GUIDE.md
Normal 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! 🚀
|
||||
274
public/email-templates/change-email.html
Normal file
274
public/email-templates/change-email.html
Normal 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>
|
||||
29
public/email-templates/change-email.txt
Normal file
29
public/email-templates/change-email.txt
Normal 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.
|
||||
300
public/email-templates/invite-user.html
Normal file
300
public/email-templates/invite-user.html
Normal 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>
|
||||
34
public/email-templates/invite-user.txt
Normal file
34
public/email-templates/invite-user.txt
Normal 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.
|
||||
257
public/email-templates/magic-link.html
Normal file
257
public/email-templates/magic-link.html
Normal 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>
|
||||
25
public/email-templates/magic-link.txt
Normal file
25
public/email-templates/magic-link.txt
Normal 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.
|
||||
327
public/email-templates/reauthentication.html
Normal file
327
public/email-templates/reauthentication.html
Normal 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>
|
||||
30
public/email-templates/reauthentication.txt
Normal file
30
public/email-templates/reauthentication.txt
Normal 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.
|
||||
249
public/email-templates/reset-password-compatible.html
Normal file
249
public/email-templates/reset-password-compatible.html
Normal 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>
|
||||
301
public/email-templates/reset-password-optimized.html
Normal file
301
public/email-templates/reset-password-optimized.html
Normal 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>
|
||||
323
public/email-templates/reset-password-table.html
Normal file
323
public/email-templates/reset-password-table.html
Normal 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>
|
||||
294
public/email-templates/reset-password.html
Normal file
294
public/email-templates/reset-password.html
Normal 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>
|
||||
32
public/email-templates/reset-password.txt
Normal file
32
public/email-templates/reset-password.txt
Normal 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.
|
||||
208
public/email-templates/welcome-email-compatible.html
Normal file
208
public/email-templates/welcome-email-compatible.html
Normal 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>
|
||||
187
public/email-templates/welcome-email-simple.html
Normal file
187
public/email-templates/welcome-email-simple.html
Normal 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>
|
||||
274
public/email-templates/welcome-email.html
Normal file
274
public/email-templates/welcome-email.html
Normal 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>
|
||||
32
public/email-templates/welcome-email.txt
Normal file
32
public/email-templates/welcome-email.txt
Normal 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
85
public/generator.html
Normal 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>© 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
241
public/impressum.html
Normal 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>© 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
262
public/index.html
Normal 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="© 2024 NinjaCross. Alle Rechte vorbehalten." data-en="© 2024 NinjaCross. All rights reserved.">© 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
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
91
public/js/adminlogin.js
Normal 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
601
public/js/cookie-consent.js
Normal 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">×</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
105
public/js/cookie-utils.js
Normal 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
2163
public/js/dashboard.js
Normal file
File diff suppressed because it is too large
Load Diff
573
public/js/generator.js
Normal file
573
public/js/generator.js
Normal 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
808
public/js/index.js
Normal 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
291
public/js/login.js
Normal 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);
|
||||
33
public/js/page-tracking.js
Normal file
33
public/js/page-tracking.js
Normal 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
202
public/js/reset-password.js
Normal 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
121
public/login.html
Normal 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>© 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
55
public/manifest.json
Normal 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
BIN
public/pictures/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
public/pictures/icon-192.png
Normal file
BIN
public/pictures/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.4 KiB |
81
public/reset-password.html
Normal file
81
public/reset-password.html
Normal 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>© 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
137
public/sw.js
Normal 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
560
public/test-push.html
Normal 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
4969
routes/api.js
Normal file
File diff suppressed because it is too large
Load Diff
176
routes/public.js
Normal file
176
routes/public.js
Normal 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;
|
||||
198
scripts/best_time_achievements.js
Normal file
198
scripts/best_time_achievements.js
Normal 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 };
|
||||
141
scripts/best_time_notifications.js
Normal file
141
scripts/best_time_notifications.js
Normal 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
109
scripts/create-user.js
Normal 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 };
|
||||
110
scripts/daily_achievements.js
Normal file
110
scripts/daily_achievements.js
Normal 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 };
|
||||
285
scripts/enhanced_player_notifications.js
Normal file
285
scripts/enhanced_player_notifications.js
Normal 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
|
||||
};
|
||||
134
scripts/fix_player_subscriptions.js
Normal file
134
scripts/fix_player_subscriptions.js
Normal 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
107
scripts/init-db.js
Normal 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 };
|
||||
129
scripts/iphone_notifications.js
Normal file
129
scripts/iphone_notifications.js
Normal 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
87
scripts/setup-players.js
Normal 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
108
scripts/setup_cron.js
Normal 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 };
|
||||
94
scripts/simulate-new-time.js
Normal file
94
scripts/simulate-new-time.js
Normal 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();
|
||||
50
scripts/test-achievements.js
Normal file
50
scripts/test-achievements.js
Normal 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();
|
||||
48
scripts/test-immediate-achievements.js
Normal file
48
scripts/test-immediate-achievements.js
Normal 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();
|
||||
75
scripts/test-multiple-achievements.js
Normal file
75
scripts/test-multiple-achievements.js
Normal 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
380
server.js
Normal 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
89
ssl-setup-guide.md
Normal 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
285
swagger.js
Normal 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
46
test-achievements.js
Normal 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
752
wiki/API-Referenz.md
Normal 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
479
wiki/Achievement-System.md
Normal 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
261
wiki/Benutzerhandbuch.md
Normal 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
588
wiki/Datenbank.md
Normal 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
642
wiki/Deployment.md
Normal 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
589
wiki/Entwicklerhandbuch.md
Normal 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
380
wiki/FAQ.md
Normal 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
92
wiki/Home.md
Normal 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
Reference in New Issue
Block a user