archive legacy code, begin clean rewrite
legacy branch preserves all prior code. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,2 +0,0 @@
|
||||
TWITCH_CLIENT_ID=op://Private/WhatToPlay/TWITCH_CLIENT_ID
|
||||
TWITCH_CLIENT_SECRET=op://Private/WhatToPlay/TWITCH_CLIENT_SECRET
|
||||
@@ -1,16 +0,0 @@
|
||||
# Backend URL (wo läuft dein Express Server?)
|
||||
# Uberspace / eigenes Backend
|
||||
VITE_API_URL=https://your-username.uber.space
|
||||
|
||||
# GitHub Pages (wenn du GitHub Pages nutzt, aber Uberspace Backend)
|
||||
# VITE_API_URL=https://your-username.uber.space
|
||||
|
||||
# Lokales Backend (für Development mit separatem Backend)
|
||||
# VITE_API_URL=http://localhost:3000
|
||||
|
||||
# Base Path (für URLs und Routing)
|
||||
# GitHub Pages deployment:
|
||||
# VITE_BASE_PATH=/whattoplay/
|
||||
|
||||
# Uberspace deployment (root):
|
||||
# VITE_BASE_PATH=/
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -6,10 +6,7 @@ node_modules
|
||||
.env
|
||||
.env.*
|
||||
!.env.*.example
|
||||
!.env.1password
|
||||
*.secret.*
|
||||
*.key
|
||||
*.pem
|
||||
!.env.example
|
||||
|
||||
# IGDB cache (generated at runtime)
|
||||
server/data/igdb-cache.json
|
||||
@@ -23,3 +20,6 @@ coverage
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# bun
|
||||
bun.lock
|
||||
|
||||
131
ARCHITECTURE.md
131
ARCHITECTURE.md
@@ -1,131 +0,0 @@
|
||||
# WhatToPlay - Architektur Entscheidung
|
||||
|
||||
## Problem: Gaming Platform APIs für iOS/Web
|
||||
|
||||
### Services Status:
|
||||
|
||||
- ✅ **Steam**: Öffentliche Web API (`GetOwnedGames`) - funktioniert im Browser/iOS
|
||||
- ⚠️ **GOG**: Galaxy Library API - benötigt OAuth (Server-Side Token Exchange)
|
||||
- ❌ **Epic Games**: Keine öffentliche API - nur über Legendary CLI (Python)
|
||||
- ❌ **Amazon Games**: Keine öffentliche API - nur über Nile CLI (Python)
|
||||
|
||||
### Warum CLI-Tools nicht funktionieren:
|
||||
|
||||
```
|
||||
❌ Python/Node CLI Tools (Legendary, Nile, gogdl)
|
||||
└─> Benötigen native Runtime
|
||||
└─> Funktioniert NICHT auf iOS
|
||||
└─> Funktioniert NICHT im Browser
|
||||
└─> Funktioniert NICHT als reine Web-App
|
||||
```
|
||||
|
||||
## Lösung: Hybrid-Architektur
|
||||
|
||||
### Phase 1: MVP (Jetzt)
|
||||
|
||||
```
|
||||
Frontend (React/Ionic)
|
||||
↓
|
||||
Steam Web API (direkt)
|
||||
- GetOwnedGames Endpoint
|
||||
- Keine Auth nötig (nur API Key)
|
||||
- Funktioniert im Browser
|
||||
```
|
||||
|
||||
### Phase 2: GOG Integration (wenn Backend da ist)
|
||||
|
||||
```
|
||||
Frontend (React/Ionic)
|
||||
↓
|
||||
Backend (Vercel Function / Cloudflare Worker)
|
||||
↓
|
||||
GOG Galaxy API
|
||||
- OAuth Token Exchange (Server-Side)
|
||||
- Library API mit Bearer Token
|
||||
- CORS-Safe
|
||||
```
|
||||
|
||||
### Phase 3: Epic/Amazon (Zukunft)
|
||||
|
||||
**Option A: Backend Proxy**
|
||||
|
||||
```
|
||||
Frontend → Backend → Epic GraphQL (Reverse-Engineered)
|
||||
→ Amazon Nile API
|
||||
```
|
||||
|
||||
**Option B: Manuelle Import-Funktion**
|
||||
|
||||
```
|
||||
User exportiert Library aus Epic/Amazon
|
||||
↓
|
||||
User uploaded JSON in App
|
||||
↓
|
||||
App parsed und zeigt an
|
||||
```
|
||||
|
||||
## Aktuelle Implementation
|
||||
|
||||
### Steam (✅ Funktioniert jetzt)
|
||||
|
||||
```javascript
|
||||
// fetch-steam.mjs
|
||||
const response = await fetch(
|
||||
`http://api.steampowered.com/IPlayerService/GetOwnedGames/v0001/`,
|
||||
{ params: { key, steamid, format: "json" } },
|
||||
);
|
||||
```
|
||||
|
||||
### GOG (⚠️ Vorbereitet, braucht Backend)
|
||||
|
||||
```javascript
|
||||
// Jetzt: Manueller Token aus Browser DevTools
|
||||
// Später: OAuth Flow über Backend
|
||||
const response = await fetch(
|
||||
`https://galaxy-library.gog.com/users/${userId}/releases`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
);
|
||||
```
|
||||
|
||||
### Epic/Amazon (❌ Placeholder)
|
||||
|
||||
```javascript
|
||||
// Aktuell: Leere JSON-Dateien als Platzhalter
|
||||
// Später: Backend-Integration oder manuelle Import-Funktion
|
||||
```
|
||||
|
||||
## Deployment Strategie
|
||||
|
||||
### Development (macOS - Jetzt)
|
||||
|
||||
```
|
||||
npm run fetch → Lokale Node.js Scripts holen Daten
|
||||
npm run dev → Vite Dev Server mit Hot Reload
|
||||
```
|
||||
|
||||
### Production (iOS/Web - Später)
|
||||
|
||||
```
|
||||
Frontend: Vercel/Netlify (Static React App)
|
||||
Backend: Vercel Functions (für GOG OAuth)
|
||||
Data: Supabase/Firebase (für User Libraries)
|
||||
```
|
||||
|
||||
## Nächste Schritte
|
||||
|
||||
1. ✅ **Steam**: Fertig implementiert
|
||||
2. 🔄 **GOG**: Manuelle Token-Eingabe (Development)
|
||||
3. 📝 **Epic/Amazon**: Placeholder JSON
|
||||
4. 🚀 **Backend**: OAuth-Service für GOG (Vercel Function)
|
||||
5. 📱 **iOS**: PWA mit Service Worker für Offline-Support
|
||||
|
||||
## Wichtige Limitierungen
|
||||
|
||||
- **Keine nativen CLI-Tools** in Production
|
||||
- **CORS** blockiert direkte Browser → Gaming APIs
|
||||
- **OAuth Secrets** können nicht im Browser gespeichert werden
|
||||
- **Backend ist Pflicht** für GOG/Epic/Amazon
|
||||
|
||||
---
|
||||
|
||||
**Fazit**: Für iOS/Web müssen wir ein Backend bauen. Steam funktioniert ohne Backend, GOG/Epic/Amazon brauchen Server-Side OAuth.
|
||||
Submodule GamePlaylist.io deleted from b9e8b6d19c
Submodule GamePlaylistMaker deleted from f695642da9
@@ -1,285 +0,0 @@
|
||||
# IMPLEMENTATION SUMMARY - Februar 2026
|
||||
|
||||
## ✅ Was wurde implementiert
|
||||
|
||||
### 1. Settings-Tab mit vollständiger Konfiguration
|
||||
|
||||
- **UI Component**: `src/pages/Settings/SettingsPage.tsx`
|
||||
- **Styling**: `src/pages/Settings/SettingsPage.css`
|
||||
- **Features**:
|
||||
- ✅ Separate Karten für jeden Gaming-Service
|
||||
- ✅ Input-Felder für API Keys, IDs, Tokens (sicher - mit `type="password"`)
|
||||
- ✅ Dropdown-Selektoren (z.B. Blizzard Region)
|
||||
- ✅ Config Export/Import (JSON Download/Upload)
|
||||
- ✅ "Alle Einstellungen löschen" Button
|
||||
- ✅ Responsive Design für iOS/Web
|
||||
|
||||
### 2. Integriertes Tutorial-System
|
||||
|
||||
- **Component**: `src/components/TutorialModal.tsx`
|
||||
- **Coverage**: 5 Services (Steam, GOG, Epic, Amazon, Blizzard)
|
||||
- **Pro Service**: 4-6 Schritte + Tipps
|
||||
- **Features**:
|
||||
- ✅ Step-by-Step Guides mit Code-Beispielen
|
||||
- ✅ Hinweise und Warnung-Boxen
|
||||
- ✅ Links zu offiziellen Dokumentationen
|
||||
- ✅ Modal-Dialog (nicht inline)
|
||||
|
||||
### 3. ConfigService - Sichere Speicherung
|
||||
|
||||
- **Service**: `src/services/ConfigService.ts`
|
||||
- **Storage-Backend**:
|
||||
- ✅ localStorage (schnell, 5-10MB)
|
||||
- ✅ IndexedDB (Backup, 50MB+)
|
||||
- ✅ Export/Import Funktionen
|
||||
- **Validierung**: Prüft auf erforderliche Felder
|
||||
- **Sicherheit**: Keine Verschlüsselung (würde Usability schaden)
|
||||
|
||||
### 4. Blizzard API Integration
|
||||
|
||||
- **Importer**: `scripts/fetch-blizzard.mjs`
|
||||
- **OAuth-Flow**: Client Credentials (Token Exchange)
|
||||
- **Unterstützte Games**:
|
||||
- World of Warcraft
|
||||
- Diablo III (Heroes)
|
||||
- Diablo IV
|
||||
- Overwatch 2
|
||||
- StarCraft II
|
||||
- Heroes of the Storm
|
||||
- Hearthstone
|
||||
- **Data**: Level, Class, Kills, Hardcore Flag, Last Updated
|
||||
|
||||
### 5. Cloudflare Workers Dokumentation
|
||||
|
||||
- **Datei**: `docs/CLOUDFLARE-WORKERS-SETUP.md`
|
||||
- **Coverage**:
|
||||
- ✅ GOG OAuth Worker (Complete)
|
||||
- ✅ Blizzard OAuth Worker (Complete)
|
||||
- ✅ Deployment Instructions
|
||||
- ✅ Security Best Practices
|
||||
- ✅ KV Store Setup
|
||||
- ✅ Debugging Guide
|
||||
|
||||
### 6. App Navigation Update
|
||||
|
||||
- **File**: `src/App.tsx`
|
||||
- **Änderung**: Settings-Tab hinzugefügt (#5 von 5)
|
||||
- **Icon**: `settingsOutline` von ionicons
|
||||
|
||||
### 7. Dokumentation & Guides
|
||||
|
||||
- **QUICK-START.md**: 5-Minuten Einstieg
|
||||
- **BLIZZARD-SETUP.md**: OAuth Konfiguration
|
||||
- **FEATURES-OVERVIEW.md**: Gesamtübersicht
|
||||
- **CLOUDFLARE-WORKERS-SETUP.md**: Backend Deployment
|
||||
- **config.local.json.example**: Config Template
|
||||
|
||||
---
|
||||
|
||||
## 📊 Code Statistics
|
||||
|
||||
| Komponente | Zeilen | Komplexität |
|
||||
| --------------------------- | ------ | -------------------- |
|
||||
| SettingsPage.tsx | 380 | Mittel |
|
||||
| TutorialModal.tsx | 420 | Mittel |
|
||||
| ConfigService.ts | 140 | Einfach |
|
||||
| fetch-blizzard.mjs | 180 | Mittel |
|
||||
| CLOUDFLARE-WORKERS-SETUP.md | 450 | Hoch (Dokumentation) |
|
||||
|
||||
**Gesamt neue Code**: ~1.570 Zeilen
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Architektur-Entscheidungen
|
||||
|
||||
### localStorage + IndexedDB Hybrid
|
||||
|
||||
```
|
||||
Warum?
|
||||
• localStorage: Schnell, einfach, < 5MB
|
||||
• IndexedDB: Großer Storage, Backup-ready
|
||||
• Beide Client-Side = Offline-Ready
|
||||
```
|
||||
|
||||
### Cloudflare Workers statt Vercel Functions
|
||||
|
||||
```
|
||||
Warum?
|
||||
• Zero Configuration (vs. Vercel config)
|
||||
• KV Store integriert (vs. external DB)
|
||||
• Better Edge Performance (distributed)
|
||||
• Free tier ist großzügig
|
||||
• Secrets natürlich geschützt
|
||||
```
|
||||
|
||||
### Client Credentials Flow (nicht Authorization Code)
|
||||
|
||||
```
|
||||
Warum?
|
||||
• Blizzard erlaubt nur Client Credentials
|
||||
• Keine User Consent nötig
|
||||
• Einfacher OAuth Flow
|
||||
• Secretmanagement einfacher
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Sicherheit
|
||||
|
||||
### ✅ Implementiert
|
||||
|
||||
- Client Secrets in Backend nur (Cloudflare KV Store)
|
||||
- Token Export/Import mit Warnung
|
||||
- Password Input Fields (verborgen)
|
||||
- CORS auf Cloudflare Worker konfigurierbar
|
||||
- State Parameter für CSRF (in Worker)
|
||||
|
||||
### ⚠️ Bewusst NICHT implementiert
|
||||
|
||||
- Token Verschlüsselung in localStorage (UX Impact)
|
||||
- 2FA für Settings (Overkill für MVP)
|
||||
- Audit Logs (später, wenn selbst-gehostet)
|
||||
- Rate Limiting (kommt auf Server-Side)
|
||||
|
||||
**Reasoning**: MVP-Fokus auf Usability, nicht auf Enterprise-Security
|
||||
|
||||
---
|
||||
|
||||
## 📈 Performance
|
||||
|
||||
| Metrik | Wert | Note |
|
||||
| ------------------- | ------ | --------------------- |
|
||||
| Settings Load | <10ms | localStorage nur |
|
||||
| Config Save | <1ms | IndexedDB async |
|
||||
| Tutorial Modal Open | <50ms | React render |
|
||||
| Export (1000 Games) | <200ms | JSON stringify |
|
||||
| Import (1000 Games) | <500ms | JSON parse + validate |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Deployment Readiness
|
||||
|
||||
### Frontend (Vite)
|
||||
|
||||
```
|
||||
Status: ✅ Production-Ready
|
||||
npm run build → dist/
|
||||
Deployment: Vercel, Netlify, GitHub Pages
|
||||
CORS: Handled via Cloudflare Worker
|
||||
```
|
||||
|
||||
### Backend (Cloudflare Workers)
|
||||
|
||||
```
|
||||
Status: ⚠️ Dokumentiert, nicht deployed
|
||||
Bedarf:
|
||||
1. Cloudflare Account (kostenlos)
|
||||
2. GOG Client ID + Secret
|
||||
3. Blizzard Client ID + Secret
|
||||
4. npx wrangler deploy
|
||||
```
|
||||
|
||||
### Data Storage
|
||||
|
||||
```
|
||||
Frontend: localStorage + IndexedDB
|
||||
Backend: Cloudflare KV Store (für Secrets)
|
||||
Optional: Supabase für Cloud-Sync
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 Noch zu tun für Production
|
||||
|
||||
### Sofort (< 1 Woche)
|
||||
|
||||
- [ ] Cloudflare Worker deployen
|
||||
- [ ] GOG/Blizzard Credentials besorgen
|
||||
- [ ] KV Store konfigurieren
|
||||
- [ ] CORS testen
|
||||
|
||||
### Bald (1-2 Wochen)
|
||||
|
||||
- [ ] Epic Games JSON Import UI
|
||||
- [ ] Amazon Games JSON Import UI
|
||||
- [ ] Token Refresh Logic
|
||||
- [ ] Error Boundary Components
|
||||
|
||||
### Later (2-4 Wochen)
|
||||
|
||||
- [ ] Home-Page Widgets
|
||||
- [ ] Playlists Feature
|
||||
- [ ] Discover/Tinder UI
|
||||
- [ ] PWA Service Worker
|
||||
|
||||
### Optional (4+ Wochen)
|
||||
|
||||
- [ ] Cloud-Sync (Supabase)
|
||||
- [ ] Native iOS App (React Native)
|
||||
- [ ] Social Features (Friends)
|
||||
- [ ] Recommendations Engine
|
||||
|
||||
---
|
||||
|
||||
## 🎓 Lernpunkte
|
||||
|
||||
### OAuth Flows
|
||||
|
||||
- ✅ Client Credentials (Blizzard)
|
||||
- ⚠️ Authorization Code (GOG, dokumentiert)
|
||||
- ❌ PKCE (zukünftig für Web)
|
||||
|
||||
### Storage Patterns
|
||||
|
||||
- ✅ Single Source of Truth (ConfigService)
|
||||
- ✅ Backup + Restore (IndexedDB)
|
||||
- ✅ Export/Import (JSON)
|
||||
|
||||
### Component Design
|
||||
|
||||
- ✅ Data-Driven Tutorials (TUTORIALS Objekt)
|
||||
- ✅ Observable Pattern (setState + Service)
|
||||
- ✅ Modal System (TutorialModal)
|
||||
|
||||
### Infrastructure
|
||||
|
||||
- ✅ Serverless (Cloudflare)
|
||||
- ✅ No Database (localStorage MVP)
|
||||
- ✅ Secret Management (KV Store)
|
||||
|
||||
---
|
||||
|
||||
## 📚 Referenzen
|
||||
|
||||
### Services & APIs
|
||||
|
||||
- [Steam Web API](https://developer.valvesoftware.com/wiki/Steam_Web_API)
|
||||
- [GOG Galaxy API](https://galaxy-library.gog.com/)
|
||||
- [Blizzard OAuth](https://develop.battle.net/documentation/guides/using-oauth)
|
||||
- [Cloudflare Workers](https://developers.cloudflare.com/workers/)
|
||||
|
||||
### Tech Stack
|
||||
|
||||
- React 18.2 + TypeScript
|
||||
- Ionic React (iOS Mode)
|
||||
- Vite 5.0
|
||||
- Cloudflare Workers
|
||||
|
||||
---
|
||||
|
||||
## 🎉 Ergebnis
|
||||
|
||||
**Komplette, produktionsreife Konfigurationsseite mit:**
|
||||
|
||||
- ✅ 5 Gaming-Services
|
||||
- ✅ Integriertes Tutorial-System
|
||||
- ✅ Sichere Speicherung
|
||||
- ✅ Export/Import Funktionalität
|
||||
- ✅ Zero Infrastructure Backend (Cloudflare)
|
||||
- ✅ iOS/Web kompatibel
|
||||
- ✅ Offline funktional
|
||||
- ✅ Umfassende Dokumentation
|
||||
|
||||
**Zeitaufwand**: ~2-3 Stunden
|
||||
**Code-Qualität**: Production-Ready
|
||||
**Dokumentation**: Exzellent
|
||||
318
QUICK-START.md
318
QUICK-START.md
@@ -1,318 +0,0 @@
|
||||
# WhatToPlay - Quick Start Guide
|
||||
|
||||
## 🚀 Schnelleinstieg (5 Minuten)
|
||||
|
||||
### 1. App öffnen
|
||||
|
||||
```bash
|
||||
cd /Users/felixfoertsch/Developer/whattoplay
|
||||
npm run dev
|
||||
# Opens: http://localhost:5173
|
||||
```
|
||||
|
||||
### 2. Settings-Tab öffnen
|
||||
|
||||
```
|
||||
Navbar unten rechts → "Einstellungen" Tab
|
||||
```
|
||||
|
||||
### 3. Steam integrieren (optional, funktioniert sofort)
|
||||
|
||||
```
|
||||
Settings Tab
|
||||
↓
|
||||
Karte "🎮 Steam"
|
||||
↓
|
||||
"?" Button → Tutorial Modal
|
||||
↓
|
||||
Folge den 6 Schritten:
|
||||
1. https://steamcommunity.com/dev/apikey
|
||||
2. Login & Accept ToS
|
||||
3. API Key kopieren
|
||||
4. https://www.steamcommunity.com/
|
||||
5. Auf Namen klicken
|
||||
6. Steam ID aus URL kopieren (z.B. 76561197960434622)
|
||||
↓
|
||||
Eintragen → Speichern
|
||||
↓
|
||||
Library Tab → 1103 Games erscheinen!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎮 Für jeden Service
|
||||
|
||||
### Steam ✅ (Funktioniert JETZT)
|
||||
|
||||
```
|
||||
Difficulty: ⭐ Einfach
|
||||
Time: 5 Minuten
|
||||
Status: Voll funktionsfähig
|
||||
```
|
||||
|
||||
### GOG ⚠️ (Funktioniert JETZT mit manuelem Token)
|
||||
|
||||
```
|
||||
Difficulty: ⭐⭐ Mittel
|
||||
Time: 10 Minuten
|
||||
Status: Development-ready
|
||||
Step: Tutorial → Browser DevTools → Token kopieren
|
||||
```
|
||||
|
||||
### Blizzard ⚠️ (Funktioniert JETZT mit Credentials)
|
||||
|
||||
```
|
||||
Difficulty: ⭐⭐ Mittel
|
||||
Time: 10 Minuten
|
||||
Status: Development-ready
|
||||
Step: Docs → OAuth → Client ID + Secret
|
||||
```
|
||||
|
||||
### Epic Games ⚠️ (Später, mit Backend)
|
||||
|
||||
```
|
||||
Difficulty: ⭐⭐⭐ Schwer
|
||||
Time: 30+ Minuten
|
||||
Status: Needs Cloudflare Worker
|
||||
Step: Warte auf Backend OAuth Proxy
|
||||
```
|
||||
|
||||
### Amazon Games ⚠️ (Später, mit Backend)
|
||||
|
||||
```
|
||||
Difficulty: ⭐⭐⭐ Schwer
|
||||
Time: 30+ Minuten
|
||||
Status: Needs Cloudflare Worker
|
||||
Step: Warte auf Backend OAuth Proxy
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💾 Config Management
|
||||
|
||||
### Export (Backup machen)
|
||||
|
||||
```
|
||||
Settings Tab
|
||||
↓
|
||||
"📦 Daten-Management"
|
||||
↓
|
||||
"Config exportieren"
|
||||
↓
|
||||
whattoplay-config.json herunterladen
|
||||
↓
|
||||
(WARNUNG: Enthält sensitive Daten! Sicher lagern!)
|
||||
```
|
||||
|
||||
### Import (Von anderem Device)
|
||||
|
||||
```
|
||||
Settings Tab
|
||||
↓
|
||||
"📦 Daten-Management"
|
||||
↓
|
||||
"Config importieren"
|
||||
↓
|
||||
whattoplay-config.json auswählen
|
||||
↓
|
||||
✓ Alles wiederhergestellt!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Häufige Probleme
|
||||
|
||||
### "Keine Games angezeigt"
|
||||
|
||||
```
|
||||
1. Settings-Tab überprüfen
|
||||
2. Alle Felder gefüllt? ✓
|
||||
3. Library-Tab laden lassen (30 Sekunden)
|
||||
4. Browser-Konsole öffnen (F12) → Fehler checken
|
||||
```
|
||||
|
||||
### "Steam ID nicht gültig"
|
||||
|
||||
```
|
||||
❌ Richtig: 76561197960434622 (lange Nummer)
|
||||
❌ Falsch: felixfoertsch (Name/Community ID)
|
||||
|
||||
→ Gehe zu https://www.steamcommunity.com/
|
||||
→ Öffne dein Profil
|
||||
→ URL ist: /profiles/76561197960434622/
|
||||
→ Diese Nummer kopieren!
|
||||
```
|
||||
|
||||
### "GOG Token abgelaufen"
|
||||
|
||||
```
|
||||
Tokens laufen nach ~24h ab
|
||||
|
||||
→ Settings Tab
|
||||
→ GOG Karte
|
||||
→ Neuer Token aus Browser (Follow Tutorial)
|
||||
→ Speichern
|
||||
```
|
||||
|
||||
### "Blizzard sagt 'invalid client'"
|
||||
|
||||
```
|
||||
1. Client ID/Secret überprüfen
|
||||
2. Battle.net Developer Portal:
|
||||
https://develop.battle.net
|
||||
3. "My Applications" öffnen
|
||||
4. Correct Credentials kopieren
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📱 Auf dem iPhone nutzen
|
||||
|
||||
### Option 1: Web App (Empfohlen)
|
||||
|
||||
```
|
||||
1. iPhone Safari
|
||||
2. Gehe zu https://whattoplay.vercel.app (später)
|
||||
3. Teilen → Home Screen hinzufügen
|
||||
4. App sieht aus wie native App!
|
||||
```
|
||||
|
||||
### Option 2: Localhost (Development)
|
||||
|
||||
```
|
||||
1. iPhone und Computer im gleichen WiFi
|
||||
2. Computer IP: 192.168.x.x
|
||||
3. iPhone Safari: 192.168.x.x:5173
|
||||
4. Funktioniert auch ohne Internet (offline!)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Workflow zum Hinzufügen neuer Games
|
||||
|
||||
```
|
||||
1. Spiel auf Steam/GOG/Epic spielen
|
||||
2. Settings speichern (automatisch täglich?)
|
||||
3. Library Tab öffnen
|
||||
4. Neue Spiele erscheinen
|
||||
5. Click auf Spiel → Details
|
||||
6. Zu Playlist hinzufügen (später)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 MVP vs. Production
|
||||
|
||||
### MVP (Jetzt, February 2026)
|
||||
|
||||
- ✅ Steam funktioniert perfekt
|
||||
- ✅ Settings-Tab mit Tutorials
|
||||
- ✅ GOG/Blizzard Development-ready
|
||||
- ⚠️ Epic/Amazon nur placeholder
|
||||
- ✅ Config Export/Import
|
||||
- ✅ Offline funktional (localStorage)
|
||||
|
||||
### Production (März+ 2026)
|
||||
|
||||
- Cloudflare Worker deployen
|
||||
- GOG/Blizzard OAuth automatisch
|
||||
- Epic/Amazon manueller Import
|
||||
- Home-Page Widgets
|
||||
- Playlists Feature
|
||||
- PWA + iOS App
|
||||
|
||||
---
|
||||
|
||||
## 📚 Dokumentation
|
||||
|
||||
| Datei | Inhalt |
|
||||
| ------------------------------------------------------------ | -------------------- |
|
||||
| [FEATURES-OVERVIEW.md](./FEATURES-OVERVIEW.md) | Was gibt es neues? |
|
||||
| [CLOUDFLARE-WORKERS-SETUP.md](./CLOUDFLARE-WORKERS-SETUP.md) | Backend deployen |
|
||||
| [BLIZZARD-SETUP.md](./BLIZZARD-SETUP.md) | Blizzard OAuth |
|
||||
| [GOG-SETUP.md](./GOG-SETUP.md) | GOG Token extraction |
|
||||
| [IOS-WEB-STRATEGY.md](./IOS-WEB-STRATEGY.md) | Gesamtstrategie |
|
||||
| [ARCHITECTURE.md](./ARCHITECTURE.md) | Technische Details |
|
||||
|
||||
---
|
||||
|
||||
## 💡 Pro Tipps
|
||||
|
||||
### Mehrere Accounts gleichzeitig
|
||||
|
||||
```
|
||||
Browser-Profile nutzen:
|
||||
↓
|
||||
Chrome/Firefox: Neue Person/Profil
|
||||
↓
|
||||
Unterschiedliche config.local.json je Profil
|
||||
↓
|
||||
Vergleiche deine Bibliothek mit Freunden!
|
||||
```
|
||||
|
||||
### Spiele schneller finden
|
||||
|
||||
```
|
||||
Library Tab
|
||||
↓
|
||||
Suchleiste (zukünftig):
|
||||
- Nach Titel suchen
|
||||
- Nach Plattform filtern
|
||||
- Nach Länge sortieren
|
||||
```
|
||||
|
||||
### Offline Modus
|
||||
|
||||
```
|
||||
1. Settings speichern (einmalig online)
|
||||
2. Dann brauchst du kein Internet mehr
|
||||
3. Daten in localStorage gespeichert
|
||||
4. Auf dem Flugzeug spielen? ✓ Funktioniert!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Nächste Schritte für dich
|
||||
|
||||
### Sofort testen
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# → Settings Tab → Steam Tutorial folgen
|
||||
```
|
||||
|
||||
### In 1 Woche
|
||||
|
||||
```
|
||||
- GOG oder Blizzard einrichten
|
||||
- Config exportieren
|
||||
- Alle Games konsolidiert sehen
|
||||
```
|
||||
|
||||
### In 2 Wochen
|
||||
|
||||
```
|
||||
- Cloudflare Worker aufsetzen
|
||||
- OAuth automatisieren
|
||||
- Epic/Amazon hinzufügen (einfacher)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ❓ Fragen?
|
||||
|
||||
Siehe `docs/` Ordner für detaillierte Guides:
|
||||
|
||||
```
|
||||
docs/
|
||||
├── FEATURES-OVERVIEW.md (Was gibt es neues?)
|
||||
├── CLOUDFLARE-WORKERS-SETUP.md (Zero-Infra Backend)
|
||||
├── BLIZZARD-SETUP.md (Blizzard OAuth)
|
||||
├── GOG-SETUP.md (GOG Token)
|
||||
├── IOS-WEB-STRATEGY.md (Gesamtvision)
|
||||
└── ARCHITECTURE.md (Tech Details)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Viel Spaß mit WhatToPlay! 🎮**
|
||||
84
README.md
84
README.md
@@ -1,84 +0,0 @@
|
||||
# WhatToPlay - Game Library Manager
|
||||
|
||||
Eine PWA zum Verwalten deiner Spielebibliotheken von Steam, GOG, Epic, und mehr.
|
||||
|
||||
## Features
|
||||
|
||||
- 📚 Alle Spiele an einem Ort
|
||||
- 🎮 Steam, GOG, Epic Games, Battle.net Integration
|
||||
- 📱 PWA - funktioniert auf iPhone, Android, Desktop
|
||||
- 🔒 Daten bleiben lokal (IndexedDB)
|
||||
- ⚡ Schnelle Tinder-Style Entdeckung
|
||||
|
||||
## Deployment
|
||||
|
||||
Die App läuft komplett auf Uberspace (~5€/Monat):
|
||||
- **Frontend**: PWA (statische Files)
|
||||
- **Backend**: Node.js Express Server (CORS-Proxy für Steam API)
|
||||
- **URL**: https://wtp.uber.space
|
||||
|
||||
Details zum Deployment siehe [UBERSPACE.md](UBERSPACE.md).
|
||||
|
||||
## Steam API Integration
|
||||
|
||||
### 1. Steam API Key bekommen
|
||||
|
||||
1. Gehe zu https://steamcommunity.com/dev/apikey
|
||||
2. Akzeptiere die Terms
|
||||
3. Domain: `localhost` (wird ignoriert)
|
||||
4. Kopiere deinen API Key
|
||||
|
||||
### 2. Steam ID finden
|
||||
|
||||
Option A: Steam Profil URL nutzen
|
||||
- `https://steamcommunity.com/id/DEINNAME/` → ID ist `DEINNAME`
|
||||
|
||||
Option B: SteamID Finder
|
||||
- https://steamid.io/
|
||||
|
||||
### 3. In der App konfigurieren
|
||||
|
||||
1. Öffne https://wtp.uber.space
|
||||
2. Gehe zu **Settings → Steam**
|
||||
3. Füge **Steam API Key** und **Steam ID** hinzu
|
||||
4. Klicke auf **Refresh** → Deine Spiele werden geladen! 🎉
|
||||
|
||||
## Architektur
|
||||
|
||||
```
|
||||
PWA (wtp.uber.space)
|
||||
↓ POST /api/steam/refresh
|
||||
Express Backend (wtp.uber.space:3000)
|
||||
↓ Forward mit API Key
|
||||
Steam Web API
|
||||
↓ Games List
|
||||
Backend → PWA → IndexedDB
|
||||
```
|
||||
|
||||
## Local Development
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Der Dev-Server nutzt Vite-Middleware für API-Calls, kein separates Backend nötig.
|
||||
|
||||
## Weitere Plattformen
|
||||
|
||||
- **GOG**: OAuth Flow (geplant)
|
||||
- **Epic Games**: Manueller Import (kein Public API)
|
||||
- **Battle.net**: OAuth Flow (geplant)
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- React + TypeScript
|
||||
- Ionic Framework (Mobile UI)
|
||||
- IndexedDB (lokale Persistenz)
|
||||
- Vite (Build Tool)
|
||||
- Node.js Express (Backend)
|
||||
- Uberspace (Hosting)
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
279
app.js
279
app.js
@@ -1,279 +0,0 @@
|
||||
const sourcesConfigUrl = "./data/sources.json";
|
||||
|
||||
const state = {
|
||||
allGames: [],
|
||||
mergedGames: [],
|
||||
search: "",
|
||||
sourceFilter: "all",
|
||||
sortBy: "title",
|
||||
sources: [],
|
||||
};
|
||||
|
||||
const ui = {
|
||||
grid: document.getElementById("gamesGrid"),
|
||||
summary: document.getElementById("summary"),
|
||||
searchInput: document.getElementById("searchInput"),
|
||||
sourceFilter: document.getElementById("sourceFilter"),
|
||||
sortSelect: document.getElementById("sortSelect"),
|
||||
refreshButton: document.getElementById("refreshButton"),
|
||||
template: document.getElementById("gameCardTemplate"),
|
||||
};
|
||||
|
||||
const normalizeTitle = (title) =>
|
||||
title.toLowerCase().replace(/[:\-]/g, " ").replace(/\s+/g, " ").trim();
|
||||
|
||||
const toDateValue = (value) => (value ? new Date(value).getTime() : 0);
|
||||
|
||||
const mergeGames = (games) => {
|
||||
const map = new Map();
|
||||
|
||||
games.forEach((game) => {
|
||||
const key = game.canonicalId || normalizeTitle(game.title);
|
||||
const entry = map.get(key) || {
|
||||
title: game.title,
|
||||
canonicalId: key,
|
||||
platforms: new Set(),
|
||||
sources: [],
|
||||
tags: new Set(),
|
||||
lastPlayed: null,
|
||||
playtimeHours: 0,
|
||||
};
|
||||
|
||||
entry.platforms.add(game.platform);
|
||||
game.tags?.forEach((tag) => entry.tags.add(tag));
|
||||
entry.sources.push({
|
||||
name: game.source,
|
||||
id: game.id,
|
||||
url: game.url,
|
||||
platform: game.platform,
|
||||
});
|
||||
|
||||
if (
|
||||
game.lastPlayed &&
|
||||
(!entry.lastPlayed || game.lastPlayed > entry.lastPlayed)
|
||||
) {
|
||||
entry.lastPlayed = game.lastPlayed;
|
||||
}
|
||||
|
||||
if (Number.isFinite(game.playtimeHours)) {
|
||||
entry.playtimeHours += game.playtimeHours;
|
||||
}
|
||||
|
||||
map.set(key, entry);
|
||||
});
|
||||
|
||||
return Array.from(map.values()).map((entry) => ({
|
||||
...entry,
|
||||
platforms: Array.from(entry.platforms),
|
||||
tags: Array.from(entry.tags),
|
||||
}));
|
||||
};
|
||||
|
||||
const sortGames = (games, sortBy) => {
|
||||
const sorted = [...games];
|
||||
sorted.sort((a, b) => {
|
||||
if (sortBy === "lastPlayed") {
|
||||
return toDateValue(b.lastPlayed) - toDateValue(a.lastPlayed);
|
||||
}
|
||||
if (sortBy === "platforms") {
|
||||
return b.platforms.length - a.platforms.length;
|
||||
}
|
||||
return a.title.localeCompare(b.title, "de");
|
||||
});
|
||||
return sorted;
|
||||
};
|
||||
|
||||
const filterGames = () => {
|
||||
const query = state.search.trim().toLowerCase();
|
||||
let filtered = [...state.mergedGames];
|
||||
|
||||
if (state.sourceFilter !== "all") {
|
||||
filtered = filtered.filter((game) =>
|
||||
game.sources.some((source) => source.name === state.sourceFilter),
|
||||
);
|
||||
}
|
||||
|
||||
if (query) {
|
||||
filtered = filtered.filter((game) => {
|
||||
const haystack = [
|
||||
game.title,
|
||||
...game.platforms,
|
||||
...game.tags,
|
||||
...game.sources.map((source) => source.name),
|
||||
]
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return haystack.includes(query);
|
||||
});
|
||||
}
|
||||
|
||||
return sortGames(filtered, state.sortBy);
|
||||
};
|
||||
|
||||
const renderSummary = (games) => {
|
||||
const totalGames = state.mergedGames.length;
|
||||
const totalSources = state.sources.length;
|
||||
const duplicates = state.allGames.length - state.mergedGames.length;
|
||||
const totalPlaytime = state.allGames.reduce(
|
||||
(sum, game) => sum + (game.playtimeHours || 0),
|
||||
0,
|
||||
);
|
||||
|
||||
ui.summary.innerHTML = [
|
||||
{
|
||||
label: "Konsolidierte Spiele",
|
||||
value: totalGames,
|
||||
},
|
||||
{
|
||||
label: "Quellen",
|
||||
value: totalSources,
|
||||
},
|
||||
{
|
||||
label: "Zusammengeführte Duplikate",
|
||||
value: Math.max(duplicates, 0),
|
||||
},
|
||||
{
|
||||
label: "Gesamte Spielzeit (h)",
|
||||
value: totalPlaytime.toFixed(1),
|
||||
},
|
||||
]
|
||||
.map(
|
||||
(item) => `
|
||||
<div class="summary-card">
|
||||
<h3>${item.label}</h3>
|
||||
<p>${item.value}</p>
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
.join("");
|
||||
};
|
||||
|
||||
const renderGames = (games) => {
|
||||
ui.grid.innerHTML = "";
|
||||
|
||||
games.forEach((game) => {
|
||||
const card = ui.template.content.cloneNode(true);
|
||||
card.querySelector(".title").textContent = game.title;
|
||||
card.querySelector(".badge").textContent =
|
||||
`${game.platforms.length} Plattformen`;
|
||||
card.querySelector(".meta").textContent = game.lastPlayed
|
||||
? `Zuletzt gespielt: ${new Date(game.lastPlayed).toLocaleDateString("de")}`
|
||||
: "Noch nicht gespielt";
|
||||
|
||||
const tagList = card.querySelector(".tag-list");
|
||||
game.tags.slice(0, 4).forEach((tag) => {
|
||||
const span = document.createElement("span");
|
||||
span.className = "tag";
|
||||
span.textContent = tag;
|
||||
tagList.appendChild(span);
|
||||
});
|
||||
|
||||
if (!game.tags.length) {
|
||||
const span = document.createElement("span");
|
||||
span.className = "tag";
|
||||
span.textContent = "Ohne Tags";
|
||||
tagList.appendChild(span);
|
||||
}
|
||||
|
||||
const sources = card.querySelector(".sources");
|
||||
game.sources.forEach((source) => {
|
||||
const item = document.createElement("div");
|
||||
item.className = "source-item";
|
||||
const name = document.createElement("span");
|
||||
name.textContent = source.name;
|
||||
const details = document.createElement("p");
|
||||
details.textContent = `${source.platform} · ${source.id}`;
|
||||
item.append(name, details);
|
||||
sources.appendChild(item);
|
||||
});
|
||||
|
||||
ui.grid.appendChild(card);
|
||||
});
|
||||
};
|
||||
|
||||
const populateSourceFilter = () => {
|
||||
ui.sourceFilter.innerHTML = '<option value="all">Alle Quellen</option>';
|
||||
state.sources.forEach((source) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = source.name;
|
||||
option.textContent = source.label;
|
||||
ui.sourceFilter.appendChild(option);
|
||||
});
|
||||
};
|
||||
|
||||
const updateUI = () => {
|
||||
const filtered = filterGames();
|
||||
renderSummary(filtered);
|
||||
renderGames(filtered);
|
||||
};
|
||||
|
||||
const loadSources = async () => {
|
||||
const response = await fetch(sourcesConfigUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error("Konnte sources.json nicht laden.");
|
||||
}
|
||||
|
||||
const config = await response.json();
|
||||
state.sources = config.sources;
|
||||
|
||||
const data = await Promise.all(
|
||||
config.sources.map(async (source) => {
|
||||
const sourceResponse = await fetch(source.file);
|
||||
if (!sourceResponse.ok) {
|
||||
throw new Error(`Konnte ${source.file} nicht laden.`);
|
||||
}
|
||||
const list = await sourceResponse.json();
|
||||
return list.map((game) => ({
|
||||
...game,
|
||||
source: source.name,
|
||||
platform: game.platform || source.platform,
|
||||
}));
|
||||
}),
|
||||
);
|
||||
|
||||
state.allGames = data.flat();
|
||||
state.mergedGames = mergeGames(state.allGames);
|
||||
};
|
||||
|
||||
const attachEvents = () => {
|
||||
ui.searchInput.addEventListener("input", (event) => {
|
||||
state.search = event.target.value;
|
||||
updateUI();
|
||||
});
|
||||
|
||||
ui.sourceFilter.addEventListener("change", (event) => {
|
||||
state.sourceFilter = event.target.value;
|
||||
updateUI();
|
||||
});
|
||||
|
||||
ui.sortSelect.addEventListener("change", (event) => {
|
||||
state.sortBy = event.target.value;
|
||||
updateUI();
|
||||
});
|
||||
|
||||
ui.refreshButton.addEventListener("click", async () => {
|
||||
ui.refreshButton.disabled = true;
|
||||
ui.refreshButton.textContent = "Lade ...";
|
||||
try {
|
||||
await loadSources();
|
||||
populateSourceFilter();
|
||||
updateUI();
|
||||
} finally {
|
||||
ui.refreshButton.disabled = false;
|
||||
ui.refreshButton.textContent = "Daten neu laden";
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const init = async () => {
|
||||
try {
|
||||
await loadSources();
|
||||
populateSourceFilter();
|
||||
attachEvents();
|
||||
updateUI();
|
||||
} catch (error) {
|
||||
ui.grid.innerHTML = `<div class="card">${error.message}</div>`;
|
||||
}
|
||||
};
|
||||
|
||||
init();
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"steam": {
|
||||
"apiKey": "YOUR_STEAM_API_KEY",
|
||||
"steamId": "YOUR_STEAM_ID"
|
||||
},
|
||||
"gog": {
|
||||
"userId": "",
|
||||
"accessToken": ""
|
||||
},
|
||||
"epic": {
|
||||
"email": "",
|
||||
"method": "manual"
|
||||
},
|
||||
"amazon": {
|
||||
"email": "",
|
||||
"method": "manual"
|
||||
},
|
||||
"blizzard": {
|
||||
"clientId": "",
|
||||
"clientSecret": "",
|
||||
"region": "eu"
|
||||
}
|
||||
}
|
||||
54
deploy.sh
54
deploy.sh
@@ -1,54 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
SERVER="wtp"
|
||||
REMOTE_HTML="~/html/"
|
||||
REMOTE_SERVER="~/whattoplay/server/"
|
||||
ENV_FILE=".env.1password"
|
||||
|
||||
echo "=== WhatToPlay Deploy ==="
|
||||
|
||||
# 1. Build frontend
|
||||
echo ""
|
||||
echo "[1/5] Building frontend..."
|
||||
npm run build
|
||||
|
||||
# 2. Deploy frontend
|
||||
echo ""
|
||||
echo "[2/5] Deploying frontend..."
|
||||
rsync -avz --delete dist/ "$SERVER:$REMOTE_HTML"
|
||||
|
||||
# 3. Deploy backend
|
||||
echo ""
|
||||
echo "[3/5] Deploying backend..."
|
||||
rsync -avz --delete \
|
||||
--exclude node_modules \
|
||||
--exclude data/igdb-cache.json \
|
||||
server/ "$SERVER:$REMOTE_SERVER"
|
||||
|
||||
# 4. Install backend dependencies on server
|
||||
echo ""
|
||||
echo "[4/5] Installing backend dependencies..."
|
||||
ssh "$SERVER" "cd $REMOTE_SERVER && npm install --production"
|
||||
|
||||
# 5. Inject secrets from 1Password and restart
|
||||
echo ""
|
||||
echo "[5/5] Updating secrets and restarting service..."
|
||||
|
||||
TWITCH_CLIENT_ID=$(op read "op://Private/WhatToPlay/TWITCH_CLIENT_ID")
|
||||
TWITCH_CLIENT_SECRET=$(op read "op://Private/WhatToPlay/TWITCH_CLIENT_SECRET")
|
||||
|
||||
ssh "$SERVER" "cat > ~/whattoplay.env << 'ENVEOF'
|
||||
PORT=3000
|
||||
ALLOWED_ORIGIN=https://wtp.uber.space
|
||||
TWITCH_CLIENT_ID=$TWITCH_CLIENT_ID
|
||||
TWITCH_CLIENT_SECRET=$TWITCH_CLIENT_SECRET
|
||||
ENVEOF
|
||||
chmod 600 ~/whattoplay.env"
|
||||
|
||||
ssh "$SERVER" "systemctl --user restart whattoplay"
|
||||
|
||||
echo ""
|
||||
echo "=== Deploy complete ==="
|
||||
echo "Frontend: https://wtp.uber.space"
|
||||
echo "Backend: https://wtp.uber.space/api/health"
|
||||
@@ -1,138 +0,0 @@
|
||||
# Blizzard Setup für WhatToPlay
|
||||
|
||||
## API OAuth Konfiguration
|
||||
|
||||
### 1. Battle.net Developer Portal öffnen
|
||||
|
||||
- Gehe zu https://develop.battle.net
|
||||
- Melde dich mit deinem Battle.net Account an
|
||||
|
||||
### 2. Application registrieren
|
||||
|
||||
- Klicke auf "Create Application"
|
||||
- Name: "WhatToPlay" (oder dein Projektname)
|
||||
- Website: https://whattoplay.local (für Development)
|
||||
- Beschreibung: "Game Library Manager"
|
||||
- Akzeptiere die ToS
|
||||
|
||||
### 3. OAuth Credentials kopieren
|
||||
|
||||
Nach der Registrierung siehst du:
|
||||
|
||||
- **Client ID** - die öffentliche ID
|
||||
- **Client Secret** - HALTEN Sie geheim! (Nur auf Server, nie im Browser!)
|
||||
|
||||
### 4. Redirect URI setzen
|
||||
|
||||
In deiner Application Settings:
|
||||
|
||||
```
|
||||
Redirect URIs:
|
||||
https://whattoplay-oauth.workers.dev/blizzard/callback (Production)
|
||||
http://localhost:3000/auth/callback (Development)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## config.local.json Setup
|
||||
|
||||
```json
|
||||
{
|
||||
"blizzard": {
|
||||
"clientId": "your_client_id_here",
|
||||
"clientSecret": "your_client_secret_here",
|
||||
"region": "eu"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Region Codes:
|
||||
|
||||
- `us` - North America
|
||||
- `eu` - Europe
|
||||
- `kr` - Korea
|
||||
- `tw` - Taiwan
|
||||
|
||||
---
|
||||
|
||||
## Blizzard Games, die unterstützt werden
|
||||
|
||||
1. **World of Warcraft** - Character-basiert
|
||||
2. **Diablo III** - Hero-basiert
|
||||
3. **Diablo IV** - Charakter-basiert
|
||||
4. **Overwatch 2** - Account-basiert
|
||||
5. **Starcraft II** - Campaign Progress
|
||||
6. **Heroes of the Storm** - Character-basiert
|
||||
7. **Hearthstone** - Deck-basiert
|
||||
|
||||
---
|
||||
|
||||
## Development vs Production
|
||||
|
||||
### Development (Lokal)
|
||||
|
||||
```bash
|
||||
# Teste mit lokalem Token
|
||||
npm run import
|
||||
|
||||
# Script verwendet config.local.json
|
||||
```
|
||||
|
||||
### Production (Mit Cloudflare Worker)
|
||||
|
||||
```
|
||||
Frontend → Cloudflare Worker → Blizzard OAuth
|
||||
↓
|
||||
Token Exchange
|
||||
(Client Secret sicher!)
|
||||
```
|
||||
|
||||
Siehe: [CLOUDFLARE-WORKERS-SETUP.md](./CLOUDFLARE-WORKERS-SETUP.md)
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Client ID invalid"
|
||||
|
||||
- Überprüfe dass die Client ID korrekt kopiert wurde
|
||||
- Stelle sicher dass du im Development Portal angemeldet bist
|
||||
|
||||
### "Redirect URI mismatch"
|
||||
|
||||
- Die Redirect URI muss exakt übereinstimmen
|
||||
- Beachte Protocol (https vs http)
|
||||
- Beachte Port-Nummern
|
||||
|
||||
### "No games found"
|
||||
|
||||
- Dein Account muss mindestens 1 Blizzard Game haben
|
||||
- Bei Diablo III: Character muss erstellt sein
|
||||
- Charaktere können bis zu 24h brauchen zum Erscheinen
|
||||
|
||||
### Token-Fehler in Production
|
||||
|
||||
- Client Secret ist abgelaufen → Neu generieren
|
||||
- Überprüfe Cloudflare Worker Logs:
|
||||
```bash
|
||||
npx wrangler tail whattoplay-blizzard
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sicherheit
|
||||
|
||||
🔒 **Wichtig:**
|
||||
|
||||
- **Client Secret** NIEMALS ins Frontend committen
|
||||
- Nutze Cloudflare KV Store oder Environment Variables
|
||||
- Token mit Ablaufdatum (expires_in) prüfen
|
||||
- Token nicht in Browser LocalStorage speichern (nur Session)
|
||||
|
||||
---
|
||||
|
||||
## Links
|
||||
|
||||
- [Battle.net Developer Portal](https://develop.battle.net)
|
||||
- [Blizzard OAuth Documentation](https://develop.battle.net/documentation/guides/using-oauth)
|
||||
- [Game Data APIs](https://develop.battle.net/documentation/guides/game-data-apis)
|
||||
@@ -1,421 +0,0 @@
|
||||
# Cloudflare Workers - Serverless OAuth Proxy
|
||||
|
||||
**Zero Infrastruktur, alles gekapselt** - So funktioniert der Proxy für GOG und Blizzard OAuth Flows.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Überblick
|
||||
|
||||
Statt auf einem eigenen Server zu hosten, nutzen wir **Cloudflare Workers** als serverless FaaS (Function as a Service):
|
||||
|
||||
```
|
||||
WhatToPlay Frontend Cloudflare Worker GOG/Blizzard API
|
||||
↓ ↓ ↓
|
||||
[Settings speichern] → [OAuth Token Exchange] ← [Bearer Token zurück]
|
||||
[API aufrufen] → [Token validieren]
|
||||
```
|
||||
|
||||
**Vorteile:**
|
||||
|
||||
- ✅ Keine Server zu verwalten
|
||||
- ✅ Kein Backend-Hosting nötig
|
||||
- ✅ Client Secrets geschützt (Server-Side)
|
||||
- ✅ Kostenlos bis 100.000 Anfragen/Tag
|
||||
- ✅ Überall deployed (weltweit verteilt)
|
||||
- ✅ Automatische CORS-Konfiguration
|
||||
|
||||
---
|
||||
|
||||
## 📋 Setup Anleitung
|
||||
|
||||
### 1. Cloudflare Account erstellen
|
||||
|
||||
```bash
|
||||
# Gehe zu https://dash.cloudflare.com
|
||||
# Registriere dich kostenfrei
|
||||
# Du brauchst keine Domain für Workers!
|
||||
```
|
||||
|
||||
### 2. Wrangler installieren (CLI Tool)
|
||||
|
||||
```bash
|
||||
npm install -D wrangler
|
||||
npx wrangler login
|
||||
```
|
||||
|
||||
### 3. Projekt initialisieren
|
||||
|
||||
```bash
|
||||
cd whattoplay
|
||||
npx wrangler init workers
|
||||
# oder für bestehendes Projekt:
|
||||
# npx wrangler init whattoplay-oauth --type javascript
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔐 GOG OAuth Worker
|
||||
|
||||
### Create `workers/gog-auth.js`:
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* GOG OAuth Proxy for WhatToPlay
|
||||
* Läuft auf: https://whattoplay-oauth.your-domain.workers.dev/gog/callback
|
||||
*/
|
||||
|
||||
const GOG_CLIENT_ID = "your_client_id";
|
||||
const GOG_CLIENT_SECRET = "your_client_secret"; // 🔒 KV Store (nicht in Code!)
|
||||
const GOG_REDIRECT_URI =
|
||||
"https://whattoplay-oauth.your-domain.workers.dev/gog/callback";
|
||||
|
||||
export default {
|
||||
async fetch(request) {
|
||||
const url = new URL(request.url);
|
||||
|
||||
// CORS Headers
|
||||
const headers = {
|
||||
"Access-Control-Allow-Origin": "https://whattoplay.local",
|
||||
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
};
|
||||
|
||||
// Preflight
|
||||
if (request.method === "OPTIONS") {
|
||||
return new Response(null, { headers });
|
||||
}
|
||||
|
||||
// 1. Initiiere OAuth Flow
|
||||
if (url.pathname === "/gog/authorize") {
|
||||
const authUrl = new URL("https://auth.gog.com/auth");
|
||||
authUrl.searchParams.append("client_id", GOG_CLIENT_ID);
|
||||
authUrl.searchParams.append("redirect_uri", GOG_REDIRECT_URI);
|
||||
authUrl.searchParams.append("response_type", "code");
|
||||
authUrl.searchParams.append("layout", "client2");
|
||||
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { Location: authUrl.toString() },
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Callback Handler
|
||||
if (url.pathname === "/gog/callback") {
|
||||
const code = url.searchParams.get("code");
|
||||
if (!code) {
|
||||
return new Response("Missing authorization code", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Token Exchange (Server-Side!)
|
||||
const tokenResponse = await fetch("https://auth.gog.com/token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: GOG_CLIENT_ID,
|
||||
client_secret: GOG_CLIENT_SECRET, // 🔒 Sicher!
|
||||
grant_type: "authorization_code",
|
||||
code: code,
|
||||
redirect_uri: GOG_REDIRECT_URI,
|
||||
}),
|
||||
});
|
||||
|
||||
const tokenData = await tokenResponse.json();
|
||||
|
||||
// Redirect zurück zur App mit Token
|
||||
const appRedirect = `https://whattoplay.local/#/settings?gog_token=${tokenData.access_token}&gog_user=${tokenData.user_id}`;
|
||||
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { Location: appRedirect },
|
||||
});
|
||||
} catch (error) {
|
||||
return new Response(`Token Error: ${error.message}`, {
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Token Validation
|
||||
if (url.pathname === "/gog/validate") {
|
||||
const authHeader = request.headers.get("Authorization");
|
||||
if (!authHeader) {
|
||||
return new Response("Missing Authorization", {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
|
||||
const token = authHeader.replace("Bearer ", "");
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
"https://galaxy-library.gog.com/users/me",
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
return new Response(JSON.stringify({ valid: true, user: data }), {
|
||||
headers,
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify({ valid: false }), {
|
||||
headers,
|
||||
});
|
||||
} catch (error) {
|
||||
return new Response(
|
||||
JSON.stringify({ valid: false, error: error.message }),
|
||||
{
|
||||
headers,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return new Response("Not Found", { status: 404 });
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### `wrangler.toml` Config:
|
||||
|
||||
```toml
|
||||
name = "whattoplay-oauth"
|
||||
main = "src/index.js"
|
||||
compatibility_date = "2024-01-01"
|
||||
|
||||
# KV Store für Secrets
|
||||
[[kv_namespaces]]
|
||||
binding = "SECRETS"
|
||||
id = "your_kv_namespace_id"
|
||||
preview_id = "your_preview_kv_id"
|
||||
|
||||
# Environment Variables (Secrets!)
|
||||
[env.production]
|
||||
vars = { ENVIRONMENT = "production" }
|
||||
|
||||
[env.production.secrets]
|
||||
GOG_CLIENT_SECRET = "your_client_secret"
|
||||
BLIZZARD_CLIENT_SECRET = "your_client_secret"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎮 Blizzard OAuth Worker
|
||||
|
||||
### Create `workers/blizzard-auth.js`:
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* Blizzard OAuth Proxy for WhatToPlay
|
||||
* Läuft auf: https://whattoplay-oauth.your-domain.workers.dev/blizzard/callback
|
||||
*/
|
||||
|
||||
const BLIZZARD_CLIENT_ID = "your_client_id";
|
||||
const BLIZZARD_CLIENT_SECRET = "your_client_secret"; // 🔒 KV Store!
|
||||
const BLIZZARD_REDIRECT_URI =
|
||||
"https://whattoplay-oauth.your-domain.workers.dev/blizzard/callback";
|
||||
|
||||
export default {
|
||||
async fetch(request) {
|
||||
const url = new URL(request.url);
|
||||
|
||||
const headers = {
|
||||
"Access-Control-Allow-Origin": "https://whattoplay.local",
|
||||
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
};
|
||||
|
||||
if (request.method === "OPTIONS") {
|
||||
return new Response(null, { headers });
|
||||
}
|
||||
|
||||
// 1. Authorize
|
||||
if (url.pathname === "/blizzard/authorize") {
|
||||
const state = crypto.randomUUID();
|
||||
const authUrl = new URL("https://oauth.battle.net/authorize");
|
||||
authUrl.searchParams.append("client_id", BLIZZARD_CLIENT_ID);
|
||||
authUrl.searchParams.append("redirect_uri", BLIZZARD_REDIRECT_URI);
|
||||
authUrl.searchParams.append("response_type", "code");
|
||||
authUrl.searchParams.append("state", state);
|
||||
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { Location: authUrl.toString() },
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Callback
|
||||
if (url.pathname === "/blizzard/callback") {
|
||||
const code = url.searchParams.get("code");
|
||||
const state = url.searchParams.get("state");
|
||||
|
||||
if (!code) {
|
||||
return new Response("Missing authorization code", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const tokenResponse = await fetch("https://oauth.battle.net/token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: BLIZZARD_CLIENT_ID,
|
||||
client_secret: BLIZZARD_CLIENT_SECRET, // 🔒 Sicher!
|
||||
grant_type: "authorization_code",
|
||||
code: code,
|
||||
redirect_uri: BLIZZARD_REDIRECT_URI,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
throw new Error(`Token request failed: ${tokenResponse.status}`);
|
||||
}
|
||||
|
||||
const tokenData = await tokenResponse.json();
|
||||
|
||||
// Redirect zurück
|
||||
const appRedirect = `https://whattoplay.local/#/settings?blizzard_token=${tokenData.access_token}`;
|
||||
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { Location: appRedirect },
|
||||
});
|
||||
} catch (error) {
|
||||
return new Response(`Error: ${error.message}`, {
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return new Response("Not Found", { status: 404 });
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Deployment
|
||||
|
||||
### 1. Deploy zu Cloudflare
|
||||
|
||||
```bash
|
||||
npx wrangler deploy workers/gog-auth.js --name whattoplay-gog
|
||||
npx wrangler deploy workers/blizzard-auth.js --name whattoplay-blizzard
|
||||
```
|
||||
|
||||
### 2. Custom Domain (optional)
|
||||
|
||||
```bash
|
||||
# Wenn du einen Domain hast, verbinde Cloudflare:
|
||||
# https://dash.cloudflare.com → Workers Routes
|
||||
|
||||
# Beispiel:
|
||||
# Domain: api.whattoplay.com
|
||||
# Worker: whattoplay-oauth
|
||||
# Route: api.whattoplay.com/gog/*
|
||||
```
|
||||
|
||||
### 3. Secrets hinzufügen
|
||||
|
||||
```bash
|
||||
# GOG Secret
|
||||
echo "your_gog_secret" | npx wrangler secret put GOG_CLIENT_SECRET --name whattoplay-gog
|
||||
|
||||
# Blizzard Secret
|
||||
echo "your_blizzard_secret" | npx wrangler secret put BLIZZARD_CLIENT_SECRET --name whattoplay-blizzard
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Frontend Integration
|
||||
|
||||
In `SettingsPage.tsx`:
|
||||
|
||||
```typescript
|
||||
// Button für GOG OAuth Login
|
||||
const handleGogOAuth = () => {
|
||||
const workerUrl = "https://whattoplay-oauth.workers.dev/gog/authorize";
|
||||
window.location.href = workerUrl;
|
||||
};
|
||||
|
||||
// Callback mit URL-Parametern
|
||||
const handleOAuthCallback = () => {
|
||||
const params = new URLSearchParams(window.location.hash.split("?")[1]);
|
||||
const token = params.get("gog_token");
|
||||
const userId = params.get("gog_user");
|
||||
|
||||
if (token) {
|
||||
handleSaveConfig("gog", {
|
||||
accessToken: token,
|
||||
userId: userId,
|
||||
});
|
||||
// Token ist jetzt gespeichert in localStorage
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Kosten (Februar 2026)
|
||||
|
||||
| Service | Free Tier | Kosten |
|
||||
| ------------------ | ------------ | ---------------------- |
|
||||
| Cloudflare Workers | 100k req/Tag | $0.50 pro 10M Anfragen |
|
||||
| KV Store | 3GB Storage | $0.50 pro GB |
|
||||
| Bandwidth | Unlimited | Keine Zusatzkosten |
|
||||
|
||||
**Beispiel:** 1.000 Users, je 10 Tokens/Monat = 10.000 Anfragen = **Kostenlos** 🎉
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Security Best Practices
|
||||
|
||||
### ✅ Was wir tun:
|
||||
|
||||
- Client Secrets in KV Store (nicht im Code)
|
||||
- Token Exchange Server-Side
|
||||
- CORS nur für unsere Domain
|
||||
- State Parameter für CSRF Protection
|
||||
- Keine Tokens in URLs speichern (Session nur)
|
||||
|
||||
### ❌ Was wir NICHT tun:
|
||||
|
||||
- Client Secrets hardcoden
|
||||
- Tokens in localStorage ohne Verschlüsselung
|
||||
- CORS für alle Origins
|
||||
- Tokens in Browser Console anzeigen
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Debugging
|
||||
|
||||
```bash
|
||||
# Logs anschauen
|
||||
npx wrangler tail whattoplay-gog
|
||||
|
||||
# Local testen
|
||||
npx wrangler dev workers/gog-auth.js
|
||||
# Öffne dann: http://localhost:8787/gog/authorize
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 Links
|
||||
|
||||
- [Cloudflare Workers Docs](https://developers.cloudflare.com/workers/)
|
||||
- [Wrangler CLI Guide](https://developers.cloudflare.com/workers/wrangler/)
|
||||
- [KV Store Reference](https://developers.cloudflare.com/workers/runtime-apis/kv/)
|
||||
- [GOG OAuth Docs](https://gogapidocs.readthedocs.io/)
|
||||
- [Blizzard OAuth Docs](https://develop.battle.net/documentation/guides/using-oauth)
|
||||
@@ -1,328 +0,0 @@
|
||||
# WhatToPlay - Feature-Übersicht (Februar 2026)
|
||||
|
||||
## 🆕 Neue Features
|
||||
|
||||
### 1️⃣ Settings-Tab mit Konfiguration
|
||||
|
||||
**Pfad**: `src/pages/Settings/SettingsPage.tsx`
|
||||
|
||||
```
|
||||
Settings-Tab
|
||||
├── 🎮 Steam Integration
|
||||
│ ├── API Key Input (verborgen)
|
||||
│ ├── Steam ID Input
|
||||
│ └── Tutorial-Button (✨ Step-by-Step Anleitung)
|
||||
│
|
||||
├── 🌐 GOG Integration
|
||||
│ ├── User ID Input
|
||||
│ ├── Access Token Input (verborgen)
|
||||
│ └── Tutorial für Token-Extraction
|
||||
│
|
||||
├── ⚙️ Epic Games
|
||||
│ ├── E-Mail Input
|
||||
│ ├── Import-Methode (Manual oder OAuth)
|
||||
│ └── ℹ️ Info: Keine öffentliche API
|
||||
│
|
||||
├── 🔶 Amazon Games
|
||||
│ ├── E-Mail Input
|
||||
│ ├── Import-Methode (Manual oder OAuth)
|
||||
│ └── Ähnlich wie Epic
|
||||
│
|
||||
├── ⚔️ Blizzard Entertainment
|
||||
│ ├── Client ID Input (verborgen)
|
||||
│ ├── Client Secret Input (verborgen)
|
||||
│ ├── Region Selector (US/EU/KR/TW)
|
||||
│ └── Tutorial-Button
|
||||
│
|
||||
└── 📦 Daten-Management
|
||||
├── Config Exportieren (JSON Download)
|
||||
├── Config Importieren (JSON Upload)
|
||||
└── Alle Einstellungen löschen
|
||||
```
|
||||
|
||||
### 2️⃣ Integriertes Tutorial-System
|
||||
|
||||
**Pfad**: `src/components/TutorialModal.tsx`
|
||||
|
||||
Jeder Service hat sein eigenes Step-by-Step Tutorial:
|
||||
|
||||
```
|
||||
Tutorial Modal
|
||||
├── Steam
|
||||
│ ├── API Key generieren
|
||||
│ ├── Steam ID finden
|
||||
│ └── 6 Schritte mit Screenshots-Links
|
||||
│
|
||||
├── GOG
|
||||
│ ├── Browser DevTools öffnen
|
||||
│ ├── Bearer Token kopieren
|
||||
│ └── 5 Schritte mit Code-Beispiele
|
||||
│
|
||||
├── Epic Games
|
||||
│ ├── Account-Setup
|
||||
│ ├── JSON Export erklären
|
||||
│ └── 4 Schritte, einfach
|
||||
│
|
||||
├── Amazon Games
|
||||
│ ├── Prime Gaming aktivieren
|
||||
│ ├── Luna erklärt
|
||||
│ └── 4 Schritte
|
||||
│
|
||||
└── Blizzard
|
||||
├── Developer Portal
|
||||
├── OAuth Credentials
|
||||
└── 6 Schritte detailliert
|
||||
```
|
||||
|
||||
### 3️⃣ ConfigService - Sichere Speicherung
|
||||
|
||||
**Pfad**: `src/services/ConfigService.ts`
|
||||
|
||||
```typescript
|
||||
ConfigService
|
||||
├── loadConfig() - Lade aus localStorage
|
||||
├── saveConfig() - Speichere in localStorage
|
||||
├── exportConfig() - Download als JSON
|
||||
├── importConfig() - Upload aus JSON
|
||||
├── backupToIndexedDB() - Redundante Speicherung
|
||||
├── restoreFromIndexedDB() - Aus Backup zurück
|
||||
├── validateConfig() - Prüfe auf Fehler
|
||||
└── clearConfig() - Alles löschen
|
||||
```
|
||||
|
||||
**Speicher-Strategie:**
|
||||
|
||||
- ✅ localStorage für schnellen Zugriff
|
||||
- ✅ IndexedDB für Backup & Encryption-Ready
|
||||
- ✅ Keine Tokens in localStorage ohne Verschlüsselung
|
||||
- ✅ Export/Import für Cloud-Sync
|
||||
|
||||
### 4️⃣ Blizzard API Integration
|
||||
|
||||
**Pfad**: `scripts/fetch-blizzard.mjs`
|
||||
|
||||
```
|
||||
Supported Games:
|
||||
• World of Warcraft
|
||||
• Diablo III (Heroes)
|
||||
• Diablo IV
|
||||
• Overwatch 2
|
||||
• StarCraft II
|
||||
• Heroes of the Storm
|
||||
• Hearthstone
|
||||
|
||||
Data:
|
||||
• Character Name
|
||||
• Level
|
||||
• Class
|
||||
• Hardcore Flag
|
||||
• Elite Kills
|
||||
• Experience
|
||||
• Last Updated
|
||||
```
|
||||
|
||||
### 5️⃣ Cloudflare Workers Setup (Serverless)
|
||||
|
||||
**Pfad**: `docs/CLOUDFLARE-WORKERS-SETUP.md`
|
||||
|
||||
```
|
||||
Zero Infrastructure Deployment:
|
||||
|
||||
Frontend (Vercel/Netlify)
|
||||
↓
|
||||
Cloudflare Workers (Serverless)
|
||||
↓
|
||||
OAuth Callbacks + Token Exchange
|
||||
↓
|
||||
GOG Galaxy Library API
|
||||
Blizzard Battle.net API
|
||||
Epic Games (später)
|
||||
Amazon Games (später)
|
||||
|
||||
✨ Benefits:
|
||||
• Keine Server zu verwalten
|
||||
• Kostenlos bis 100k req/Tag
|
||||
• Client Secrets geschützt (Server-Side)
|
||||
• CORS automatisch konfiguriert
|
||||
• Weltweit verteilt
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📁 Neue Dateien
|
||||
|
||||
| Datei | Beschreibung | Status |
|
||||
| ------------------------------------- | --------------------------- | ------ |
|
||||
| `src/pages/Settings/SettingsPage.tsx` | Settings-Tab mit Formularen | ✅ |
|
||||
| `src/pages/Settings/SettingsPage.css` | Styling | ✅ |
|
||||
| `src/components/TutorialModal.tsx` | Tutorial-System | ✅ |
|
||||
| `src/services/ConfigService.ts` | Konfiguration speichern | ✅ |
|
||||
| `scripts/fetch-blizzard.mjs` | Blizzard API Importer | ✅ |
|
||||
| `docs/CLOUDFLARE-WORKERS-SETUP.md` | Worker Dokumentation | ✅ |
|
||||
| `docs/BLIZZARD-SETUP.md` | Blizzard OAuth Guide | ✅ |
|
||||
| `config.local.json.example` | Config Template | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Workflow für Nutzer
|
||||
|
||||
### Erste Nutzung:
|
||||
|
||||
```
|
||||
1. App öffnen → Settings-Tab
|
||||
2. Auf "?" Button klicken → Tutorial Modal
|
||||
3. Step-by-Step folgen
|
||||
4. Credentials eingeben
|
||||
5. "Speichern" klicken → localStorage
|
||||
6. Daten werden automatisch synced
|
||||
```
|
||||
|
||||
### Daten importieren:
|
||||
|
||||
```
|
||||
1. Settings-Tab → "Config importieren"
|
||||
2. Datei auswählen (whattoplay-config.json)
|
||||
3. Credentials werden wiederhergestellt
|
||||
4. Alle APIs neu abfragen
|
||||
```
|
||||
|
||||
### Daten exportieren:
|
||||
|
||||
```
|
||||
1. Settings-Tab → "Config exportieren"
|
||||
2. JSON-Datei downloaded
|
||||
3. Kann auf anderem Device importiert werden
|
||||
4. Oder als Backup gespeichert
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Nächste Schritte
|
||||
|
||||
### Phase 1: Production Ready (Jetzt)
|
||||
|
||||
- [x] Steam Integration
|
||||
- [x] Settings-Tab
|
||||
- [x] Blizzard OAuth
|
||||
- [x] Cloudflare Worker Setup (dokumentiert)
|
||||
|
||||
### Phase 2: Backend Deployment (1-2 Wochen)
|
||||
|
||||
- [ ] Cloudflare Worker deployen
|
||||
- [ ] GOG OAuth Callback
|
||||
- [ ] Blizzard OAuth Callback
|
||||
- [ ] Token Encryption in KV Store
|
||||
|
||||
### Phase 3: Import Features (2-4 Wochen)
|
||||
|
||||
- [ ] Epic Games JSON Import UI
|
||||
- [ ] Amazon Games JSON Import UI
|
||||
- [ ] Drag & Drop Upload
|
||||
- [ ] Validierung
|
||||
|
||||
### Phase 4: Polish (4+ Wochen)
|
||||
|
||||
- [ ] Home-Page Widgets
|
||||
- [ ] Playlists Feature
|
||||
- [ ] Discover/Tinder UI
|
||||
- [ ] PWA Setup
|
||||
- [ ] iOS Testing
|
||||
|
||||
---
|
||||
|
||||
## 📊 Statistiken
|
||||
|
||||
| Metric | Wert |
|
||||
| --------------------------- | -------------------------------------- |
|
||||
| Unterstützte Plattformen | 5 (Steam, GOG, Epic, Amazon, Blizzard) |
|
||||
| Settings-Formulare | 5 |
|
||||
| Tutorial-Schritte | 30+ |
|
||||
| Cloudflare Worker Functions | 3 (GOG, Blizzard, Validation) |
|
||||
| API Endpoints | 15+ |
|
||||
| LocalStorage Capacity | 5-10MB |
|
||||
| IndexedDB Capacity | 50MB+ |
|
||||
|
||||
---
|
||||
|
||||
## 💡 Design Patterns
|
||||
|
||||
### Konfiguration speichern (Observable Pattern)
|
||||
|
||||
```typescript
|
||||
// SettingsPage.tsx
|
||||
const [config, setConfig] = useState<ServiceConfig>({});
|
||||
|
||||
const handleSaveConfig = (service: keyof ServiceConfig, data: any) => {
|
||||
const updated = { ...config, [service]: { ...config[service], ...data } };
|
||||
setConfig(updated);
|
||||
ConfigService.saveConfig(updated); // → localStorage
|
||||
// Optional: ConfigService.backupToIndexedDB(updated); // → Backup
|
||||
};
|
||||
```
|
||||
|
||||
### Tutorial System (Data-Driven)
|
||||
|
||||
```typescript
|
||||
// TutorialModal.tsx - Alle Tutorials in TUTORIALS Objekt
|
||||
const TUTORIALS: Record<string, Tutorial> = {
|
||||
steam: { ... },
|
||||
gog: { ... },
|
||||
// Einfach zu erweitern!
|
||||
};
|
||||
```
|
||||
|
||||
### OAuth Flow mit Cloudflare Worker
|
||||
|
||||
```
|
||||
Frontend initiiert:
|
||||
↓
|
||||
Worker erhält Callback:
|
||||
↓
|
||||
Token Exchange Server-Side:
|
||||
↓
|
||||
Frontend erhält Token in URL:
|
||||
↓
|
||||
ConfigService speichert Token:
|
||||
↓
|
||||
Nächster API Call mit Token
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Sicherheit
|
||||
|
||||
### ✅ Best Practices implementiert:
|
||||
|
||||
- Client Secrets in Backend nur (Cloudflare KV)
|
||||
- Tokens mit Session-Speicher (nicht persistent)
|
||||
- Export/Import mit Warnung
|
||||
- Validation der Credentials
|
||||
- CORS nur für eigene Domain
|
||||
- State Parameter für CSRF
|
||||
|
||||
### ❌ Nicht implementiert (wäre Overkill):
|
||||
|
||||
- Token-Verschlüsselung in localStorage (würde Komplexität erhöhen)
|
||||
- 2FA für Settings
|
||||
- Audit Logs
|
||||
- Rate Limiting (kommt auf Server-Side)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Gesamtziel
|
||||
|
||||
**Zero Infrastructure, Full-Featured:**
|
||||
|
||||
- Frontend: Statisch deployed (Vercel/Netlify)
|
||||
- Backend: Serverless (Cloudflare Workers)
|
||||
- Datenbank: Optional (Supabase/Firebase)
|
||||
- Secrets: KV Store oder Environment Variables
|
||||
- **Kosten**: ~$0/Monat für < 1000 User
|
||||
|
||||
Nutzer kann:
|
||||
|
||||
- ✅ Alle Credentials selbst eingeben
|
||||
- ✅ Daten jederzeit exportieren/importieren
|
||||
- ✅ Offline mit LocalStorage arbeiten
|
||||
- ✅ Auf iOS/Web/Desktop gleiches UI
|
||||
- ✅ Keine zusätzlichen Apps nötig
|
||||
@@ -1,144 +0,0 @@
|
||||
# GOG Integration - Development Setup
|
||||
|
||||
## ⚠️ Wichtig: Temporäre Lösung für Development
|
||||
|
||||
Da wir eine **Web/iOS App** bauen, können wir keine CLI-Tools (wie `gogdl`) nutzen.
|
||||
Für Production brauchen wir ein **Backend mit OAuth Flow**.
|
||||
|
||||
## Wie bekomme ich GOG Credentials?
|
||||
|
||||
### Option 1: Manuell aus Browser (Development)
|
||||
|
||||
1. **Öffne GOG.com (eingeloggt)**
|
||||
|
||||
```
|
||||
https://www.gog.com
|
||||
```
|
||||
|
||||
2. **Öffne Browser DevTools**
|
||||
- Chrome/Edge: `F12` oder `Cmd+Option+I` (Mac)
|
||||
- Firefox: `F12`
|
||||
|
||||
3. **Gehe zu Network Tab**
|
||||
- Klicke auf "Network" / "Netzwerk"
|
||||
- Aktiviere "Preserve log" / "Log beibehalten"
|
||||
|
||||
4. **Lade eine GOG Seite neu**
|
||||
- Z.B. deine Library: `https://www.gog.com/account`
|
||||
|
||||
5. **Finde Request mit Bearer Token**
|
||||
- Suche nach Requests zu `gog.com` oder `galaxy-library.gog.com`
|
||||
- Klicke auf einen Request
|
||||
- Gehe zu "Headers" Tab
|
||||
- Kopiere den `Authorization: Bearer ...` Token
|
||||
|
||||
6. **Kopiere User ID**
|
||||
- Suche nach Request zu `embed.gog.com/userData.json`
|
||||
- Im Response findest du `"galaxyUserId": "123456789..."`
|
||||
- Kopiere diese ID
|
||||
|
||||
7. **Trage in config.local.json ein**
|
||||
```json
|
||||
{
|
||||
"steam": { ... },
|
||||
"epic": {},
|
||||
"gog": {
|
||||
"userId": "DEINE_GALAXY_USER_ID",
|
||||
"accessToken": "DEIN_BEARER_TOKEN"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Option 2: Backend OAuth Flow (Production - TODO)
|
||||
|
||||
Für Production implementieren wir einen OAuth Flow:
|
||||
|
||||
```javascript
|
||||
// Backend Endpoint (z.B. Vercel Function)
|
||||
export async function POST(request) {
|
||||
// 1. User zu GOG Auth redirecten
|
||||
const authUrl = `https://auth.gog.com/auth?client_id=...&redirect_uri=...`;
|
||||
|
||||
// 2. Callback mit Code
|
||||
// 3. Code gegen Access Token tauschen
|
||||
const token = await fetch("https://auth.gog.com/token", {
|
||||
method: "POST",
|
||||
body: { code, client_secret: process.env.GOG_SECRET },
|
||||
});
|
||||
|
||||
// 4. Token sicher speichern (z.B. encrypted in DB)
|
||||
return { success: true };
|
||||
}
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### GOG Galaxy Library
|
||||
|
||||
```
|
||||
GET https://galaxy-library.gog.com/users/{userId}/releases
|
||||
Headers:
|
||||
Authorization: Bearer {accessToken}
|
||||
User-Agent: WhatToPlay/1.0
|
||||
|
||||
Response:
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"external_id": "1207658930",
|
||||
"platform_id": "gog",
|
||||
"date_created": 1234567890,
|
||||
...
|
||||
}
|
||||
],
|
||||
"total_count": 123,
|
||||
"next_page_token": "..."
|
||||
}
|
||||
```
|
||||
|
||||
### GOG User Data
|
||||
|
||||
```
|
||||
GET https://embed.gog.com/userData.json
|
||||
Headers:
|
||||
Authorization: Bearer {accessToken}
|
||||
|
||||
Response:
|
||||
{
|
||||
"userId": "...",
|
||||
"galaxyUserId": "...",
|
||||
"username": "...",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
## Token Lebensdauer
|
||||
|
||||
- GOG Tokens laufen nach **ca. 1 Stunde** ab
|
||||
- Für Development: Token regelmäßig neu kopieren
|
||||
- Für Production: Refresh Token Flow implementieren
|
||||
|
||||
## Nächste Schritte
|
||||
|
||||
1. ✅ Development: Manueller Token aus Browser
|
||||
2. 📝 Backend: Vercel Function für OAuth
|
||||
3. 🔐 Backend: Token Refresh implementieren
|
||||
4. 📱 iOS: Secure Storage für Tokens (Keychain)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### `401 Unauthorized`
|
||||
|
||||
- Token abgelaufen → Neu aus Browser kopieren
|
||||
- Falscher Token → Prüfe `Authorization: Bearer ...`
|
||||
|
||||
### `CORS Error`
|
||||
|
||||
- Normal im Browser (darum brauchen wir Backend)
|
||||
- Development: Scripts laufen in Node.js (kein CORS)
|
||||
- Production: Backend macht die Requests
|
||||
|
||||
### Leere Library
|
||||
|
||||
- Prüfe `userId` - muss `galaxyUserId` sein, nicht `userId`
|
||||
- Prüfe API Endpoint - `/users/{userId}/releases`, nicht `/games`
|
||||
@@ -1,172 +0,0 @@
|
||||
# WhatToPlay - iOS/Web Strategie
|
||||
|
||||
## ✅ Was funktioniert JETZT
|
||||
|
||||
### Steam Integration (Voll funktionsfähig)
|
||||
|
||||
```javascript
|
||||
// ✅ Öffentliche Web API - funktioniert im Browser/iOS
|
||||
const response = await fetch(
|
||||
"http://api.steampowered.com/IPlayerService/GetOwnedGames/v0001/",
|
||||
{
|
||||
params: {
|
||||
key: "YOUR_STEAM_API_KEY",
|
||||
steamid: "YOUR_STEAM_ID",
|
||||
format: "json",
|
||||
},
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
**Status**: 1103 Games erfolgreich importiert ✅
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Was BACKEND braucht
|
||||
|
||||
### GOG Integration
|
||||
|
||||
**Problem**: OAuth Token Exchange geht nicht im Browser (CORS + Secrets)
|
||||
|
||||
**Development-Lösung** (jetzt):
|
||||
|
||||
1. Öffne https://www.gog.com (eingeloggt)
|
||||
2. Browser DevTools → Network → Kopiere Bearer Token
|
||||
3. Trage in `config.local.json` ein
|
||||
|
||||
**Production-Lösung** (später):
|
||||
|
||||
```
|
||||
Frontend → Backend (Vercel Function) → GOG OAuth
|
||||
→ GOG Galaxy Library API
|
||||
```
|
||||
|
||||
**Siehe**: [docs/GOG-SETUP.md](./GOG-SETUP.md)
|
||||
|
||||
---
|
||||
|
||||
### Epic Games Integration
|
||||
|
||||
**Problem**: Keine öffentliche API, nur CLI-Tool (Legendary)
|
||||
|
||||
**Optionen**:
|
||||
|
||||
1. ❌ Legendary CLI → Funktioniert nicht auf iOS
|
||||
2. ⚠️ Backend mit Epic GraphQL → Reverse-Engineered, gegen ToS
|
||||
3. ✅ Manuelle Import-Funktion → User uploaded JSON
|
||||
|
||||
**Empfehlung**: Manuelle Import-Funktion für MVP
|
||||
|
||||
---
|
||||
|
||||
### Amazon Games Integration
|
||||
|
||||
**Problem**: Keine öffentliche API, nur CLI-Tool (Nile)
|
||||
|
||||
**Status**: Gleiche Situation wie Epic
|
||||
**Empfehlung**: Später, wenn Epic funktioniert
|
||||
|
||||
---
|
||||
|
||||
## 🎯 MVP Strategie (iOS/Web Ready)
|
||||
|
||||
### Phase 1: Steam Only (✅ Fertig)
|
||||
|
||||
```
|
||||
React/Ionic App
|
||||
↓
|
||||
Steam Web API (direkt vom Browser)
|
||||
↓
|
||||
1103 Games imported
|
||||
```
|
||||
|
||||
### Phase 2: GOG mit Backend (🔜 Next)
|
||||
|
||||
```
|
||||
React/Ionic App
|
||||
↓
|
||||
Vercel Function (OAuth Proxy)
|
||||
↓
|
||||
GOG Galaxy Library API
|
||||
```
|
||||
|
||||
### Phase 3: Epic/Amazon Import (📝 TODO)
|
||||
|
||||
```
|
||||
React/Ionic App
|
||||
↓
|
||||
User uploaded JSON
|
||||
↓
|
||||
Parse & Display
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Deployment Plan
|
||||
|
||||
### Frontend (iOS/Web)
|
||||
|
||||
- **Hosting**: Vercel / Netlify (Static React App)
|
||||
- **PWA**: Service Worker für Offline-Support
|
||||
- **iOS**: Add to Home Screen (keine App Store App)
|
||||
|
||||
### Backend (nur für GOG/Epic OAuth)
|
||||
|
||||
- **Option 1**: Vercel Serverless Functions
|
||||
- **Option 2**: Cloudflare Workers
|
||||
- **Option 3**: Supabase Edge Functions
|
||||
|
||||
### Datenbank (optional)
|
||||
|
||||
- **Option 1**: localStorage (nur Client-Side)
|
||||
- **Option 2**: Supabase (für Cloud-Sync)
|
||||
- **Option 3**: Firebase Firestore
|
||||
|
||||
---
|
||||
|
||||
## ❓ FAQ
|
||||
|
||||
### Warum kein Python/CLI auf iOS?
|
||||
|
||||
iOS erlaubt keine nativen Binaries in Web-Apps. Nur JavaScript im Browser oder Swift in nativer App.
|
||||
|
||||
### Warum brauchen wir ein Backend?
|
||||
|
||||
OAuth Secrets können nicht sicher im Browser gespeichert werden (jeder kann den Source-Code sehen). CORS blockiert direkte API-Calls.
|
||||
|
||||
### Kann ich die App ohne Backend nutzen?
|
||||
|
||||
Ja! Steam funktioniert ohne Backend. GOG/Epic brauchen aber Backend oder manuelle Imports.
|
||||
|
||||
### Wie sicher sind die Tokens?
|
||||
|
||||
- **Development**: Tokens in `config.local.json` (nicht in Git!)
|
||||
- **Production**: Tokens im Backend, verschlüsselt in DB
|
||||
- **iOS**: Tokens im Keychain (nativer secure storage)
|
||||
|
||||
---
|
||||
|
||||
## 📋 Checklist
|
||||
|
||||
- [x] Steam API Integration
|
||||
- [x] React/Ionic UI Setup
|
||||
- [x] Tab Navigation (Home, Library, Playlists, Discover, **Settings**)
|
||||
- [x] Game Consolidation (Duplicates merging)
|
||||
- [x] Blizzard API Integration
|
||||
- [x] Settings-Tab mit Tutorials
|
||||
- [x] ConfigService (localStorage + IndexedDB)
|
||||
- [ ] GOG OAuth Backend (Cloudflare Worker)
|
||||
- [ ] Epic Import-Funktion (JSON Upload)
|
||||
- [ ] PWA Setup (Service Worker)
|
||||
- [ ] iOS Testing (Add to Home Screen)
|
||||
- [ ] Cloud-Sync (optional)
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Nützliche Links
|
||||
|
||||
- [Steam Web API Docs](https://developer.valvesoftware.com/wiki/Steam_Web_API)
|
||||
- [GOG Galaxy API](https://galaxy-library.gog.com/)
|
||||
- [Heroic Launcher](https://github.com/Heroic-Games-Launcher/HeroicGamesLauncher) (Referenz-Implementation)
|
||||
- [Ionic React Docs](https://ionicframework.com/docs/react)
|
||||
- [PWA Guide](https://web.dev/progressive-web-apps/)
|
||||
20
index.html
20
index.html
@@ -1,20 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#0a84ff" />
|
||||
<meta name="description" content="Verwalte deine Spielebibliothek und entdecke neue Spiele" />
|
||||
<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="WhatToPlay" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png" />
|
||||
<title>WhatToPlay</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
3655
package-lock.json
generated
3655
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
34
package.json
34
package.json
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"name": "whattoplay",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "op run --env-file=.env.1password -- vite",
|
||||
"dev:no-op": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "node --test server/**/*.test.mjs",
|
||||
"deploy": "./deploy.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ionic/react": "^8.0.0",
|
||||
"@ionic/react-router": "^8.0.0",
|
||||
"@react-spring/web": "^9.7.5",
|
||||
"ionicons": "^7.2.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router": "^5.3.4",
|
||||
"react-router-dom": "^5.3.4",
|
||||
"react-tinder-card": "^1.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@types/react-router": "^5.1.20",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
RewriteBase /
|
||||
|
||||
# Don't rewrite files or directories
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
|
||||
# Don't rewrite API calls
|
||||
RewriteCond %{REQUEST_URI} !^/api/
|
||||
|
||||
# Rewrite everything else to index.html
|
||||
RewriteRule . /index.html [L]
|
||||
</IfModule>
|
||||
|
||||
# No cache for manifest and index (PWA updates)
|
||||
<FilesMatch "(manifest\.json|index\.html)$">
|
||||
Header set Cache-Control "no-cache, must-revalidate"
|
||||
</FilesMatch>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.5 KiB |
@@ -1,20 +0,0 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Clear Storage</title>
|
||||
</head>
|
||||
<body>
|
||||
<h2>Clearing Storage...</h2>
|
||||
<script>
|
||||
// Clear localStorage
|
||||
localStorage.clear();
|
||||
|
||||
// Clear IndexedDB
|
||||
indexedDB.deleteDatabase("whattoplay");
|
||||
|
||||
document.write("<p>✓ localStorage cleared</p>");
|
||||
document.write("<p>✓ IndexedDB deleted</p>");
|
||||
document.write("<br><p>Close this tab and reload the app.</p>");
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.5 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 9.7 KiB |
@@ -1,23 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="96" fill="#0a84ff"/>
|
||||
<g fill="white">
|
||||
<!-- Gamepad body -->
|
||||
<rect x="116" y="196" width="280" height="160" rx="48" ry="48"/>
|
||||
<!-- Left grip -->
|
||||
<rect x="136" y="296" width="60" height="80" rx="24" ry="24"/>
|
||||
<!-- Right grip -->
|
||||
<rect x="316" y="296" width="60" height="80" rx="24" ry="24"/>
|
||||
</g>
|
||||
<!-- D-pad -->
|
||||
<g fill="#0a84ff">
|
||||
<rect x="181" y="244" width="14" height="44" rx="3"/>
|
||||
<rect x="166" y="259" width="44" height="14" rx="3"/>
|
||||
</g>
|
||||
<!-- Buttons -->
|
||||
<circle cx="332" cy="252" r="9" fill="#0a84ff"/>
|
||||
<circle cx="356" cy="268" r="9" fill="#0a84ff"/>
|
||||
<circle cx="308" cy="268" r="9" fill="#0a84ff"/>
|
||||
<circle cx="332" cy="284" r="9" fill="#0a84ff"/>
|
||||
<!-- Play triangle (center) -->
|
||||
<polygon points="240,148 280,168 240,188" fill="white"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 907 B |
@@ -1,30 +0,0 @@
|
||||
{
|
||||
"name": "WhatToPlay",
|
||||
"short_name": "WhatToPlay",
|
||||
"description": "Verwalte deine Spielebibliothek und entdecke neue Spiele",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"background_color": "#f2f2f7",
|
||||
"theme_color": "#0a84ff",
|
||||
"categories": ["games", "entertainment"],
|
||||
"icons": [
|
||||
{
|
||||
"src": "icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
|
||||
const loadConfig = async () => {
|
||||
const configUrl = new URL("../config.local.json", import.meta.url);
|
||||
try {
|
||||
const raw = await readFile(configUrl, "utf-8");
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const toIsoDate = (unixSeconds) =>
|
||||
unixSeconds ? new Date(unixSeconds * 1000).toISOString().slice(0, 10) : null;
|
||||
|
||||
const sanitizeFileName = (value) => {
|
||||
const normalized = value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
return normalized || "spiel";
|
||||
};
|
||||
|
||||
const fetchOwnedGames = async ({ apiKey, steamId }) => {
|
||||
const url = new URL(
|
||||
"https://api.steampowered.com/IPlayerService/GetOwnedGames/v1/",
|
||||
);
|
||||
url.searchParams.set("key", apiKey);
|
||||
url.searchParams.set("steamid", steamId);
|
||||
url.searchParams.set("include_appinfo", "true");
|
||||
url.searchParams.set("include_played_free_games", "true");
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Steam API Fehler: ${response.status}`);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
return payload.response?.games ?? [];
|
||||
};
|
||||
|
||||
const buildSteamEntry = (game) => ({
|
||||
id: String(game.appid),
|
||||
title: game.name,
|
||||
platform: "PC",
|
||||
lastPlayed: toIsoDate(game.rtime_last_played),
|
||||
playtimeHours: Math.round((game.playtime_forever / 60) * 10) / 10,
|
||||
tags: [],
|
||||
url: `https://store.steampowered.com/app/${game.appid}`,
|
||||
});
|
||||
|
||||
const buildTextFile = (entry) => {
|
||||
const lines = [
|
||||
`Titel: ${entry.title}`,
|
||||
`Steam AppID: ${entry.id}`,
|
||||
`Zuletzt gespielt: ${entry.lastPlayed ?? "-"}`,
|
||||
`Spielzeit (h): ${entry.playtimeHours ?? 0}`,
|
||||
`Store: ${entry.url}`,
|
||||
"Quelle: steam",
|
||||
];
|
||||
return lines.join("\n") + "\n";
|
||||
};
|
||||
|
||||
const writeOutputs = async (entries) => {
|
||||
const dataDir = new URL("../public/data/", import.meta.url);
|
||||
const textDir = new URL("../public/data/steam-text/", import.meta.url);
|
||||
|
||||
await mkdir(dataDir, { recursive: true });
|
||||
await mkdir(textDir, { recursive: true });
|
||||
|
||||
const jsonPath = new URL("steam.json", dataDir);
|
||||
await writeFile(jsonPath, JSON.stringify(entries, null, 2) + "\n", "utf-8");
|
||||
|
||||
await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const fileName = `${sanitizeFileName(entry.title)}__${entry.id}.txt`;
|
||||
const filePath = new URL(fileName, textDir);
|
||||
await writeFile(filePath, buildTextFile(entry), "utf-8");
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const run = async () => {
|
||||
const config = await loadConfig();
|
||||
const apiKey = config.steam?.apiKey || process.env.STEAM_API_KEY;
|
||||
const steamId = config.steam?.steamId || process.env.STEAM_ID;
|
||||
|
||||
if (!apiKey || !steamId) {
|
||||
console.error(
|
||||
"Bitte Steam-Zugangsdaten in config.local.json oder per Umgebungsvariablen setzen.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const games = await fetchOwnedGames({ apiKey, steamId });
|
||||
const entries = games.map(buildSteamEntry);
|
||||
await writeOutputs(entries);
|
||||
console.log(`Steam-Export fertig: ${entries.length} Spiele.`);
|
||||
};
|
||||
|
||||
run().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,101 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Steam CLI - Direktes Testen der Steam API
|
||||
* Usage: node scripts/steam-cli.mjs [apiKey] [steamId]
|
||||
*/
|
||||
|
||||
import { fetchSteamGames } from "../server/steam-backend.mjs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const configPath = join(__dirname, "..", "config.local.json");
|
||||
const configData = await readFile(configPath, "utf-8");
|
||||
return JSON.parse(configData);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("=".repeat(70));
|
||||
console.log("Steam API CLI Test");
|
||||
console.log("=".repeat(70));
|
||||
|
||||
// API Key und Steam ID holen (CLI-Args oder config.local.json)
|
||||
let apiKey = process.argv[2];
|
||||
let steamId = process.argv[3];
|
||||
|
||||
if (!apiKey || !steamId) {
|
||||
console.log("\nKeine CLI-Args, versuche config.local.json zu laden...");
|
||||
const config = await loadConfig();
|
||||
if (config?.steam) {
|
||||
apiKey = config.steam.apiKey;
|
||||
steamId = config.steam.steamId;
|
||||
console.log("✓ Credentials aus config.local.json geladen");
|
||||
}
|
||||
}
|
||||
|
||||
if (!apiKey || !steamId) {
|
||||
console.error("\n❌ Fehler: API Key und Steam ID erforderlich!");
|
||||
console.error("\nUsage:");
|
||||
console.error(" node scripts/steam-cli.mjs <apiKey> <steamId>");
|
||||
console.error(
|
||||
" oder config.local.json mit steam.apiKey und steam.steamId",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("\nParameter:");
|
||||
console.log(" API Key:", apiKey.substring(0, 8) + "...");
|
||||
console.log(" Steam ID:", steamId);
|
||||
console.log("\nRufe Steam API auf...\n");
|
||||
|
||||
try {
|
||||
const result = await fetchSteamGames(apiKey, steamId);
|
||||
|
||||
console.log("=".repeat(70));
|
||||
console.log("✓ Erfolgreich!");
|
||||
console.log("=".repeat(70));
|
||||
console.log(`\nAnzahl Spiele: ${result.count}`);
|
||||
|
||||
if (result.count > 0) {
|
||||
console.log("\nErste 5 Spiele:");
|
||||
console.log("-".repeat(70));
|
||||
result.games.slice(0, 5).forEach((game, idx) => {
|
||||
console.log(`\n${idx + 1}. ${game.title}`);
|
||||
console.log(` ID: ${game.id}`);
|
||||
console.log(` Spielzeit: ${game.playtimeHours}h`);
|
||||
console.log(` Zuletzt gespielt: ${game.lastPlayed || "nie"}`);
|
||||
console.log(` URL: ${game.url}`);
|
||||
});
|
||||
|
||||
console.log("\n" + "-".repeat(70));
|
||||
console.log("\nKomplettes JSON (erste 3 Spiele):");
|
||||
console.log(JSON.stringify(result.games.slice(0, 3), null, 2));
|
||||
}
|
||||
|
||||
console.log("\n" + "=".repeat(70));
|
||||
console.log("✓ Test erfolgreich abgeschlossen");
|
||||
console.log("=".repeat(70) + "\n");
|
||||
} catch (error) {
|
||||
console.error("\n" + "=".repeat(70));
|
||||
console.error("❌ Fehler:");
|
||||
console.error("=".repeat(70));
|
||||
console.error("\nMessage:", error.message);
|
||||
if (error.stack) {
|
||||
console.error("\nStack:");
|
||||
console.error(error.stack);
|
||||
}
|
||||
console.error("\n" + "=".repeat(70) + "\n");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,75 +0,0 @@
|
||||
/**
|
||||
* Test-Script für Backend-APIs
|
||||
* Ruft die Endpoints direkt auf ohne Browser/GUI
|
||||
*/
|
||||
|
||||
import { handleConfigLoad, handleSteamRefresh } from "../server/steam-api.mjs";
|
||||
|
||||
// Mock Request/Response Objekte
|
||||
class MockRequest {
|
||||
constructor(method, url, body = null) {
|
||||
this.method = method;
|
||||
this.url = url;
|
||||
this._body = body;
|
||||
this._listeners = {};
|
||||
}
|
||||
|
||||
on(event, callback) {
|
||||
this._listeners[event] = callback;
|
||||
|
||||
if (event === "data" && this._body) {
|
||||
setTimeout(() => callback(this._body), 0);
|
||||
}
|
||||
if (event === "end") {
|
||||
setTimeout(() => callback(), 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class MockResponse {
|
||||
constructor() {
|
||||
this.statusCode = 200;
|
||||
this.headers = {};
|
||||
this._chunks = [];
|
||||
}
|
||||
|
||||
setHeader(name, value) {
|
||||
this.headers[name] = value;
|
||||
}
|
||||
|
||||
end(data) {
|
||||
if (data) this._chunks.push(data);
|
||||
const output = this._chunks.join("");
|
||||
console.log("\n=== RESPONSE ===");
|
||||
console.log("Status:", this.statusCode);
|
||||
console.log("Headers:", this.headers);
|
||||
console.log("Body:", output);
|
||||
|
||||
// Parse JSON wenn Content-Type gesetzt ist
|
||||
if (this.headers["Content-Type"] === "application/json") {
|
||||
try {
|
||||
const parsed = JSON.parse(output);
|
||||
console.log("\nParsed JSON:");
|
||||
console.log(JSON.stringify(parsed, null, 2));
|
||||
} catch (e) {
|
||||
console.error("JSON Parse Error:", e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test 1: Config Load
|
||||
console.log("\n### TEST 1: Config Load ###");
|
||||
const configReq = new MockRequest("GET", "/api/config/load");
|
||||
const configRes = new MockResponse();
|
||||
await handleConfigLoad(configReq, configRes);
|
||||
|
||||
// Test 2: Steam Refresh (braucht config.local.json)
|
||||
console.log("\n\n### TEST 2: Steam Refresh ###");
|
||||
const steamBody = JSON.stringify({
|
||||
apiKey: "78CDB987B47DDBB9C385522E5F6D0A52",
|
||||
steamId: "76561197960313963",
|
||||
});
|
||||
const steamReq = new MockRequest("POST", "/api/steam/refresh", steamBody);
|
||||
const steamRes = new MockResponse();
|
||||
await handleSteamRefresh(steamReq, steamRes);
|
||||
@@ -1,54 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Standalone Backend-Test
|
||||
* Testet die API-Funktionen direkt ohne Vite-Server
|
||||
*/
|
||||
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const rootDir = join(__dirname, "..");
|
||||
|
||||
console.log("=".repeat(60));
|
||||
console.log("Backend API Test");
|
||||
console.log("=".repeat(60));
|
||||
|
||||
// Test 1: Config File lesen
|
||||
console.log("\n[TEST 1] Config File direkt lesen");
|
||||
console.log("-".repeat(60));
|
||||
|
||||
const configPath = join(rootDir, "config.local.json");
|
||||
console.log("Config Pfad:", configPath);
|
||||
|
||||
try {
|
||||
const configRaw = await readFile(configPath, "utf-8");
|
||||
console.log("\n✓ Datei gelesen, Größe:", configRaw.length, "bytes");
|
||||
console.log("\nInhalt:");
|
||||
console.log(configRaw);
|
||||
|
||||
const config = JSON.parse(configRaw);
|
||||
console.log("\n✓ JSON parsing erfolgreich");
|
||||
console.log("\nGeparste Config:");
|
||||
console.log(JSON.stringify(config, null, 2));
|
||||
|
||||
if (config.steam?.apiKey && config.steam?.steamId) {
|
||||
console.log("\n✓ Steam-Daten vorhanden:");
|
||||
console.log(" - API Key:", config.steam.apiKey.substring(0, 8) + "...");
|
||||
console.log(" - Steam ID:", config.steam.steamId);
|
||||
} else {
|
||||
console.log("\n⚠️ Steam-Daten nicht vollständig");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("\n❌ Fehler beim Lesen der Config:");
|
||||
console.error(" Error:", error.message);
|
||||
console.error(" Stack:", error.stack);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("\n" + "=".repeat(60));
|
||||
console.log("✓ Alle Tests bestanden!");
|
||||
console.log("=".repeat(60));
|
||||
@@ -1,28 +0,0 @@
|
||||
/**
|
||||
* Einfacher Test: Lädt config.local.json
|
||||
*/
|
||||
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const configPath = join(__dirname, "..", "config.local.json");
|
||||
|
||||
console.log("Config Pfad:", configPath);
|
||||
|
||||
try {
|
||||
const configData = await readFile(configPath, "utf-8");
|
||||
console.log("\nRaw File Content:");
|
||||
console.log(configData);
|
||||
|
||||
const config = JSON.parse(configData);
|
||||
console.log("\nParsed Config:");
|
||||
console.log(JSON.stringify(config, null, 2));
|
||||
|
||||
console.log("\n✓ Config erfolgreich geladen!");
|
||||
} catch (error) {
|
||||
console.error("\n❌ Fehler:", error.message);
|
||||
console.error(error);
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
/**
|
||||
* GOG API Handler für Vite Dev Server
|
||||
* Fungiert als Proxy um CORS-Probleme zu vermeiden
|
||||
*/
|
||||
|
||||
import { exchangeGogCode, fetchGogGames } from "./gog-backend.mjs";
|
||||
import { enrichGamesWithIgdb } from "./igdb-cache.mjs";
|
||||
|
||||
export async function handleGogAuth(req, res) {
|
||||
if (req.method !== "POST") {
|
||||
res.statusCode = 405;
|
||||
res.end("Method Not Allowed");
|
||||
return;
|
||||
}
|
||||
|
||||
let body = "";
|
||||
req.on("data", (chunk) => {
|
||||
body += chunk.toString();
|
||||
});
|
||||
|
||||
req.on("end", async () => {
|
||||
try {
|
||||
const payload = JSON.parse(body || "{}");
|
||||
const { code } = payload;
|
||||
|
||||
if (!code) {
|
||||
res.statusCode = 400;
|
||||
res.end(JSON.stringify({ error: "code ist erforderlich" }));
|
||||
return;
|
||||
}
|
||||
|
||||
const tokens = await exchangeGogCode(code);
|
||||
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify(tokens));
|
||||
} catch (error) {
|
||||
res.statusCode = 500;
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleGogRefresh(req, res) {
|
||||
if (req.method !== "POST") {
|
||||
res.statusCode = 405;
|
||||
res.end("Method Not Allowed");
|
||||
return;
|
||||
}
|
||||
|
||||
let body = "";
|
||||
req.on("data", (chunk) => {
|
||||
body += chunk.toString();
|
||||
});
|
||||
|
||||
req.on("end", async () => {
|
||||
try {
|
||||
const payload = JSON.parse(body || "{}");
|
||||
const { accessToken, refreshToken } = payload;
|
||||
|
||||
if (!accessToken || !refreshToken) {
|
||||
res.statusCode = 400;
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: "accessToken und refreshToken sind erforderlich",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await fetchGogGames(accessToken, refreshToken);
|
||||
result.games = await enrichGamesWithIgdb(result.games);
|
||||
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify(result));
|
||||
} catch (error) {
|
||||
res.statusCode = 500;
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
/**
|
||||
* GOG Backend - Unofficial GOG API Integration
|
||||
* Uses Galaxy client credentials (well-known, used by lgogdownloader etc.)
|
||||
*/
|
||||
|
||||
const CLIENT_ID = "46899977096215655";
|
||||
const CLIENT_SECRET =
|
||||
"9d85c43b1482497dbbce61f6e4aa173a433796eeae2ca8c5f6129f2dc4de46d9";
|
||||
const REDIRECT_URI =
|
||||
"https://embed.gog.com/on_login_success?origin=client";
|
||||
|
||||
/**
|
||||
* Exchange authorization code for access + refresh tokens
|
||||
* @param {string} code - Auth code from GOG login redirect
|
||||
* @returns {Promise<{access_token: string, refresh_token: string, user_id: string, expires_in: number}>}
|
||||
*/
|
||||
export async function exchangeGogCode(code) {
|
||||
if (!code) {
|
||||
throw new Error("Authorization code ist erforderlich");
|
||||
}
|
||||
|
||||
const url = new URL("https://auth.gog.com/token");
|
||||
url.searchParams.set("client_id", CLIENT_ID);
|
||||
url.searchParams.set("client_secret", CLIENT_SECRET);
|
||||
url.searchParams.set("grant_type", "authorization_code");
|
||||
url.searchParams.set("code", code);
|
||||
url.searchParams.set("redirect_uri", REDIRECT_URI);
|
||||
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(
|
||||
`GOG Token Exchange Error: ${response.status} ${text}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
access_token: data.access_token,
|
||||
refresh_token: data.refresh_token,
|
||||
user_id: data.user_id,
|
||||
expires_in: data.expires_in,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh an expired access token
|
||||
* @param {string} refreshToken
|
||||
* @returns {Promise<{access_token: string, refresh_token: string, expires_in: number}>}
|
||||
*/
|
||||
async function refreshAccessToken(refreshToken) {
|
||||
const url = new URL("https://auth.gog.com/token");
|
||||
url.searchParams.set("client_id", CLIENT_ID);
|
||||
url.searchParams.set("client_secret", CLIENT_SECRET);
|
||||
url.searchParams.set("grant_type", "refresh_token");
|
||||
url.searchParams.set("refresh_token", refreshToken);
|
||||
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`GOG Token Refresh Error: ${response.status} ${text}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
access_token: data.access_token,
|
||||
refresh_token: data.refresh_token,
|
||||
expires_in: data.expires_in,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all owned games from GOG
|
||||
* @param {string} accessToken
|
||||
* @param {string} refreshToken
|
||||
* @returns {Promise<{games: Array, count: number, newAccessToken?: string, newRefreshToken?: string}>}
|
||||
*/
|
||||
export async function fetchGogGames(accessToken, refreshToken) {
|
||||
if (!accessToken || !refreshToken) {
|
||||
throw new Error("accessToken und refreshToken sind erforderlich");
|
||||
}
|
||||
|
||||
let token = accessToken;
|
||||
let newTokens = null;
|
||||
|
||||
// Fetch first page to get totalPages
|
||||
let page = 1;
|
||||
let totalPages = 1;
|
||||
const allProducts = [];
|
||||
|
||||
while (page <= totalPages) {
|
||||
const url = `https://embed.gog.com/account/getFilteredProducts?mediaType=1&page=${page}`;
|
||||
|
||||
let response = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
// Token expired — try refresh
|
||||
if (response.status === 401 && !newTokens) {
|
||||
console.log("[GOG] Token expired, refreshing...");
|
||||
newTokens = await refreshAccessToken(refreshToken);
|
||||
token = newTokens.access_token;
|
||||
|
||||
response = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`GOG API Error: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
totalPages = data.totalPages || 1;
|
||||
allProducts.push(...(data.products || []));
|
||||
page++;
|
||||
}
|
||||
|
||||
// Transform to our Game format, skip products without title
|
||||
const games = allProducts
|
||||
.filter((product) => product.title)
|
||||
.map((product) => ({
|
||||
id: `gog-${product.id}`,
|
||||
title: product.title,
|
||||
source: "gog",
|
||||
sourceId: String(product.id),
|
||||
platform: "PC",
|
||||
url: product.url
|
||||
? `https://www.gog.com${product.url}`
|
||||
: undefined,
|
||||
}));
|
||||
|
||||
return {
|
||||
games,
|
||||
count: games.length,
|
||||
...(newTokens && {
|
||||
newAccessToken: newTokens.access_token,
|
||||
newRefreshToken: newTokens.refresh_token,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the GOG auth URL for the user to open in their browser
|
||||
*/
|
||||
export function getGogAuthUrl() {
|
||||
const url = new URL("https://auth.gog.com/auth");
|
||||
url.searchParams.set("client_id", CLIENT_ID);
|
||||
url.searchParams.set("redirect_uri", REDIRECT_URI);
|
||||
url.searchParams.set("response_type", "code");
|
||||
url.searchParams.set("layout", "client2");
|
||||
return url.toString();
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
/**
|
||||
* IGDB Cache - Shared canonical game ID resolution
|
||||
* Uses Twitch OAuth + IGDB external_games endpoint
|
||||
* Cache is shared across all users (mappings are universal)
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const CACHE_FILE = join(__dirname, "data", "igdb-cache.json");
|
||||
|
||||
// IGDB external game categories
|
||||
const CATEGORY_STEAM = 1;
|
||||
const CATEGORY_GOG = 2;
|
||||
|
||||
const SOURCE_TO_CATEGORY = {
|
||||
steam: CATEGORY_STEAM,
|
||||
gog: CATEGORY_GOG,
|
||||
};
|
||||
|
||||
// In-memory cache: "steam:12345" → { igdbId: 67890 }
|
||||
const cache = new Map();
|
||||
|
||||
// Twitch OAuth token state
|
||||
let twitchToken = null;
|
||||
let tokenExpiry = 0;
|
||||
|
||||
/**
|
||||
* Load cache from JSON file on disk
|
||||
*/
|
||||
export function loadCache() {
|
||||
try {
|
||||
const data = readFileSync(CACHE_FILE, "utf-8");
|
||||
const entries = JSON.parse(data);
|
||||
for (const [key, value] of Object.entries(entries)) {
|
||||
cache.set(key, value);
|
||||
}
|
||||
console.log(`[IGDB] Cache loaded: ${cache.size} entries`);
|
||||
} catch {
|
||||
console.log("[IGDB] No cache file found, starting fresh");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save cache to JSON file on disk
|
||||
*/
|
||||
function saveCache() {
|
||||
try {
|
||||
mkdirSync(join(__dirname, "data"), { recursive: true });
|
||||
const obj = Object.fromEntries(cache);
|
||||
writeFileSync(CACHE_FILE, JSON.stringify(obj, null, 2));
|
||||
} catch (err) {
|
||||
console.error("[IGDB] Failed to save cache:", err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a valid Twitch access token (refreshes if expired)
|
||||
*/
|
||||
async function getIgdbToken() {
|
||||
if (twitchToken && Date.now() < tokenExpiry) {
|
||||
return twitchToken;
|
||||
}
|
||||
|
||||
const clientId = process.env.TWITCH_CLIENT_ID;
|
||||
const clientSecret = process.env.TWITCH_CLIENT_SECRET;
|
||||
|
||||
if (!clientId || !clientSecret) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const url = new URL("https://id.twitch.tv/oauth2/token");
|
||||
url.searchParams.set("client_id", clientId);
|
||||
url.searchParams.set("client_secret", clientSecret);
|
||||
url.searchParams.set("grant_type", "client_credentials");
|
||||
|
||||
const response = await fetch(url, { method: "POST" });
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(
|
||||
`[IGDB] Twitch auth failed: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
twitchToken = data.access_token;
|
||||
// Refresh 5 minutes before actual expiry
|
||||
tokenExpiry = Date.now() + (data.expires_in - 300) * 1000;
|
||||
console.log("[IGDB] Twitch token acquired");
|
||||
return twitchToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an IGDB API request with Apicalypse query
|
||||
*/
|
||||
async function igdbRequest(endpoint, query) {
|
||||
const token = await getIgdbToken();
|
||||
if (!token) return [];
|
||||
|
||||
const response = await fetch(`https://api.igdb.com/v4${endpoint}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Client-ID": process.env.TWITCH_CLIENT_ID,
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "text/plain",
|
||||
},
|
||||
body: query,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
console.error(`[IGDB] API error: ${response.status} ${text}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleep helper for rate limiting (4 req/sec max)
|
||||
*/
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
/**
|
||||
* Batch-resolve IGDB IDs for a list of source IDs
|
||||
* @param {number} category - IGDB category (1=Steam, 2=GOG)
|
||||
* @param {string[]} sourceIds - List of source-specific IDs
|
||||
* @returns {Map<string, number>} sourceId → igdbGameId
|
||||
*/
|
||||
async function batchResolve(category, sourceIds) {
|
||||
const results = new Map();
|
||||
const BATCH_SIZE = 500;
|
||||
|
||||
for (let i = 0; i < sourceIds.length; i += BATCH_SIZE) {
|
||||
const batch = sourceIds.slice(i, i + BATCH_SIZE);
|
||||
const uids = batch.map((id) => `"${id}"`).join(",");
|
||||
const query = `fields game,uid; where category = ${category} & uid = (${uids}); limit ${BATCH_SIZE};`;
|
||||
|
||||
const data = await igdbRequest("/external_games", query);
|
||||
|
||||
for (const entry of data) {
|
||||
if (entry.game && entry.uid) {
|
||||
results.set(entry.uid, entry.game);
|
||||
}
|
||||
}
|
||||
|
||||
// Rate limit: wait between batches
|
||||
if (i + BATCH_SIZE < sourceIds.length) {
|
||||
await sleep(260);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrich games with IGDB canonical IDs
|
||||
* Graceful: if IGDB is unavailable or no credentials, games pass through unchanged
|
||||
* @param {Array<{source: string, sourceId: string}>} games
|
||||
* @returns {Promise<Array>} Games with canonicalId added where available
|
||||
*/
|
||||
export async function enrichGamesWithIgdb(games) {
|
||||
// Check if IGDB credentials are configured
|
||||
if (!process.env.TWITCH_CLIENT_ID || !process.env.TWITCH_CLIENT_SECRET) {
|
||||
return games;
|
||||
}
|
||||
|
||||
// Find uncached games, grouped by source
|
||||
const uncachedBySource = {};
|
||||
for (const game of games) {
|
||||
const cacheKey = `${game.source}:${game.sourceId}`;
|
||||
if (!cache.has(cacheKey) && SOURCE_TO_CATEGORY[game.source]) {
|
||||
if (!uncachedBySource[game.source]) {
|
||||
uncachedBySource[game.source] = [];
|
||||
}
|
||||
uncachedBySource[game.source].push(game.sourceId);
|
||||
}
|
||||
}
|
||||
|
||||
// Batch-resolve uncached games from IGDB
|
||||
let newEntries = 0;
|
||||
try {
|
||||
for (const [source, sourceIds] of Object.entries(uncachedBySource)) {
|
||||
const category = SOURCE_TO_CATEGORY[source];
|
||||
console.log(
|
||||
`[IGDB] Resolving ${sourceIds.length} ${source} games (category ${category})...`,
|
||||
);
|
||||
|
||||
const resolved = await batchResolve(category, sourceIds);
|
||||
|
||||
for (const [uid, igdbId] of resolved) {
|
||||
cache.set(`${source}:${uid}`, { igdbId });
|
||||
newEntries++;
|
||||
}
|
||||
|
||||
// Mark unresolved games as null so we don't re-query them
|
||||
for (const uid of sourceIds) {
|
||||
if (!resolved.has(uid)) {
|
||||
cache.set(`${source}:${uid}`, { igdbId: null });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (newEntries > 0) {
|
||||
console.log(
|
||||
`[IGDB] Resolved ${newEntries} new games, cache now has ${cache.size} entries`,
|
||||
);
|
||||
saveCache();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[IGDB] Enrichment failed (non-fatal):", err.message);
|
||||
}
|
||||
|
||||
// Enrich games with canonicalId from cache
|
||||
return games.map((game) => {
|
||||
const cached = cache.get(`${game.source}:${game.sourceId}`);
|
||||
if (cached?.igdbId) {
|
||||
return { ...game, canonicalId: String(cached.igdbId) };
|
||||
}
|
||||
return game;
|
||||
});
|
||||
}
|
||||
175
server/index.js
175
server/index.js
@@ -1,175 +0,0 @@
|
||||
import express from "express";
|
||||
import cors from "cors";
|
||||
import fetch from "node-fetch";
|
||||
import { exchangeGogCode, fetchGogGames } from "./gog-backend.mjs";
|
||||
import { enrichGamesWithIgdb, loadCache } from "./igdb-cache.mjs";
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
// Enable CORS for your PWA
|
||||
app.use(
|
||||
cors({
|
||||
origin: process.env.ALLOWED_ORIGIN || "*",
|
||||
}),
|
||||
);
|
||||
|
||||
app.use(express.json());
|
||||
|
||||
// Load IGDB cache on startup
|
||||
loadCache();
|
||||
|
||||
// Health check
|
||||
app.get("/health", (req, res) => {
|
||||
res.json({ status: "ok" });
|
||||
});
|
||||
|
||||
// Steam API refresh endpoint
|
||||
app.post("/steam/refresh", async (req, res) => {
|
||||
const { apiKey, steamId } = req.body;
|
||||
|
||||
console.log(`[Steam] Starting refresh for user: ${steamId}`);
|
||||
|
||||
if (!apiKey || !steamId) {
|
||||
console.log("[Steam] Missing credentials");
|
||||
return res.status(400).json({
|
||||
error: "Missing required fields: apiKey and steamId",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Call Steam Web API
|
||||
const steamUrl = `https://api.steampowered.com/IPlayerService/GetOwnedGames/v1/?key=${apiKey}&steamid=${steamId}&include_appinfo=1&include_played_free_games=1&format=json`;
|
||||
|
||||
console.log("[Steam] Calling Steam API...");
|
||||
const response = await fetch(steamUrl);
|
||||
console.log(`[Steam] Got response: ${response.status}`);
|
||||
|
||||
if (!response.ok) {
|
||||
console.log(`[Steam] Steam API error: ${response.statusText}`);
|
||||
return res.status(response.status).json({
|
||||
error: "Steam API error",
|
||||
message: response.statusText,
|
||||
});
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log(`[Steam] Success! Games count: ${data.response?.game_count || 0}`);
|
||||
|
||||
const rawGames = data.response?.games || [];
|
||||
|
||||
// Enrich with IGDB canonical IDs
|
||||
const gamesForIgdb = rawGames.map((g) => ({
|
||||
...g,
|
||||
source: "steam",
|
||||
sourceId: String(g.appid),
|
||||
}));
|
||||
const enriched = await enrichGamesWithIgdb(gamesForIgdb);
|
||||
|
||||
// Return enriched games (source/sourceId/canonicalId included)
|
||||
const transformed = {
|
||||
games: enriched,
|
||||
count: enriched.length,
|
||||
};
|
||||
|
||||
const responseSize = JSON.stringify(transformed).length;
|
||||
console.log(`[Steam] Sending response: ${responseSize} bytes, ${transformed.games.length} games`);
|
||||
res.json(transformed);
|
||||
console.log(`[Steam] Response sent successfully`);
|
||||
} catch (error) {
|
||||
console.error("[Steam] Exception:", error);
|
||||
res.status(500).json({
|
||||
error: "Failed to fetch games",
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GOG API: Exchange auth code for tokens
|
||||
app.post("/gog/auth", async (req, res) => {
|
||||
const { code } = req.body;
|
||||
|
||||
console.log("[GOG] Starting code exchange");
|
||||
|
||||
if (!code) {
|
||||
return res.status(400).json({ error: "Missing required field: code" });
|
||||
}
|
||||
|
||||
try {
|
||||
const tokens = await exchangeGogCode(code);
|
||||
console.log(`[GOG] Token exchange successful, user: ${tokens.user_id}`);
|
||||
res.json(tokens);
|
||||
} catch (error) {
|
||||
console.error("[GOG] Token exchange error:", error);
|
||||
res.status(500).json({
|
||||
error: "GOG token exchange failed",
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GOG API: Refresh games
|
||||
app.post("/gog/refresh", async (req, res) => {
|
||||
const { accessToken, refreshToken } = req.body;
|
||||
|
||||
console.log("[GOG] Starting game refresh");
|
||||
|
||||
if (!accessToken || !refreshToken) {
|
||||
return res.status(400).json({
|
||||
error: "Missing required fields: accessToken and refreshToken",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await fetchGogGames(accessToken, refreshToken);
|
||||
result.games = await enrichGamesWithIgdb(result.games);
|
||||
console.log(`[GOG] Success! ${result.count} games fetched`);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error("[GOG] Refresh error:", error);
|
||||
res.status(500).json({
|
||||
error: "GOG refresh failed",
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Fallback proxy for other Steam API calls
|
||||
app.all("/*", async (req, res) => {
|
||||
const path = req.url;
|
||||
const steamUrl = `https://store.steampowered.com${path}`;
|
||||
|
||||
console.log(`Proxying: ${req.method} ${steamUrl}`);
|
||||
|
||||
try {
|
||||
const response = await fetch(steamUrl, {
|
||||
method: req.method,
|
||||
headers: {
|
||||
"User-Agent": "WhatToPlay/1.0",
|
||||
Accept: "application/json",
|
||||
...(req.body && { "Content-Type": "application/json" }),
|
||||
},
|
||||
...(req.body && { body: JSON.stringify(req.body) }),
|
||||
});
|
||||
|
||||
const contentType = response.headers.get("content-type");
|
||||
|
||||
if (contentType && contentType.includes("application/json")) {
|
||||
const data = await response.json();
|
||||
res.json(data);
|
||||
} else {
|
||||
const text = await response.text();
|
||||
res.send(text);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Proxy error:", error);
|
||||
res.status(500).json({
|
||||
error: "Proxy error",
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server running on port ${PORT}`);
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"name": "whattoplay-server",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"description": "Simple proxy server for WhatToPlay Steam API calls",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "node index.js",
|
||||
"dev": "node --watch index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"cors": "^2.8.5",
|
||||
"node-fetch": "^3.3.2"
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
/**
|
||||
* Steam API Handler für Vite Dev Server
|
||||
* Fungiert als Proxy um CORS-Probleme zu vermeiden
|
||||
*/
|
||||
|
||||
import { fetchSteamGames } from "./steam-backend.mjs";
|
||||
import { enrichGamesWithIgdb } from "./igdb-cache.mjs";
|
||||
|
||||
export async function handleSteamRefresh(req, res) {
|
||||
if (req.method !== "POST") {
|
||||
res.statusCode = 405;
|
||||
res.end("Method Not Allowed");
|
||||
return;
|
||||
}
|
||||
|
||||
let body = "";
|
||||
req.on("data", (chunk) => {
|
||||
body += chunk.toString();
|
||||
});
|
||||
|
||||
req.on("end", async () => {
|
||||
try {
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(body || "{}");
|
||||
} catch (error) {
|
||||
res.statusCode = 400;
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: "Ungültiges JSON im Request-Body",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { apiKey, steamId } = payload;
|
||||
|
||||
if (!apiKey || !steamId) {
|
||||
res.statusCode = 400;
|
||||
res.end(JSON.stringify({ error: "apiKey und steamId erforderlich" }));
|
||||
return;
|
||||
}
|
||||
|
||||
const { games, count } = await fetchSteamGames(apiKey, steamId);
|
||||
const enriched = await enrichGamesWithIgdb(games);
|
||||
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ games: enriched, count: enriched.length }));
|
||||
} catch (error) {
|
||||
res.statusCode = 500;
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
/**
|
||||
* Steam Backend - Isoliertes Modul für Steam API Calls
|
||||
* Keine Dependencies zu Vite oder Express
|
||||
*/
|
||||
|
||||
/**
|
||||
* Ruft Steam API auf und gibt formatierte Spiele zurück
|
||||
* @param {string} apiKey - Steam Web API Key
|
||||
* @param {string} steamId - Steam User ID
|
||||
* @returns {Promise<{games: Array, count: number}>}
|
||||
*/
|
||||
export async function fetchSteamGames(apiKey, steamId) {
|
||||
if (!apiKey || !steamId) {
|
||||
throw new Error("apiKey und steamId sind erforderlich");
|
||||
}
|
||||
|
||||
// Steam API aufrufen
|
||||
const url = new URL(
|
||||
"https://api.steampowered.com/IPlayerService/GetOwnedGames/v1/",
|
||||
);
|
||||
url.searchParams.set("key", apiKey);
|
||||
url.searchParams.set("steamid", steamId);
|
||||
url.searchParams.set("include_appinfo", "true");
|
||||
url.searchParams.set("include_played_free_games", "true");
|
||||
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Steam API Error: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const rawGames = data.response?.games ?? [];
|
||||
|
||||
// Spiele formatieren
|
||||
const games = rawGames.map((game) => ({
|
||||
id: `steam-${game.appid}`,
|
||||
title: game.name,
|
||||
source: "steam",
|
||||
sourceId: String(game.appid),
|
||||
platform: "PC",
|
||||
lastPlayed: game.rtime_last_played
|
||||
? new Date(game.rtime_last_played * 1000).toISOString().slice(0, 10)
|
||||
: null,
|
||||
playtimeHours: Math.round((game.playtime_forever / 60) * 10) / 10,
|
||||
url: `https://store.steampowered.com/app/${game.appid}`,
|
||||
}));
|
||||
|
||||
return {
|
||||
games,
|
||||
count: games.length,
|
||||
};
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
/**
|
||||
* Tests für Steam Backend
|
||||
* Verwendung: node --test server/steam-backend.test.mjs
|
||||
*/
|
||||
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import { fetchSteamGames } from "./steam-backend.mjs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// Lade Test-Credentials aus config.local.json
|
||||
async function loadTestConfig() {
|
||||
try {
|
||||
const configPath = join(__dirname, "..", "config.local.json");
|
||||
const configData = await readFile(configPath, "utf-8");
|
||||
const config = JSON.parse(configData);
|
||||
return config.steam;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
describe("Steam Backend", () => {
|
||||
describe("fetchSteamGames()", () => {
|
||||
it("sollte Fehler werfen wenn apiKey fehlt", async () => {
|
||||
await assert.rejects(
|
||||
async () => await fetchSteamGames(null, "12345"),
|
||||
/apiKey und steamId sind erforderlich/,
|
||||
);
|
||||
});
|
||||
|
||||
it("sollte Fehler werfen wenn steamId fehlt", async () => {
|
||||
await assert.rejects(
|
||||
async () => await fetchSteamGames("test-key", null),
|
||||
/apiKey und steamId sind erforderlich/,
|
||||
);
|
||||
});
|
||||
|
||||
it("sollte Spiele von echter Steam API laden", async () => {
|
||||
const testConfig = await loadTestConfig();
|
||||
|
||||
if (!testConfig?.apiKey || !testConfig?.steamId) {
|
||||
console.log("⚠️ Überspringe Test - config.local.json nicht vorhanden");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await fetchSteamGames(
|
||||
testConfig.apiKey,
|
||||
testConfig.steamId,
|
||||
);
|
||||
|
||||
// Validiere Struktur
|
||||
assert.ok(result, "Result sollte existieren");
|
||||
assert.ok(
|
||||
typeof result.count === "number",
|
||||
"count sollte eine Zahl sein",
|
||||
);
|
||||
assert.ok(Array.isArray(result.games), "games sollte ein Array sein");
|
||||
assert.strictEqual(
|
||||
result.count,
|
||||
result.games.length,
|
||||
"count sollte games.length entsprechen",
|
||||
);
|
||||
|
||||
// Validiere erstes Spiel (wenn vorhanden)
|
||||
if (result.games.length > 0) {
|
||||
const firstGame = result.games[0];
|
||||
assert.ok(firstGame.id, "Spiel sollte ID haben");
|
||||
assert.ok(firstGame.title, "Spiel sollte Titel haben");
|
||||
assert.strictEqual(firstGame.platform, "PC", "Platform sollte PC sein");
|
||||
assert.strictEqual(
|
||||
firstGame.source,
|
||||
"steam",
|
||||
"Source sollte steam sein",
|
||||
);
|
||||
assert.ok(
|
||||
typeof firstGame.playtimeHours === "number",
|
||||
"playtimeHours sollte eine Zahl sein",
|
||||
);
|
||||
assert.ok(
|
||||
firstGame.url?.includes("steampowered.com"),
|
||||
"URL sollte steampowered.com enthalten",
|
||||
);
|
||||
|
||||
console.log(`\n✓ ${result.count} Spiele erfolgreich geladen`);
|
||||
console.log(
|
||||
` Beispiel: "${firstGame.title}" (${firstGame.playtimeHours}h)`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("sollte Fehler bei ungültigen Credentials werfen", async () => {
|
||||
await assert.rejects(
|
||||
async () => await fetchSteamGames("invalid-key", "invalid-id"),
|
||||
/Steam API Error/,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
.content {
|
||||
--padding-top: 16px;
|
||||
--padding-start: 16px;
|
||||
--padding-end: 16px;
|
||||
}
|
||||
84
src/App.tsx
84
src/App.tsx
@@ -1,84 +0,0 @@
|
||||
import {
|
||||
IonIcon,
|
||||
IonLabel,
|
||||
IonRouterOutlet,
|
||||
IonTabBar,
|
||||
IonTabButton,
|
||||
IonTabs,
|
||||
IonApp,
|
||||
} from "@ionic/react";
|
||||
import { IonReactRouter } from "@ionic/react-router";
|
||||
import {
|
||||
albumsOutline,
|
||||
heartCircleOutline,
|
||||
homeOutline,
|
||||
libraryOutline,
|
||||
settingsOutline,
|
||||
} from "ionicons/icons";
|
||||
import { Redirect, Route, Switch } from "react-router-dom";
|
||||
|
||||
import DiscoverPage from "./pages/Discover/DiscoverPage";
|
||||
import HomePage from "./pages/Home/HomePage";
|
||||
import LibraryPage from "./pages/Library/LibraryPage";
|
||||
import PlaylistsPage from "./pages/Playlists/PlaylistsPage";
|
||||
import PlaylistDetailPage from "./pages/Playlists/PlaylistDetailPage";
|
||||
import SettingsPage from "./pages/Settings/SettingsPage";
|
||||
import SettingsDetailPage from "./pages/Settings/SettingsDetailPage";
|
||||
|
||||
import "./App.css";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<IonApp>
|
||||
<IonReactRouter basename={import.meta.env.BASE_URL}>
|
||||
<IonTabs>
|
||||
<IonRouterOutlet>
|
||||
<Switch>
|
||||
<Route exact path="/home" component={HomePage} />
|
||||
<Route exact path="/library" component={LibraryPage} />
|
||||
<Route exact path="/playlists" component={PlaylistsPage} />
|
||||
<Route
|
||||
exact
|
||||
path="/playlists/:playlistId"
|
||||
component={PlaylistDetailPage}
|
||||
/>
|
||||
<Route exact path="/discover" component={DiscoverPage} />
|
||||
<Route exact path="/settings" component={SettingsPage} />
|
||||
<Route
|
||||
exact
|
||||
path="/settings/:serviceId"
|
||||
component={SettingsDetailPage}
|
||||
/>
|
||||
<Route exact path="/">
|
||||
<Redirect to="/home" />
|
||||
</Route>
|
||||
</Switch>
|
||||
</IonRouterOutlet>
|
||||
|
||||
<IonTabBar slot="bottom">
|
||||
<IonTabButton tab="home" href="/home">
|
||||
<IonIcon aria-hidden="true" icon={homeOutline} />
|
||||
<IonLabel>Home</IonLabel>
|
||||
</IonTabButton>
|
||||
<IonTabButton tab="library" href="/library">
|
||||
<IonIcon aria-hidden="true" icon={libraryOutline} />
|
||||
<IonLabel>Bibliothek</IonLabel>
|
||||
</IonTabButton>
|
||||
<IonTabButton tab="playlists" href="/playlists">
|
||||
<IonIcon aria-hidden="true" icon={albumsOutline} />
|
||||
<IonLabel>Playlists</IonLabel>
|
||||
</IonTabButton>
|
||||
<IonTabButton tab="discover" href="/discover">
|
||||
<IonIcon aria-hidden="true" icon={heartCircleOutline} />
|
||||
<IonLabel>Entdecken</IonLabel>
|
||||
</IonTabButton>
|
||||
<IonTabButton tab="settings" href="/settings">
|
||||
<IonIcon aria-hidden="true" icon={settingsOutline} />
|
||||
<IonLabel>Einstellungen</IonLabel>
|
||||
</IonTabButton>
|
||||
</IonTabBar>
|
||||
</IonTabs>
|
||||
</IonReactRouter>
|
||||
</IonApp>
|
||||
);
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
import {
|
||||
cloudOutline,
|
||||
gameControllerOutline,
|
||||
globeOutline,
|
||||
shieldOutline,
|
||||
storefrontOutline,
|
||||
} from "ionicons/icons";
|
||||
|
||||
export interface TutorialStep {
|
||||
title: string;
|
||||
description: string;
|
||||
code?: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export interface Tutorial {
|
||||
title: string;
|
||||
icon: string;
|
||||
steps: TutorialStep[];
|
||||
tips: string[];
|
||||
}
|
||||
|
||||
export const TUTORIALS: Record<string, Tutorial> = {
|
||||
steam: {
|
||||
title: "Steam API Key & ID einrichten",
|
||||
icon: gameControllerOutline,
|
||||
steps: [
|
||||
{
|
||||
title: "1. Gehe zu Steam Web API",
|
||||
description:
|
||||
"Öffne https://steamcommunity.com/dev/apikey in deinem Browser",
|
||||
code: "https://steamcommunity.com/dev/apikey",
|
||||
},
|
||||
{
|
||||
title: "2. Login & Registrierung",
|
||||
description:
|
||||
"Falls nötig, akzeptiere die Vereinbarungen und registriere dich",
|
||||
hint: "Du brauchst einen Steam Account mit mindestens 5€ Spielezeit",
|
||||
},
|
||||
{
|
||||
title: "3. API Key kopieren",
|
||||
description: "Kopiere deinen generierten API Key aus dem Textfeld",
|
||||
hint: "Halte diesen Key privat! Teile ihn nicht öffentlich!",
|
||||
},
|
||||
{
|
||||
title: "4. Steam ID finden",
|
||||
description: "Gehe zu https://www.steamcommunity.com/",
|
||||
code: "https://www.steamcommunity.com/",
|
||||
},
|
||||
{
|
||||
title: "5. Profil öffnen",
|
||||
description: "Klicke auf deinen Namen oben rechts",
|
||||
hint: "Die URL sollte /profiles/[STEAM_ID]/ enthalten",
|
||||
},
|
||||
{
|
||||
title: "6. Steam ID kopieren",
|
||||
description: "Kopiere die Nummern aus der URL (z.B. 76561197960434622)",
|
||||
hint: "Das ist eine lange Nummer, keine Kurzform",
|
||||
},
|
||||
],
|
||||
tips: [
|
||||
"Der API Key wird automatisch alle 24 Stunden zurückgesetzt",
|
||||
"Dein Game-Profil muss auf 'Öffentlich' gestellt sein",
|
||||
"Private Games werden nicht angezeigt",
|
||||
],
|
||||
},
|
||||
|
||||
gog: {
|
||||
title: "GOG Galaxy Login",
|
||||
icon: globeOutline,
|
||||
steps: [
|
||||
{
|
||||
title: "1. OAuth Proxy starten",
|
||||
description: "Im Terminal: npm run oauth",
|
||||
code: "npm run oauth",
|
||||
hint: "Startet lokalen OAuth Proxy auf Port 3001",
|
||||
},
|
||||
{
|
||||
title: "2. Mit GOG einloggen",
|
||||
description: "Klicke auf 'Mit GOG einloggen' in der App",
|
||||
hint: "Du wirst zu GOG weitergeleitet",
|
||||
},
|
||||
{
|
||||
title: "3. Bei GOG anmelden",
|
||||
description: "Melde dich mit deinen GOG Zugangsdaten an",
|
||||
hint: "Akzeptiere die Berechtigungen",
|
||||
},
|
||||
{
|
||||
title: "4. Automatisch verbunden",
|
||||
description: "Nach der Anmeldung wirst du zurück zur App geleitet",
|
||||
hint: "Dein Token wird automatisch gespeichert",
|
||||
},
|
||||
],
|
||||
tips: [
|
||||
"Der OAuth Proxy muss laufen (npm run oauth)",
|
||||
"Tokens werden automatisch erneuert",
|
||||
"Für Production: Deploy den Worker zu Cloudflare",
|
||||
],
|
||||
},
|
||||
|
||||
epic: {
|
||||
title: "Epic Games (Manueller Import)",
|
||||
icon: shieldOutline,
|
||||
steps: [
|
||||
{
|
||||
title: "1. Keine API verfügbar",
|
||||
description: "Epic Games hat KEINE öffentliche API für Bibliotheken",
|
||||
hint: "Auch OAuth ist nicht möglich",
|
||||
},
|
||||
{
|
||||
title: "2. JSON-Datei erstellen",
|
||||
description: "Erstelle eine JSON-Datei mit deinen Spielen",
|
||||
code: `[
|
||||
{"name": "Fortnite", "appId": "fortnite"},
|
||||
{"name": "Rocket League", "appId": "rocket-league"}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
title: "3. Datei hochladen",
|
||||
description: "Klicke auf 'Games JSON importieren' und wähle deine Datei",
|
||||
hint: "Unterstützt auch {games: [...]} Format",
|
||||
},
|
||||
],
|
||||
tips: [
|
||||
"Epic erlaubt keinen API-Zugriff auf Libraries",
|
||||
"Manuelle Import ist die einzige Option",
|
||||
"Spiele-Namen aus Epic Launcher abschreiben",
|
||||
],
|
||||
},
|
||||
|
||||
amazon: {
|
||||
title: "Amazon Games (Manueller Import)",
|
||||
icon: storefrontOutline,
|
||||
steps: [
|
||||
{
|
||||
title: "1. Keine API verfügbar",
|
||||
description: "Amazon hat KEINE öffentliche API für Prime Gaming",
|
||||
hint: "Auch OAuth ist nicht möglich",
|
||||
},
|
||||
{
|
||||
title: "2. Spiele-Liste erstellen",
|
||||
description: "Gehe zu gaming.amazon.com und notiere deine Spiele",
|
||||
code: "https://gaming.amazon.com/home",
|
||||
},
|
||||
{
|
||||
title: "3. JSON-Datei erstellen",
|
||||
description: "Erstelle eine JSON-Datei mit deinen Spielen",
|
||||
code: `[
|
||||
{"name": "Fallout 76", "source": "prime"},
|
||||
{"name": "Control", "source": "prime"}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
title: "4. Datei hochladen",
|
||||
description: "Klicke auf 'Games JSON importieren' und wähle deine Datei",
|
||||
hint: "source: 'prime' oder 'luna'",
|
||||
},
|
||||
],
|
||||
tips: [
|
||||
"Amazon erlaubt keinen API-Zugriff",
|
||||
"Manuelle Import ist die einzige Option",
|
||||
"Prime Gaming Spiele wechseln monatlich",
|
||||
],
|
||||
},
|
||||
|
||||
blizzard: {
|
||||
title: "Blizzard OAuth Setup",
|
||||
icon: cloudOutline,
|
||||
steps: [
|
||||
{
|
||||
title: "1. Battle.net Developers",
|
||||
description: "Gehe zu https://develop.battle.net und melde dich an",
|
||||
code: "https://develop.battle.net",
|
||||
},
|
||||
{
|
||||
title: "2. API-Zugang anfordern",
|
||||
description: "Klicke auf 'Create Application' oder gehe zu API Access",
|
||||
hint: "Du brauchst einen Account mit mindestens einem Blizzard-Spiel",
|
||||
},
|
||||
{
|
||||
title: "3. App registrieren",
|
||||
description:
|
||||
"Gebe einen Namen ein (z.B. 'WhatToPlay') und akzeptiere Terms",
|
||||
hint: "Das ist für deine persönliche Nutzung",
|
||||
},
|
||||
{
|
||||
title: "4. Client ID kopieren",
|
||||
description: "Kopiere die 'Client ID' aus deiner API-Anwendung",
|
||||
code: "Client ID: xxx-xxx-xxx",
|
||||
},
|
||||
{
|
||||
title: "5. Client Secret kopieren",
|
||||
description:
|
||||
"Generiere und kopiere das 'Client Secret' (einmalig sichtbar!)",
|
||||
hint: "Speichere es sicher! Du kannst es später nicht mehr sehen!",
|
||||
},
|
||||
{
|
||||
title: "6. OAuth Callback URL",
|
||||
description:
|
||||
"Setze die Redirect URI auf https://whattoplay.local/auth/callback",
|
||||
hint: "Dies ist für lokale Entwicklung",
|
||||
},
|
||||
],
|
||||
tips: [
|
||||
"Blizzard supports: WoW, Diablo, Overwatch, StarCraft, Heroes",
|
||||
"Für Production brauchst du ein Backend für OAuth",
|
||||
"Der API Access kann bis zu 24 Stunden dauern",
|
||||
],
|
||||
},
|
||||
};
|
||||
26
src/main.tsx
26
src/main.tsx
@@ -1,26 +0,0 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { setupIonicReact } from "@ionic/react";
|
||||
|
||||
import App from "./App";
|
||||
|
||||
import "@ionic/react/css/core.css";
|
||||
import "@ionic/react/css/normalize.css";
|
||||
import "@ionic/react/css/structure.css";
|
||||
import "@ionic/react/css/typography.css";
|
||||
import "@ionic/react/css/padding.css";
|
||||
import "@ionic/react/css/float-elements.css";
|
||||
import "@ionic/react/css/text-alignment.css";
|
||||
import "@ionic/react/css/text-transformation.css";
|
||||
import "@ionic/react/css/flex-utils.css";
|
||||
import "@ionic/react/css/display.css";
|
||||
|
||||
import "./theme/variables.css";
|
||||
|
||||
setupIonicReact({ mode: "ios" });
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -1,241 +0,0 @@
|
||||
.discover-content {
|
||||
--padding-top: 16px;
|
||||
--padding-start: 16px;
|
||||
--padding-end: 16px;
|
||||
}
|
||||
|
||||
/* States: loading, empty */
|
||||
.discover-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4rem 2rem;
|
||||
text-align: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.discover-state p {
|
||||
margin: 0;
|
||||
color: #8e8e93;
|
||||
}
|
||||
|
||||
.discover-state-hint {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Done state */
|
||||
.discover-done {
|
||||
background: #ffffff;
|
||||
border-radius: 20px;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.08);
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.discover-done h2 {
|
||||
margin: 0 0 1.5rem;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.discover-done-stats {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 2rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.discover-done-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.discover-done-stat strong {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.discover-done-stat span {
|
||||
font-size: 0.85rem;
|
||||
color: #8e8e93;
|
||||
}
|
||||
|
||||
/* Progress bar */
|
||||
.discover-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.discover-progress span {
|
||||
font-size: 0.85rem;
|
||||
color: #8e8e93;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.discover-progress-bar {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
background: #e5e5ea;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.discover-progress-fill {
|
||||
height: 100%;
|
||||
background: var(--ion-color-primary, #0a84ff);
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
/* Card stack */
|
||||
.discover-stack {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 3 / 4;
|
||||
max-height: 55vh;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.discover-stack > div {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Game card */
|
||||
.discover-card {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #ffffff;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
user-select: none;
|
||||
cursor: grab;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.discover-card:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.discover-card-behind {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Card image */
|
||||
.discover-card-image {
|
||||
width: 100%;
|
||||
height: 45%;
|
||||
min-height: 120px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.discover-card-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Card body */
|
||||
.discover-card-body {
|
||||
flex: 1;
|
||||
padding: 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.discover-card-source {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.discover-card-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 auto;
|
||||
line-height: 1.2;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.discover-card-details {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
padding-top: 0.75rem;
|
||||
}
|
||||
|
||||
.discover-card-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.discover-card-detail-label {
|
||||
font-size: 0.7rem;
|
||||
color: #8e8e93;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.discover-card-detail-value {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Action buttons */
|
||||
.discover-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 2rem;
|
||||
padding: 1.5rem 0;
|
||||
}
|
||||
|
||||
.discover-action-btn {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid;
|
||||
background: #ffffff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.75rem;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.discover-action-btn:active {
|
||||
transform: scale(0.92);
|
||||
}
|
||||
|
||||
.discover-action-skip {
|
||||
border-color: #ff3b30;
|
||||
color: #ff3b30;
|
||||
}
|
||||
|
||||
.discover-action-skip:hover {
|
||||
background: #fff5f5;
|
||||
}
|
||||
|
||||
.discover-action-like {
|
||||
border-color: #34c759;
|
||||
color: #34c759;
|
||||
}
|
||||
|
||||
.discover-action-like:hover {
|
||||
background: #f0faf3;
|
||||
}
|
||||
@@ -1,296 +0,0 @@
|
||||
import {
|
||||
IonAlert,
|
||||
IonBadge,
|
||||
IonButton,
|
||||
IonContent,
|
||||
IonHeader,
|
||||
IonIcon,
|
||||
IonPage,
|
||||
IonSpinner,
|
||||
IonTitle,
|
||||
IonToolbar,
|
||||
} from "@ionic/react";
|
||||
import { closeOutline, checkmarkOutline, refreshOutline } from "ionicons/icons";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type SyntheticEvent,
|
||||
} from "react";
|
||||
import TinderCard from "react-tinder-card";
|
||||
import { db, type Game, type Playlist } from "../../services/Database";
|
||||
|
||||
import "./DiscoverPage.css";
|
||||
|
||||
const formatDate = (value?: string | null) => {
|
||||
if (!value) return "-";
|
||||
return new Date(value).toLocaleDateString("de");
|
||||
};
|
||||
|
||||
const formatPlaytime = (hours?: number) => {
|
||||
if (!hours) return "0 h";
|
||||
if (hours < 1) return `${Math.round(hours * 60)} min`;
|
||||
return `${hours.toFixed(1)} h`;
|
||||
};
|
||||
|
||||
export default function DiscoverPage() {
|
||||
const [games, setGames] = useState<Game[]>([]);
|
||||
const [playlists, setPlaylists] = useState<Playlist[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showResetAlert, setShowResetAlert] = useState(false);
|
||||
|
||||
const cardRefs = useRef<Map<number, any>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [dbGames, dbPlaylists] = await Promise.all([
|
||||
db.getGames(),
|
||||
db.getPlaylists(),
|
||||
]);
|
||||
|
||||
if (active) {
|
||||
setGames(dbGames);
|
||||
setPlaylists(dbPlaylists);
|
||||
}
|
||||
} finally {
|
||||
if (active) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
load();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const unseenGames = useMemo(() => {
|
||||
const allSwipedGameIds = new Set(playlists.flatMap((p) => p.gameIds));
|
||||
return games.filter((g) => !allSwipedGameIds.has(g.id));
|
||||
}, [games, playlists]);
|
||||
|
||||
const handleSwipe = useCallback(async (direction: string, gameId: string) => {
|
||||
const playlistId =
|
||||
direction === "right" ? "want-to-play" : "not-interesting";
|
||||
await db.addGameToPlaylist(playlistId, gameId);
|
||||
|
||||
// Reload playlists to update UI
|
||||
const updatedPlaylists = await db.getPlaylists();
|
||||
setPlaylists(updatedPlaylists);
|
||||
}, []);
|
||||
|
||||
const swipeButton = useCallback(
|
||||
(direction: "left" | "right") => {
|
||||
if (unseenGames.length === 0) return;
|
||||
const topGame = unseenGames[unseenGames.length - 1];
|
||||
const topIndex = games.indexOf(topGame);
|
||||
const ref = cardRefs.current.get(topIndex);
|
||||
if (ref) {
|
||||
ref.swipe(direction);
|
||||
}
|
||||
},
|
||||
[unseenGames, games],
|
||||
);
|
||||
|
||||
const handleReset = useCallback(async () => {
|
||||
// Clear both playlists
|
||||
const wantToPlay = playlists.find((p) => p.id === "want-to-play");
|
||||
const notInteresting = playlists.find((p) => p.id === "not-interesting");
|
||||
|
||||
if (wantToPlay) {
|
||||
await db.createPlaylist({ ...wantToPlay, gameIds: [] });
|
||||
}
|
||||
if (notInteresting) {
|
||||
await db.createPlaylist({ ...notInteresting, gameIds: [] });
|
||||
}
|
||||
|
||||
// Reload playlists
|
||||
const updatedPlaylists = await db.getPlaylists();
|
||||
setPlaylists(updatedPlaylists);
|
||||
}, [playlists]);
|
||||
|
||||
const wantToPlay = playlists.find((p) => p.id === "want-to-play");
|
||||
const notInteresting = playlists.find((p) => p.id === "not-interesting");
|
||||
const totalSwiped =
|
||||
(wantToPlay?.gameIds.length || 0) + (notInteresting?.gameIds.length || 0);
|
||||
const interestedCount = wantToPlay?.gameIds.length || 0;
|
||||
const skippedCount = notInteresting?.gameIds.length || 0;
|
||||
|
||||
return (
|
||||
<IonPage>
|
||||
<IonHeader translucent>
|
||||
<IonToolbar>
|
||||
<IonTitle>Entdecken</IonTitle>
|
||||
{totalSwiped > 0 && (
|
||||
<IonButton
|
||||
slot="end"
|
||||
fill="clear"
|
||||
onClick={() => setShowResetAlert(true)}
|
||||
color="medium"
|
||||
>
|
||||
<IonIcon slot="icon-only" icon={refreshOutline} />
|
||||
</IonButton>
|
||||
)}
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
<IonAlert
|
||||
isOpen={showResetAlert}
|
||||
onDidDismiss={() => setShowResetAlert(false)}
|
||||
header="Zurucksetzen?"
|
||||
message={`Alle ${totalSwiped} Swipe-Entscheidungen werden geloscht.`}
|
||||
buttons={[
|
||||
{ text: "Abbrechen", role: "cancel" },
|
||||
{
|
||||
text: "Zurucksetzen",
|
||||
role: "destructive",
|
||||
handler: handleReset,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<IonContent fullscreen className="discover-content">
|
||||
<IonHeader collapse="condense">
|
||||
<IonToolbar>
|
||||
<IonTitle size="large">Entdecken</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
{loading ? (
|
||||
<div className="discover-state">
|
||||
<IonSpinner name="crescent" />
|
||||
<p>Lade Spiele ...</p>
|
||||
</div>
|
||||
) : games.length === 0 ? (
|
||||
<div className="discover-state">
|
||||
<p>Keine Spiele vorhanden.</p>
|
||||
<p className="discover-state-hint">
|
||||
Importiere zuerst Spiele in den Einstellungen.
|
||||
</p>
|
||||
</div>
|
||||
) : unseenGames.length === 0 ? (
|
||||
<div className="discover-done">
|
||||
<h2>Alle Spiele gesehen!</h2>
|
||||
<div className="discover-done-stats">
|
||||
<div className="discover-done-stat">
|
||||
<strong>{interestedCount}</strong>
|
||||
<span>Interessiert</span>
|
||||
</div>
|
||||
<div className="discover-done-stat">
|
||||
<strong>{skippedCount}</strong>
|
||||
<span>Ubersprungen</span>
|
||||
</div>
|
||||
</div>
|
||||
<IonButton expand="block" fill="outline" onClick={handleReset}>
|
||||
<IonIcon slot="start" icon={refreshOutline} />
|
||||
Nochmal starten
|
||||
</IonButton>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="discover-progress">
|
||||
<span>
|
||||
{totalSwiped} / {games.length} Spiele
|
||||
</span>
|
||||
<div className="discover-progress-bar">
|
||||
<div
|
||||
className="discover-progress-fill"
|
||||
style={{
|
||||
width: `${(totalSwiped / games.length) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="discover-stack">
|
||||
{unseenGames.slice(-3).map((game, i, arr) => {
|
||||
const globalIndex = games.indexOf(game);
|
||||
const stackPosition = arr.length - 1 - i;
|
||||
return (
|
||||
<TinderCard
|
||||
ref={(ref: any) => {
|
||||
if (ref) {
|
||||
cardRefs.current.set(globalIndex, ref);
|
||||
} else {
|
||||
cardRefs.current.delete(globalIndex);
|
||||
}
|
||||
}}
|
||||
key={game.id}
|
||||
onSwipe={(dir: string) => handleSwipe(dir, game.id)}
|
||||
preventSwipe={["up", "down"]}
|
||||
swipeRequirementType="position"
|
||||
swipeThreshold={80}
|
||||
>
|
||||
<div
|
||||
className={`discover-card ${stackPosition > 0 ? "discover-card-behind" : ""}`}
|
||||
style={{
|
||||
zIndex: arr.length - stackPosition,
|
||||
transform: `scale(${1 - stackPosition * 0.04}) translateY(${stackPosition * 12}px)`,
|
||||
}}
|
||||
>
|
||||
<div className="discover-card-image">
|
||||
<img
|
||||
src={`https://cdn.cloudflare.steamstatic.com/steam/apps/${game.id.split("-")[1]}/header.jpg`}
|
||||
alt={game.title}
|
||||
onError={(e: SyntheticEvent<HTMLImageElement>) => {
|
||||
e.currentTarget.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="discover-card-body">
|
||||
<div className="discover-card-source">
|
||||
<IonBadge color="medium">
|
||||
{game.source ?? "Unbekannt"}
|
||||
</IonBadge>
|
||||
</div>
|
||||
<h2 className="discover-card-title">{game.title}</h2>
|
||||
<div className="discover-card-details">
|
||||
<div className="discover-card-detail">
|
||||
<span className="discover-card-detail-label">
|
||||
Spielzeit
|
||||
</span>
|
||||
<span className="discover-card-detail-value">
|
||||
{formatPlaytime(game.playtimeHours)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="discover-card-detail">
|
||||
<span className="discover-card-detail-label">
|
||||
Zuletzt gespielt
|
||||
</span>
|
||||
<span className="discover-card-detail-value">
|
||||
{formatDate(game.lastPlayed)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TinderCard>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="discover-actions">
|
||||
<button
|
||||
className="discover-action-btn discover-action-skip"
|
||||
onClick={() => swipeButton("left")}
|
||||
>
|
||||
<IonIcon icon={closeOutline} />
|
||||
</button>
|
||||
<button
|
||||
className="discover-action-btn discover-action-like"
|
||||
onClick={() => swipeButton("right")}
|
||||
>
|
||||
<IonIcon icon={checkmarkOutline} />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
.home-content {
|
||||
--padding-top: 16px;
|
||||
--padding-start: 16px;
|
||||
--padding-end: 16px;
|
||||
}
|
||||
|
||||
.home-placeholder {
|
||||
background: #ffffff;
|
||||
border-radius: 20px;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.home-placeholder h2 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.home-placeholder p {
|
||||
margin: 0;
|
||||
color: #8e8e93;
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import {
|
||||
IonContent,
|
||||
IonHeader,
|
||||
IonPage,
|
||||
IonTitle,
|
||||
IonToolbar,
|
||||
} from "@ionic/react";
|
||||
|
||||
import "./HomePage.css";
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<IonPage>
|
||||
<IonHeader translucent>
|
||||
<IonToolbar>
|
||||
<IonTitle>Home</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
<IonContent fullscreen className="home-content">
|
||||
<IonHeader collapse="condense">
|
||||
<IonToolbar>
|
||||
<IonTitle size="large">Home</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
<div className="home-placeholder">
|
||||
<h2>Willkommen bei WhatToPlay</h2>
|
||||
<p>Helper-Widgets und Statistiken kommen hier später rein.</p>
|
||||
</div>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
.library-content {
|
||||
--padding-top: 16px;
|
||||
--padding-start: 16px;
|
||||
--padding-end: 16px;
|
||||
}
|
||||
|
||||
.library-searchbar {
|
||||
--background: transparent;
|
||||
--border-radius: 0;
|
||||
--padding-top: 8px;
|
||||
--padding-start: 8px;
|
||||
--padding-end: 8px;
|
||||
--padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.action-sheet-button.sort-action-active {
|
||||
color: #007aff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 0 0 0.4rem;
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
margin: 0;
|
||||
color: #6b6f78;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.hero-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(120px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.hero-stats div {
|
||||
background: #f2f2f7;
|
||||
border-radius: 16px;
|
||||
padding: 0.8rem 0.9rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hero-stats span {
|
||||
color: #8e8e93;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.hero-stats strong {
|
||||
display: block;
|
||||
font-size: 1.4rem;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.state {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: #8e8e93;
|
||||
}
|
||||
|
||||
.state.error {
|
||||
color: #ff453a;
|
||||
}
|
||||
|
||||
.game-list {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.game-list ion-item {
|
||||
--padding-start: 16px;
|
||||
--padding-end: 16px;
|
||||
--inner-padding-end: 12px;
|
||||
|
||||
}
|
||||
@@ -1,313 +0,0 @@
|
||||
import {
|
||||
IonBadge,
|
||||
IonActionSheet,
|
||||
IonButton,
|
||||
IonContent,
|
||||
IonHeader,
|
||||
IonIcon,
|
||||
IonItem,
|
||||
IonLabel,
|
||||
IonList,
|
||||
IonNote,
|
||||
IonPage,
|
||||
IonSearchbar,
|
||||
IonSpinner,
|
||||
IonTitle,
|
||||
IonToolbar,
|
||||
} from "@ionic/react";
|
||||
import { heart, heartOutline, swapVerticalOutline } from "ionicons/icons";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { db, type Game } from "../../services/Database";
|
||||
|
||||
import "./LibraryPage.css";
|
||||
|
||||
const formatDate = (value?: string | null) => {
|
||||
if (!value) return "-";
|
||||
return new Date(value).toLocaleDateString("de");
|
||||
};
|
||||
|
||||
const normalizeTitle = (title: string) =>
|
||||
title.toLowerCase().replace(/[:\-]/g, " ").replace(/\s+/g, " ").trim();
|
||||
|
||||
const mergeGames = (allGames: Game[]) => {
|
||||
const map = new Map<string, Game>();
|
||||
|
||||
allGames.forEach((game) => {
|
||||
if (!game.title) return;
|
||||
|
||||
// Primary: canonicalId (IGDB), fallback: normalized title
|
||||
const key = game.canonicalId || `title:${normalizeTitle(game.title)}`;
|
||||
const existing = map.get(key);
|
||||
|
||||
if (!existing) {
|
||||
map.set(key, { ...game });
|
||||
} else {
|
||||
// Merge: bevorzuge neuestes lastPlayed und summiere playtime
|
||||
if (
|
||||
game.lastPlayed &&
|
||||
(!existing.lastPlayed || game.lastPlayed > existing.lastPlayed)
|
||||
) {
|
||||
existing.lastPlayed = game.lastPlayed;
|
||||
}
|
||||
existing.playtimeHours =
|
||||
(existing.playtimeHours || 0) + (game.playtimeHours || 0);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(map.values());
|
||||
};
|
||||
|
||||
export default function LibraryPage() {
|
||||
const [games, setGames] = useState<Game[]>([]);
|
||||
const [favoriteIds, setFavoriteIds] = useState<Set<string>>(new Set());
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [sortBy, setSortBy] = useState<"title" | "playtime" | "lastPlayed">(
|
||||
"playtime",
|
||||
);
|
||||
const [showSortSheet, setShowSortSheet] = useState(false);
|
||||
const [displayCount, setDisplayCount] = useState(20);
|
||||
const INITIAL_RENDER_COUNT = 20;
|
||||
const BACKGROUND_CHUNK_SIZE = 50;
|
||||
const BACKGROUND_CHUNK_DELAY = 50;
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const [dbGames, favPlaylist] = await Promise.all([
|
||||
db.getGames(),
|
||||
db.getPlaylist("favorites"),
|
||||
]);
|
||||
|
||||
if (active) {
|
||||
setGames(mergeGames(dbGames));
|
||||
setFavoriteIds(new Set(favPlaylist?.gameIds ?? []));
|
||||
setError(null);
|
||||
}
|
||||
} catch (err) {
|
||||
if (active) {
|
||||
setError(err instanceof Error ? err.message : "Unbekannter Fehler");
|
||||
}
|
||||
} finally {
|
||||
if (active) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
load();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const totalPlaytime = useMemo(() => {
|
||||
return games.reduce(
|
||||
(sum: number, game: Game) => sum + (game.playtimeHours ?? 0),
|
||||
0,
|
||||
);
|
||||
}, [games]);
|
||||
|
||||
const filteredAndSortedGames = useMemo(() => {
|
||||
let filtered = games.filter(
|
||||
(game) =>
|
||||
game.title &&
|
||||
game.title.toLowerCase().includes(searchText.toLowerCase()),
|
||||
);
|
||||
|
||||
filtered.sort((a, b) => {
|
||||
if (sortBy === "title") {
|
||||
return a.title.localeCompare(b.title, "de");
|
||||
} else if (sortBy === "playtime") {
|
||||
return (b.playtimeHours ?? 0) - (a.playtimeHours ?? 0);
|
||||
}
|
||||
const aDate = a.lastPlayed ? new Date(a.lastPlayed).getTime() : 0;
|
||||
const bDate = b.lastPlayed ? new Date(b.lastPlayed).getTime() : 0;
|
||||
return bDate - aDate;
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}, [games, searchText, sortBy]);
|
||||
|
||||
useEffect(() => {
|
||||
let timeoutId: number | undefined;
|
||||
|
||||
const targetCount = filteredAndSortedGames.length;
|
||||
setDisplayCount(Math.min(INITIAL_RENDER_COUNT, targetCount));
|
||||
|
||||
const scheduleNextChunk = () => {
|
||||
timeoutId = window.setTimeout(() => {
|
||||
setDisplayCount((prev) => {
|
||||
const next = Math.min(prev + BACKGROUND_CHUNK_SIZE, targetCount);
|
||||
if (next < targetCount) {
|
||||
scheduleNextChunk();
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, BACKGROUND_CHUNK_DELAY);
|
||||
};
|
||||
|
||||
if (targetCount > INITIAL_RENDER_COUNT) {
|
||||
scheduleNextChunk();
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timeoutId !== undefined) {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
}, [
|
||||
filteredAndSortedGames,
|
||||
INITIAL_RENDER_COUNT,
|
||||
BACKGROUND_CHUNK_SIZE,
|
||||
BACKGROUND_CHUNK_DELAY,
|
||||
]);
|
||||
|
||||
const displayedGames = useMemo(() => {
|
||||
return filteredAndSortedGames.slice(0, displayCount);
|
||||
}, [filteredAndSortedGames, displayCount]);
|
||||
|
||||
const handleToggleFavorite = async (gameId: string) => {
|
||||
if (favoriteIds.has(gameId)) {
|
||||
await db.removeGameFromPlaylist("favorites", gameId);
|
||||
setFavoriteIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(gameId);
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
await db.addGameToPlaylist("favorites", gameId);
|
||||
setFavoriteIds((prev) => new Set(prev).add(gameId));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<IonPage>
|
||||
<IonHeader translucent>
|
||||
<IonToolbar>
|
||||
<IonTitle>Bibliothek</IonTitle>
|
||||
<IonButton
|
||||
slot="end"
|
||||
fill="clear"
|
||||
onClick={() => setShowSortSheet(true)}
|
||||
color="primary"
|
||||
>
|
||||
<IonIcon slot="icon-only" icon={swapVerticalOutline} />
|
||||
</IonButton>
|
||||
</IonToolbar>
|
||||
<IonToolbar>
|
||||
<IonSearchbar
|
||||
placeholder="Spiele suchen..."
|
||||
value={searchText}
|
||||
onIonInput={(e) => setSearchText(e.detail.value || "")}
|
||||
className="library-searchbar"
|
||||
/>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
<IonActionSheet
|
||||
isOpen={showSortSheet}
|
||||
onDidDismiss={() => setShowSortSheet(false)}
|
||||
header="Sortieren nach"
|
||||
buttons={[
|
||||
{
|
||||
text: "Titel",
|
||||
cssClass: sortBy === "title" ? "sort-action-active" : "",
|
||||
handler: () => setSortBy("title"),
|
||||
},
|
||||
{
|
||||
text: "Spielzeit",
|
||||
cssClass: sortBy === "playtime" ? "sort-action-active" : "",
|
||||
handler: () => setSortBy("playtime"),
|
||||
},
|
||||
{
|
||||
text: "Zuletzt gespielt",
|
||||
cssClass: sortBy === "lastPlayed" ? "sort-action-active" : "",
|
||||
handler: () => setSortBy("lastPlayed"),
|
||||
},
|
||||
{ text: "Abbrechen", role: "cancel" },
|
||||
]}
|
||||
/>
|
||||
<IonContent fullscreen className="library-content" id="library-content">
|
||||
<IonHeader collapse="condense">
|
||||
<IonToolbar>
|
||||
<IonTitle size="large">Bibliothek</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
<section className="hero">
|
||||
<div>
|
||||
<h1>Spielebibliothek</h1>
|
||||
<p>
|
||||
Deine Spiele aus allen Quellen.
|
||||
</p>
|
||||
</div>
|
||||
<div className="hero-stats">
|
||||
<div>
|
||||
<span>Spiele</span>
|
||||
<strong>{games.length}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Spielzeit (h)</span>
|
||||
<strong>{totalPlaytime.toFixed(1)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{loading ? (
|
||||
<div className="state">
|
||||
<IonSpinner name="crescent" />
|
||||
<p>Lade Spiele …</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="state error">
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
) : filteredAndSortedGames.length === 0 ? (
|
||||
<div className="state">
|
||||
<p>
|
||||
{searchText ? "Keine Spiele gefunden" : "Keine Spiele vorhanden"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<IonList inset className="game-list">
|
||||
{displayedGames.map((game) => (
|
||||
<IonItem
|
||||
key={game.id}
|
||||
lines="full"
|
||||
href={game.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<IonIcon
|
||||
icon={favoriteIds.has(game.id) ? heart : heartOutline}
|
||||
slot="start"
|
||||
color={favoriteIds.has(game.id) ? "danger" : "medium"}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleToggleFavorite(game.id);
|
||||
}}
|
||||
style={{ cursor: "pointer", fontSize: "22px" }}
|
||||
/>
|
||||
<IonLabel>
|
||||
<h2>{game.title}</h2>
|
||||
<p>Zuletzt gespielt: {formatDate(game.lastPlayed)}</p>
|
||||
</IonLabel>
|
||||
<IonNote slot="end">
|
||||
<IonBadge color="primary">
|
||||
{game.playtimeHours ?? 0} h
|
||||
</IonBadge>
|
||||
</IonNote>
|
||||
</IonItem>
|
||||
))}
|
||||
</IonList>
|
||||
)}
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
.detail-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.detail-name-edit {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.detail-name-edit .name-input {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.detail-search {
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
cursor: pointer;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.source-badge {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
import {
|
||||
IonBackButton,
|
||||
IonBadge,
|
||||
IonButtons,
|
||||
IonContent,
|
||||
IonHeader,
|
||||
IonIcon,
|
||||
IonInput,
|
||||
IonItem,
|
||||
IonItemOption,
|
||||
IonItemOptions,
|
||||
IonItemSliding,
|
||||
IonLabel,
|
||||
IonList,
|
||||
IonListHeader,
|
||||
IonPage,
|
||||
IonSearchbar,
|
||||
IonSpinner,
|
||||
IonTitle,
|
||||
IonToolbar,
|
||||
} from "@ionic/react";
|
||||
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import {
|
||||
addCircleOutline,
|
||||
heart,
|
||||
heartOutline,
|
||||
removeCircleOutline,
|
||||
} from "ionicons/icons";
|
||||
import { db, type Playlist, type Game } from "../../services/Database";
|
||||
|
||||
import "./PlaylistDetailPage.css";
|
||||
|
||||
export default function PlaylistDetailPage() {
|
||||
const { playlistId } = useParams<{ playlistId: string }>();
|
||||
const [playlist, setPlaylist] = useState<Playlist | null>(null);
|
||||
const [games, setGames] = useState<Game[]>([]);
|
||||
const [favoriteIds, setFavoriteIds] = useState<Set<string>>(new Set());
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [editName, setEditName] = useState("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const [pl, allGames, favPlaylist] = await Promise.all([
|
||||
db.getPlaylist(playlistId),
|
||||
db.getGames(),
|
||||
db.getPlaylist("favorites"),
|
||||
]);
|
||||
setPlaylist(pl);
|
||||
setGames(allGames);
|
||||
setFavoriteIds(new Set(favPlaylist?.gameIds ?? []));
|
||||
if (pl) setEditName(pl.name);
|
||||
setLoading(false);
|
||||
}, [playlistId]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const playlistGames = useMemo(() => {
|
||||
if (!playlist) return [];
|
||||
return playlist.gameIds
|
||||
.map((id) => games.find((g) => g.id === id))
|
||||
.filter(Boolean) as Game[];
|
||||
}, [playlist, games]);
|
||||
|
||||
const searchResults = useMemo(() => {
|
||||
if (!searchText.trim() || !playlist) return [];
|
||||
const term = searchText.toLowerCase();
|
||||
const inPlaylist = new Set(playlist.gameIds);
|
||||
return games
|
||||
.filter(
|
||||
(g) => g.title.toLowerCase().includes(term) && !inPlaylist.has(g.id),
|
||||
)
|
||||
.slice(0, 20);
|
||||
}, [searchText, games, playlist]);
|
||||
|
||||
const handleAddGame = async (gameId: string) => {
|
||||
await db.addGameToPlaylist(playlistId, gameId);
|
||||
const updated = await db.getPlaylist(playlistId);
|
||||
setPlaylist(updated);
|
||||
};
|
||||
|
||||
const handleRemoveGame = async (gameId: string) => {
|
||||
await db.removeGameFromPlaylist(playlistId, gameId);
|
||||
const updated = await db.getPlaylist(playlistId);
|
||||
setPlaylist(updated);
|
||||
};
|
||||
|
||||
const handleToggleFavorite = async (gameId: string) => {
|
||||
if (favoriteIds.has(gameId)) {
|
||||
await db.removeGameFromPlaylist("favorites", gameId);
|
||||
setFavoriteIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(gameId);
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
await db.addGameToPlaylist("favorites", gameId);
|
||||
setFavoriteIds((prev) => new Set(prev).add(gameId));
|
||||
}
|
||||
// If we're viewing the favorites playlist, reload it
|
||||
if (playlistId === "favorites") {
|
||||
const updated = await db.getPlaylist("favorites");
|
||||
setPlaylist(updated);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNameBlur = async () => {
|
||||
if (!playlist || playlist.isStatic) return;
|
||||
const trimmed = editName.trim() || "Neue Liste";
|
||||
if (trimmed !== playlist.name) {
|
||||
await db.updatePlaylist(playlistId, { name: trimmed });
|
||||
setPlaylist((prev) => (prev ? { ...prev, name: trimmed } : prev));
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<IonPage>
|
||||
<IonHeader translucent>
|
||||
<IonToolbar>
|
||||
<IonButtons slot="start">
|
||||
<IonBackButton defaultHref="/playlists" />
|
||||
</IonButtons>
|
||||
<IonTitle>Playlist</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
<IonContent>
|
||||
<div className="detail-loading">
|
||||
<IonSpinner name="crescent" />
|
||||
</div>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
}
|
||||
|
||||
if (!playlist) {
|
||||
return (
|
||||
<IonPage>
|
||||
<IonHeader translucent>
|
||||
<IonToolbar>
|
||||
<IonButtons slot="start">
|
||||
<IonBackButton defaultHref="/playlists" />
|
||||
</IonButtons>
|
||||
<IonTitle>Nicht gefunden</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
<IonContent>
|
||||
<p className="ion-padding ion-text-center">
|
||||
Playlist nicht gefunden.
|
||||
</p>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<IonPage>
|
||||
<IonHeader translucent>
|
||||
<IonToolbar>
|
||||
<IonButtons slot="start">
|
||||
<IonBackButton defaultHref="/playlists" />
|
||||
</IonButtons>
|
||||
<IonTitle>{playlist.name}</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
<IonContent fullscreen>
|
||||
<IonHeader collapse="condense">
|
||||
<IonToolbar>
|
||||
<IonTitle size="large">{playlist.name}</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
{/* Editable name for custom playlists */}
|
||||
{!playlist.isStatic && (
|
||||
<div className="detail-name-edit">
|
||||
<IonInput
|
||||
value={editName}
|
||||
placeholder="Playlist-Name"
|
||||
onIonInput={(e) => setEditName(e.detail.value ?? "")}
|
||||
onIonBlur={handleNameBlur}
|
||||
className="name-input"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search to add games */}
|
||||
<div className="detail-search">
|
||||
<IonSearchbar
|
||||
placeholder="Spiel hinzufügen..."
|
||||
value={searchText}
|
||||
onIonInput={(e) => setSearchText(e.detail.value ?? "")}
|
||||
debounce={200}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{searchResults.length > 0 && (
|
||||
<IonList inset>
|
||||
<IonListHeader>
|
||||
<IonLabel>Suchergebnisse</IonLabel>
|
||||
</IonListHeader>
|
||||
{searchResults.map((game) => (
|
||||
<IonItem key={game.id}>
|
||||
<IonLabel>
|
||||
<h2>{game.title}</h2>
|
||||
{game.source && (
|
||||
<p>
|
||||
<IonBadge color="medium" className="source-badge">
|
||||
{game.source}
|
||||
</IonBadge>
|
||||
</p>
|
||||
)}
|
||||
</IonLabel>
|
||||
<IonIcon
|
||||
icon={addCircleOutline}
|
||||
slot="end"
|
||||
color="primary"
|
||||
onClick={() => handleAddGame(game.id)}
|
||||
className="action-icon"
|
||||
/>
|
||||
</IonItem>
|
||||
))}
|
||||
</IonList>
|
||||
)}
|
||||
|
||||
{/* Playlist games */}
|
||||
<IonList inset>
|
||||
<IonListHeader>
|
||||
<IonLabel>
|
||||
{playlist.gameIds.length}{" "}
|
||||
{playlist.gameIds.length === 1 ? "Spiel" : "Spiele"}
|
||||
</IonLabel>
|
||||
</IonListHeader>
|
||||
{playlistGames.length === 0 ? (
|
||||
<IonItem>
|
||||
<IonLabel color="medium" className="ion-text-center">
|
||||
Keine Spiele in dieser Playlist
|
||||
</IonLabel>
|
||||
</IonItem>
|
||||
) : (
|
||||
playlistGames.map((game) => (
|
||||
<IonItemSliding key={game.id}>
|
||||
<IonItem>
|
||||
<IonIcon
|
||||
icon={favoriteIds.has(game.id) ? heart : heartOutline}
|
||||
slot="start"
|
||||
color={favoriteIds.has(game.id) ? "danger" : "medium"}
|
||||
onClick={() => handleToggleFavorite(game.id)}
|
||||
className="action-icon"
|
||||
/>
|
||||
<IonLabel>
|
||||
<h2>{game.title}</h2>
|
||||
{game.source && (
|
||||
<p>
|
||||
<IonBadge color="medium" className="source-badge">
|
||||
{game.source}
|
||||
</IonBadge>
|
||||
</p>
|
||||
)}
|
||||
</IonLabel>
|
||||
</IonItem>
|
||||
<IonItemOptions side="end">
|
||||
<IonItemOption
|
||||
color="danger"
|
||||
onClick={() => handleRemoveGame(game.id)}
|
||||
>
|
||||
<IonIcon
|
||||
slot="icon-only"
|
||||
icon={removeCircleOutline}
|
||||
/>
|
||||
</IonItemOption>
|
||||
</IonItemOptions>
|
||||
</IonItemSliding>
|
||||
))
|
||||
)}
|
||||
</IonList>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
.playlists-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.chevron-icon {
|
||||
font-size: 16px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
import {
|
||||
IonBadge,
|
||||
IonButton,
|
||||
IonButtons,
|
||||
IonContent,
|
||||
IonHeader,
|
||||
IonIcon,
|
||||
IonItem,
|
||||
IonItemOption,
|
||||
IonItemOptions,
|
||||
IonItemSliding,
|
||||
IonLabel,
|
||||
IonList,
|
||||
IonListHeader,
|
||||
IonPage,
|
||||
IonSpinner,
|
||||
IonTitle,
|
||||
IonToolbar,
|
||||
} from "@ionic/react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import {
|
||||
addOutline,
|
||||
chevronForward,
|
||||
heartOutline,
|
||||
gameControllerOutline,
|
||||
thumbsDownOutline,
|
||||
listOutline,
|
||||
trashOutline,
|
||||
} from "ionicons/icons";
|
||||
import { db, type Playlist } from "../../services/Database";
|
||||
|
||||
import "./PlaylistsPage.css";
|
||||
|
||||
const STATIC_ORDER = ["favorites", "want-to-play", "not-interesting"];
|
||||
const STATIC_ICONS: Record<string, string> = {
|
||||
favorites: heartOutline,
|
||||
"want-to-play": gameControllerOutline,
|
||||
"not-interesting": thumbsDownOutline,
|
||||
};
|
||||
|
||||
export default function PlaylistsPage() {
|
||||
const [playlists, setPlaylists] = useState<Playlist[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const history = useHistory();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const all = await db.getPlaylists();
|
||||
setPlaylists(all);
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
// Reload when returning to this page (ionViewWillEnter equivalent)
|
||||
useEffect(() => {
|
||||
const unlisten = history.listen((location) => {
|
||||
if (location.pathname === "/playlists") {
|
||||
load();
|
||||
}
|
||||
});
|
||||
return unlisten;
|
||||
}, [history, load]);
|
||||
|
||||
const staticPlaylists = STATIC_ORDER.map((id) =>
|
||||
playlists.find((p) => p.id === id),
|
||||
).filter(Boolean) as Playlist[];
|
||||
|
||||
const customPlaylists = playlists
|
||||
.filter((p) => !p.isStatic)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
|
||||
);
|
||||
|
||||
const handleAdd = async () => {
|
||||
const id = `custom-${Date.now()}`;
|
||||
await db.createPlaylist({
|
||||
id,
|
||||
name: "Neue Liste",
|
||||
gameIds: [],
|
||||
isStatic: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
history.push(`/playlists/${id}`);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
await db.deletePlaylist(id);
|
||||
await load();
|
||||
};
|
||||
|
||||
return (
|
||||
<IonPage>
|
||||
<IonHeader translucent>
|
||||
<IonToolbar>
|
||||
<IonTitle>Playlists</IonTitle>
|
||||
<IonButtons slot="end">
|
||||
<IonButton onClick={handleAdd}>
|
||||
<IonIcon slot="icon-only" icon={addOutline} />
|
||||
</IonButton>
|
||||
</IonButtons>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
<IonContent fullscreen>
|
||||
<IonHeader collapse="condense">
|
||||
<IonToolbar>
|
||||
<IonTitle size="large">Playlists</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
{loading ? (
|
||||
<div className="playlists-loading">
|
||||
<IonSpinner name="crescent" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<IonList inset>
|
||||
{staticPlaylists.map((playlist) => (
|
||||
<IonItem
|
||||
key={playlist.id}
|
||||
routerLink={`/playlists/${playlist.id}`}
|
||||
detail={false}
|
||||
>
|
||||
<IonIcon
|
||||
icon={STATIC_ICONS[playlist.id]}
|
||||
slot="start"
|
||||
color="primary"
|
||||
/>
|
||||
<IonLabel>{playlist.name}</IonLabel>
|
||||
<IonBadge color="medium" slot="end">
|
||||
{playlist.gameIds.length}
|
||||
</IonBadge>
|
||||
<IonIcon
|
||||
icon={chevronForward}
|
||||
slot="end"
|
||||
color="medium"
|
||||
className="chevron-icon"
|
||||
/>
|
||||
</IonItem>
|
||||
))}
|
||||
</IonList>
|
||||
|
||||
<IonList inset>
|
||||
<IonListHeader>
|
||||
<IonLabel>Eigene Playlists</IonLabel>
|
||||
</IonListHeader>
|
||||
{customPlaylists.length === 0 ? (
|
||||
<IonItem>
|
||||
<IonLabel color="medium" className="ion-text-center">
|
||||
Keine eigenen Playlists
|
||||
</IonLabel>
|
||||
</IonItem>
|
||||
) : (
|
||||
customPlaylists.map((playlist) => (
|
||||
<IonItemSliding key={playlist.id}>
|
||||
<IonItem
|
||||
routerLink={`/playlists/${playlist.id}`}
|
||||
detail={false}
|
||||
>
|
||||
<IonIcon
|
||||
icon={listOutline}
|
||||
slot="start"
|
||||
color="primary"
|
||||
/>
|
||||
<IonLabel>{playlist.name}</IonLabel>
|
||||
<IonBadge color="medium" slot="end">
|
||||
{playlist.gameIds.length}
|
||||
</IonBadge>
|
||||
<IonIcon
|
||||
icon={chevronForward}
|
||||
slot="end"
|
||||
color="medium"
|
||||
className="chevron-icon"
|
||||
/>
|
||||
</IonItem>
|
||||
<IonItemOptions side="end">
|
||||
<IonItemOption
|
||||
color="danger"
|
||||
onClick={() => handleDelete(playlist.id)}
|
||||
>
|
||||
<IonIcon slot="icon-only" icon={trashOutline} />
|
||||
</IonItemOption>
|
||||
</IonItemOptions>
|
||||
</IonItemSliding>
|
||||
))
|
||||
)}
|
||||
</IonList>
|
||||
</>
|
||||
)}
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
.settings-detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 18px 8px;
|
||||
color: var(--ion-color-medium);
|
||||
}
|
||||
|
||||
.settings-detail-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
color: var(--ion-text-color, #111);
|
||||
}
|
||||
|
||||
.settings-detail-header p {
|
||||
margin: 2px 0 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.settings-detail-note {
|
||||
margin: 4px 16px 12px;
|
||||
gap: 10px;
|
||||
--inner-padding-end: 0;
|
||||
}
|
||||
|
||||
.settings-detail-file-item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.settings-detail-file-input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.settings-detail-actions {
|
||||
padding: 0 16px 16px;
|
||||
}
|
||||
|
||||
.settings-detail-empty {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.settings-detail-last-refresh {
|
||||
padding: 0 16px 8px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.settings-detail-api-output {
|
||||
margin: 12px 16px;
|
||||
padding: 12px;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.settings-detail-api-output pre {
|
||||
margin: 8px 0 0 0;
|
||||
padding: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: "Monaco", "Courier New", monospace;
|
||||
font-size: 0.8rem;
|
||||
color: #374151;
|
||||
overflow-x: auto;
|
||||
}
|
||||
@@ -1,607 +0,0 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
IonAlert,
|
||||
IonBackButton,
|
||||
IonButton,
|
||||
IonButtons,
|
||||
IonContent,
|
||||
IonHeader,
|
||||
IonIcon,
|
||||
IonInput,
|
||||
IonItem,
|
||||
IonLabel,
|
||||
IonList,
|
||||
IonPage,
|
||||
IonText,
|
||||
IonTitle,
|
||||
IonToolbar,
|
||||
} from "@ionic/react";
|
||||
import {
|
||||
linkOutline,
|
||||
logOutOutline,
|
||||
refreshOutline,
|
||||
saveOutline,
|
||||
settingsOutline,
|
||||
shareOutline,
|
||||
timeOutline,
|
||||
trashOutline,
|
||||
} from "ionicons/icons";
|
||||
import { useParams } from "react-router-dom";
|
||||
|
||||
import {
|
||||
ConfigService,
|
||||
type ServiceConfig,
|
||||
} from "../../services/ConfigService";
|
||||
import { db } from "../../services/Database";
|
||||
|
||||
import "./SettingsDetailPage.css";
|
||||
|
||||
interface SettingsRouteParams {
|
||||
serviceId: string;
|
||||
}
|
||||
|
||||
const GOG_AUTH_URL =
|
||||
"https://auth.gog.com/auth?client_id=46899977096215655&redirect_uri=https%3A%2F%2Fembed.gog.com%2Fon_login_success%3Forigin%3Dclient&response_type=code&layout=client2";
|
||||
|
||||
const SERVICE_META = {
|
||||
steam: {
|
||||
title: "Steam",
|
||||
description: "Deine Steam-Bibliothek",
|
||||
},
|
||||
gog: {
|
||||
title: "GOG",
|
||||
description: "Deine GOG-Bibliothek",
|
||||
},
|
||||
data: {
|
||||
title: "Datenverwaltung",
|
||||
description: "Export, Import und Reset",
|
||||
},
|
||||
} as const;
|
||||
|
||||
type ServiceId = keyof typeof SERVICE_META;
|
||||
const PROVIDER_IDS = ["steam", "gog"] as const;
|
||||
|
||||
export default function SettingsDetailPage() {
|
||||
const { serviceId } = useParams<SettingsRouteParams>();
|
||||
const [config, setConfig] = useState<ServiceConfig>({});
|
||||
const [showAlert, setShowAlert] = useState(false);
|
||||
const [alertMessage, setAlertMessage] = useState("");
|
||||
const [apiOutput, setApiOutput] = useState<string>("");
|
||||
const [gogCode, setGogCode] = useState("");
|
||||
|
||||
const meta = useMemo(() => SERVICE_META[serviceId as ServiceId], [serviceId]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadConfig = async () => {
|
||||
const loadedConfig = await ConfigService.loadConfig();
|
||||
setConfig(loadedConfig);
|
||||
};
|
||||
|
||||
loadConfig();
|
||||
}, [serviceId]);
|
||||
|
||||
const handleDraftChange = (service: keyof ServiceConfig, data: any) => {
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
[service]: { ...prev[service], ...data },
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSaveService = async (service: keyof ServiceConfig) => {
|
||||
await ConfigService.saveConfig(config);
|
||||
setAlertMessage(`✓ ${service.toUpperCase()} Einstellungen gespeichert`);
|
||||
setShowAlert(true);
|
||||
|
||||
// Automatisch Daten abrufen nach dem Speichern
|
||||
if (service === "steam") {
|
||||
await handleManualRefresh(service);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGogConnect = async () => {
|
||||
if (!gogCode.trim()) {
|
||||
setAlertMessage("Bitte den Code aus der URL eingeben");
|
||||
setShowAlert(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setApiOutput("Tausche Code gegen Token...");
|
||||
|
||||
try {
|
||||
const apiUrl = ConfigService.getApiUrl("/api/gog/auth");
|
||||
const response = await fetch(apiUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ code: gogCode.trim() }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
setApiOutput(`❌ Fehler: ${response.status}\n${errorText}`);
|
||||
setAlertMessage("GOG Verbindung fehlgeschlagen");
|
||||
setShowAlert(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const tokens = await response.json();
|
||||
|
||||
const updatedConfig = {
|
||||
...config,
|
||||
gog: {
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
userId: tokens.user_id,
|
||||
},
|
||||
};
|
||||
setConfig(updatedConfig);
|
||||
await ConfigService.saveConfig(updatedConfig);
|
||||
setGogCode("");
|
||||
setApiOutput("✓ Verbunden! Spiele werden abgerufen...");
|
||||
|
||||
// Automatically fetch games after connecting
|
||||
await handleManualRefresh("gog", updatedConfig);
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
setApiOutput(`❌ Fehler: ${errorMsg}`);
|
||||
setAlertMessage("GOG Verbindung fehlgeschlagen");
|
||||
setShowAlert(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGogDisconnect = async () => {
|
||||
const updatedConfig = { ...config };
|
||||
delete updatedConfig.gog;
|
||||
setConfig(updatedConfig);
|
||||
await ConfigService.saveConfig(updatedConfig);
|
||||
setApiOutput("");
|
||||
setAlertMessage("✓ GOG Verbindung getrennt");
|
||||
setShowAlert(true);
|
||||
};
|
||||
|
||||
const handleManualRefresh = async (
|
||||
service: keyof ServiceConfig,
|
||||
configOverride?: ServiceConfig,
|
||||
) => {
|
||||
const currentConfig = configOverride || config;
|
||||
setApiOutput("Rufe API auf...");
|
||||
|
||||
try {
|
||||
if (service === "steam") {
|
||||
const steamConfig = currentConfig.steam;
|
||||
if (!steamConfig?.apiKey || !steamConfig?.steamId) {
|
||||
setApiOutput("❌ Fehler: Steam API Key und Steam ID erforderlich");
|
||||
setAlertMessage("Bitte zuerst Steam-Zugangsdaten eingeben");
|
||||
setShowAlert(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const apiUrl = ConfigService.getApiUrl("/api/steam/refresh");
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 60000);
|
||||
|
||||
try {
|
||||
const response = await fetch(apiUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
apiKey: steamConfig.apiKey,
|
||||
steamId: steamConfig.steamId,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
setApiOutput(`❌ API Fehler: ${response.status}\n${errorText}`);
|
||||
setAlertMessage("Steam Refresh fehlgeschlagen");
|
||||
setShowAlert(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
const transformedGames = result.games.map((steamGame: any) => ({
|
||||
id: `steam-${steamGame.appid}`,
|
||||
title: steamGame.name,
|
||||
source: "steam",
|
||||
sourceId: String(steamGame.appid),
|
||||
platform: "PC",
|
||||
playtimeHours: steamGame.playtime_forever
|
||||
? Math.round(steamGame.playtime_forever / 60)
|
||||
: 0,
|
||||
url: `https://store.steampowered.com/app/${steamGame.appid}`,
|
||||
...(steamGame.canonicalId && {
|
||||
canonicalId: steamGame.canonicalId,
|
||||
}),
|
||||
}));
|
||||
|
||||
await db.saveGamesBySource("steam", transformedGames);
|
||||
|
||||
const updatedConfig = {
|
||||
...currentConfig,
|
||||
steam: {
|
||||
...currentConfig.steam,
|
||||
lastRefresh: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
setConfig(updatedConfig);
|
||||
await ConfigService.saveConfig(updatedConfig);
|
||||
|
||||
setApiOutput(
|
||||
`✓ ${result.count} Spiele abgerufen\n\nBeispiel:\n${JSON.stringify(transformedGames.slice(0, 2), null, 2)}`,
|
||||
);
|
||||
setAlertMessage(`✓ ${result.count} Spiele aktualisiert`);
|
||||
setShowAlert(true);
|
||||
} catch (fetchError: any) {
|
||||
clearTimeout(timeoutId);
|
||||
if (fetchError.name === "AbortError") {
|
||||
throw new Error(
|
||||
"Request timeout - Steam API took too long to respond",
|
||||
);
|
||||
}
|
||||
throw fetchError;
|
||||
}
|
||||
} else if (service === "gog") {
|
||||
const gogConfig = currentConfig.gog;
|
||||
if (!gogConfig?.accessToken || !gogConfig?.refreshToken) {
|
||||
setApiOutput("❌ Fehler: Bitte zuerst mit GOG verbinden");
|
||||
setAlertMessage("Bitte zuerst mit GOG verbinden");
|
||||
setShowAlert(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const apiUrl = ConfigService.getApiUrl("/api/gog/refresh");
|
||||
const response = await fetch(apiUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
accessToken: gogConfig.accessToken,
|
||||
refreshToken: gogConfig.refreshToken,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
setApiOutput(`❌ API Fehler: ${response.status}\n${errorText}`);
|
||||
setAlertMessage("GOG Refresh fehlgeschlagen");
|
||||
setShowAlert(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
await db.saveGamesBySource("gog", result.games);
|
||||
|
||||
// Update tokens if refreshed
|
||||
const updatedConfig = {
|
||||
...currentConfig,
|
||||
gog: {
|
||||
...currentConfig.gog,
|
||||
lastRefresh: new Date().toISOString(),
|
||||
...(result.newAccessToken && {
|
||||
accessToken: result.newAccessToken,
|
||||
}),
|
||||
...(result.newRefreshToken && {
|
||||
refreshToken: result.newRefreshToken,
|
||||
}),
|
||||
},
|
||||
};
|
||||
setConfig(updatedConfig);
|
||||
await ConfigService.saveConfig(updatedConfig);
|
||||
|
||||
setApiOutput(
|
||||
`✓ ${result.count} Spiele abgerufen\n\nBeispiel:\n${JSON.stringify(result.games.slice(0, 2), null, 2)}`,
|
||||
);
|
||||
setAlertMessage(`✓ ${result.count} GOG-Spiele aktualisiert`);
|
||||
setShowAlert(true);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
console.error("[Frontend] Error during refresh:", error);
|
||||
setApiOutput(`❌ Fehler: ${errorMsg}`);
|
||||
setAlertMessage("Aktualisierung fehlgeschlagen");
|
||||
setShowAlert(true);
|
||||
}
|
||||
};
|
||||
|
||||
const formatLastRefresh = (value?: string) => {
|
||||
if (!value) return "Nie";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "Unbekannt";
|
||||
return new Intl.DateTimeFormat("de-DE", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
const handleExportConfig = () => {
|
||||
const validation = ConfigService.validateConfig(config);
|
||||
if (!validation.valid) {
|
||||
setAlertMessage(
|
||||
`⚠️ Config unvollständig:\n${validation.errors.join("\n")}`,
|
||||
);
|
||||
setShowAlert(true);
|
||||
return;
|
||||
}
|
||||
ConfigService.exportConfig(config);
|
||||
setAlertMessage("✓ Config exportiert");
|
||||
setShowAlert(true);
|
||||
};
|
||||
|
||||
const handleImportConfig = async (
|
||||
event: React.ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const imported = await ConfigService.importConfig(file);
|
||||
if (imported) {
|
||||
setConfig(imported);
|
||||
setAlertMessage("✓ Config importiert");
|
||||
} else {
|
||||
setAlertMessage("❌ Import fehlgeschlagen");
|
||||
}
|
||||
setShowAlert(true);
|
||||
};
|
||||
|
||||
const handleClearConfig = async () => {
|
||||
await ConfigService.clearConfig();
|
||||
await db.clear();
|
||||
setConfig({});
|
||||
setApiOutput("");
|
||||
setAlertMessage("✓ Alle Einstellungen und Spiele gelöscht");
|
||||
setShowAlert(true);
|
||||
};
|
||||
|
||||
if (!meta) {
|
||||
return (
|
||||
<IonPage>
|
||||
<IonHeader>
|
||||
<IonToolbar>
|
||||
<IonButtons slot="start">
|
||||
<IonBackButton defaultHref="/settings" />
|
||||
</IonButtons>
|
||||
<IonTitle>Einstellungen</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
<IonContent fullscreen>
|
||||
<div className="settings-detail-empty">
|
||||
<IonText color="medium">Unbekannter Bereich.</IonText>
|
||||
</div>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
}
|
||||
|
||||
const isProvider = (PROVIDER_IDS as readonly string[]).includes(serviceId);
|
||||
const providerKey = isProvider ? (serviceId as keyof ServiceConfig) : null;
|
||||
const lastRefresh = providerKey
|
||||
? config[providerKey]?.lastRefresh
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<IonPage>
|
||||
<IonHeader>
|
||||
<IonToolbar>
|
||||
<IonButtons slot="start">
|
||||
<IonBackButton defaultHref="/settings" />
|
||||
</IonButtons>
|
||||
<IonTitle>{meta.title}</IonTitle>
|
||||
{isProvider && providerKey && (
|
||||
<IonButtons slot="end">
|
||||
<IonButton
|
||||
fill="clear"
|
||||
aria-label="Manuell aktualisieren"
|
||||
onClick={() => handleManualRefresh(providerKey)}
|
||||
>
|
||||
<IonIcon icon={refreshOutline} />
|
||||
</IonButton>
|
||||
</IonButtons>
|
||||
)}
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
<IonContent fullscreen>
|
||||
<div className="settings-detail-header">
|
||||
<IonIcon icon={settingsOutline} />
|
||||
<div>
|
||||
<h2>{meta.title}</h2>
|
||||
<p>{meta.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{serviceId === "steam" && (
|
||||
<>
|
||||
<IonList inset>
|
||||
<IonItem>
|
||||
<IonLabel position="stacked">Steam API Key</IonLabel>
|
||||
<IonInput
|
||||
type="text"
|
||||
placeholder="Dein Steam Web API Key"
|
||||
value={config.steam?.apiKey || ""}
|
||||
onIonChange={(e) =>
|
||||
handleDraftChange("steam", {
|
||||
apiKey: e.detail.value || "",
|
||||
})
|
||||
}
|
||||
/>
|
||||
</IonItem>
|
||||
<IonItem>
|
||||
<IonLabel position="stacked">Steam Profil URL oder ID</IonLabel>
|
||||
<IonInput
|
||||
placeholder="steamcommunity.com/id/deinname oder Steam ID"
|
||||
value={config.steam?.steamId || ""}
|
||||
onIonChange={(e) => {
|
||||
const input = e.detail.value || "";
|
||||
// Extract Steam ID from URL if provided
|
||||
const idMatch = input.match(/\/(id|profiles)\/(\w+)/);
|
||||
const extractedId = idMatch ? idMatch[2] : input;
|
||||
handleDraftChange("steam", {
|
||||
steamId: extractedId,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</IonItem>
|
||||
</IonList>
|
||||
<div className="settings-detail-actions">
|
||||
<IonButton
|
||||
expand="block"
|
||||
onClick={() => handleSaveService("steam")}
|
||||
>
|
||||
<IonIcon slot="start" icon={saveOutline} />
|
||||
<IonLabel>Speichern</IonLabel>
|
||||
</IonButton>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{serviceId === "gog" && (
|
||||
<>
|
||||
{config.gog?.refreshToken ? (
|
||||
<>
|
||||
<IonList inset>
|
||||
<IonItem>
|
||||
<IonLabel>
|
||||
<h2>Verbunden</h2>
|
||||
<p>User ID: {config.gog.userId || "Unbekannt"}</p>
|
||||
</IonLabel>
|
||||
</IonItem>
|
||||
</IonList>
|
||||
<div className="settings-detail-actions">
|
||||
<IonButton
|
||||
expand="block"
|
||||
onClick={() => handleManualRefresh("gog")}
|
||||
>
|
||||
<IonIcon slot="start" icon={refreshOutline} />
|
||||
Spiele aktualisieren
|
||||
</IonButton>
|
||||
<IonButton
|
||||
expand="block"
|
||||
fill="outline"
|
||||
color="danger"
|
||||
onClick={handleGogDisconnect}
|
||||
>
|
||||
<IonIcon slot="start" icon={logOutOutline} />
|
||||
Verbindung trennen
|
||||
</IonButton>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<IonList inset>
|
||||
<IonItem>
|
||||
<IonLabel className="ion-text-wrap">
|
||||
<p>
|
||||
1. Klicke auf "Bei GOG anmelden" und logge dich ein.
|
||||
</p>
|
||||
<p>
|
||||
2. Nach dem Login landest du auf einer Seite mit einer
|
||||
URL die <strong>code=...</strong> enthält.
|
||||
</p>
|
||||
<p>
|
||||
3. Kopiere den Wert nach <strong>code=</strong> und
|
||||
füge ihn unten ein.
|
||||
</p>
|
||||
</IonLabel>
|
||||
</IonItem>
|
||||
</IonList>
|
||||
<div className="settings-detail-actions">
|
||||
<IonButton
|
||||
expand="block"
|
||||
fill="outline"
|
||||
onClick={() => window.open(GOG_AUTH_URL, "_blank")}
|
||||
>
|
||||
<IonIcon slot="start" icon={linkOutline} />
|
||||
Bei GOG anmelden
|
||||
</IonButton>
|
||||
</div>
|
||||
<IonList inset>
|
||||
<IonItem>
|
||||
<IonLabel position="stacked">Authorization Code</IonLabel>
|
||||
<IonInput
|
||||
type="text"
|
||||
placeholder="Code aus der URL einfügen"
|
||||
value={gogCode}
|
||||
onIonInput={(e) =>
|
||||
setGogCode(e.detail.value || "")
|
||||
}
|
||||
/>
|
||||
</IonItem>
|
||||
</IonList>
|
||||
<div className="settings-detail-actions">
|
||||
<IonButton
|
||||
expand="block"
|
||||
onClick={handleGogConnect}
|
||||
disabled={!gogCode.trim()}
|
||||
>
|
||||
<IonIcon slot="start" icon={saveOutline} />
|
||||
Verbinden
|
||||
</IonButton>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{serviceId === "data" && (
|
||||
<>
|
||||
<IonList inset>
|
||||
<IonItem button onClick={handleExportConfig}>
|
||||
<IonLabel>Config exportieren</IonLabel>
|
||||
<IonIcon slot="end" icon={shareOutline} />
|
||||
</IonItem>
|
||||
<IonItem className="settings-detail-file-item">
|
||||
<IonLabel>Config importieren</IonLabel>
|
||||
<input
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleImportConfig}
|
||||
className="settings-detail-file-input"
|
||||
/>
|
||||
</IonItem>
|
||||
</IonList>
|
||||
<div className="settings-detail-actions">
|
||||
<IonButton
|
||||
expand="block"
|
||||
color="danger"
|
||||
onClick={() => handleClearConfig()}
|
||||
>
|
||||
<IonIcon slot="start" icon={trashOutline} />
|
||||
Alle Einstellungen löschen
|
||||
</IonButton>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isProvider && (
|
||||
<>
|
||||
<div className="settings-detail-last-refresh">
|
||||
<IonText color="medium">
|
||||
<IonIcon icon={timeOutline} /> Letzter Abruf:{" "}
|
||||
{formatLastRefresh(lastRefresh)}
|
||||
</IonText>
|
||||
</div>
|
||||
{apiOutput && (
|
||||
<div className="settings-detail-api-output">
|
||||
<IonText color="medium">
|
||||
<strong>API Response:</strong>
|
||||
</IonText>
|
||||
<pre>{apiOutput}</pre>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div style={{ paddingBottom: "24px" }} />
|
||||
</IonContent>
|
||||
|
||||
<IonAlert
|
||||
isOpen={showAlert}
|
||||
onDidDismiss={() => setShowAlert(false)}
|
||||
message={alertMessage}
|
||||
buttons={["OK"]}
|
||||
/>
|
||||
</IonPage>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
.settings-page-note {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import React from "react";
|
||||
import {
|
||||
IonContent,
|
||||
IonHeader,
|
||||
IonIcon,
|
||||
IonItem,
|
||||
IonLabel,
|
||||
IonList,
|
||||
IonListHeader,
|
||||
IonNote,
|
||||
IonPage,
|
||||
IonTitle,
|
||||
IonToolbar,
|
||||
} from "@ionic/react";
|
||||
import {
|
||||
cloudOutline,
|
||||
cogOutline,
|
||||
gameControllerOutline,
|
||||
} from "ionicons/icons";
|
||||
|
||||
import "./SettingsPage.css";
|
||||
|
||||
export default function SettingsPage() {
|
||||
return (
|
||||
<IonPage>
|
||||
<IonHeader>
|
||||
<IonToolbar>
|
||||
<IonTitle>
|
||||
<IonIcon icon={cogOutline} /> Einstellungen
|
||||
</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
<IonContent fullscreen>
|
||||
<IonList inset>
|
||||
<IonListHeader>Provider</IonListHeader>
|
||||
<IonItem
|
||||
routerLink="/settings/steam"
|
||||
routerDirection="forward"
|
||||
detail
|
||||
>
|
||||
<IonIcon slot="start" icon={gameControllerOutline} />
|
||||
<IonLabel>Steam</IonLabel>
|
||||
<IonNote slot="end">API Key · Steam ID</IonNote>
|
||||
</IonItem>
|
||||
<IonItem
|
||||
routerLink="/settings/gog"
|
||||
routerDirection="forward"
|
||||
detail
|
||||
>
|
||||
<IonIcon slot="start" icon={gameControllerOutline} />
|
||||
<IonLabel>GOG</IonLabel>
|
||||
<IonNote slot="end">OAuth Login</IonNote>
|
||||
</IonItem>
|
||||
</IonList>
|
||||
|
||||
<IonList inset>
|
||||
<IonListHeader>Verwaltung</IonListHeader>
|
||||
<IonItem routerLink="/settings/data" routerDirection="forward" detail>
|
||||
<IonIcon slot="start" icon={cloudOutline} />
|
||||
<IonLabel>Datenverwaltung</IonLabel>
|
||||
<IonNote slot="end">Export · Import</IonNote>
|
||||
</IonItem>
|
||||
</IonList>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
/**
|
||||
* ConfigService - Sichere Konfigurationsverwaltung
|
||||
* Nutzt IndexedDB (Primary) mit localStorage Fallback (wie Voyager)
|
||||
*/
|
||||
import { db } from "./Database";
|
||||
|
||||
export interface ServiceConfig {
|
||||
steam?: {
|
||||
apiKey?: string;
|
||||
steamId?: string;
|
||||
lastRefresh?: string;
|
||||
};
|
||||
gog?: {
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
userId?: string;
|
||||
lastRefresh?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "whattoplay_config";
|
||||
|
||||
export class ConfigService {
|
||||
/**
|
||||
* Lade Konfiguration aus IndexedDB (Primary) oder localStorage (Fallback)
|
||||
*/
|
||||
static async loadConfig(): Promise<ServiceConfig> {
|
||||
try {
|
||||
// Versuche IndexedDB
|
||||
const dbConfig = await db.getConfig();
|
||||
if (dbConfig) {
|
||||
return dbConfig;
|
||||
}
|
||||
|
||||
// Fallback: localStorage
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
const config = stored ? JSON.parse(stored) : {};
|
||||
|
||||
// Migriere zu IndexedDB
|
||||
if (stored) {
|
||||
await db.saveConfig(config);
|
||||
}
|
||||
|
||||
return config;
|
||||
} catch (error) {
|
||||
console.warn("Config konnte nicht geladen werden", error);
|
||||
// Letzter Fallback: localStorage
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
return stored ? JSON.parse(stored) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Speichere Konfiguration in IndexedDB (Primary Storage)
|
||||
*/
|
||||
static async saveConfig(config: ServiceConfig) {
|
||||
try {
|
||||
await db.saveConfig(config);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Config konnte nicht in IndexedDB gespeichert werden",
|
||||
error,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exportiere Config als JSON-Datei für Download
|
||||
* ⚠️ WARNUNG: Enthält sensitive Daten!
|
||||
*/
|
||||
static exportConfig(config: ServiceConfig) {
|
||||
const element = document.createElement("a");
|
||||
const file = new Blob([JSON.stringify(config, null, 2)], {
|
||||
type: "application/json",
|
||||
});
|
||||
element.href = URL.createObjectURL(file);
|
||||
element.download = "whattoplay-config.json";
|
||||
document.body.appendChild(element);
|
||||
element.click();
|
||||
document.body.removeChild(element);
|
||||
}
|
||||
|
||||
/**
|
||||
* Importiere Config aus JSON-Datei
|
||||
*/
|
||||
static async importConfig(file: File): Promise<ServiceConfig | null> {
|
||||
try {
|
||||
const text = await file.text();
|
||||
const config = JSON.parse(text);
|
||||
await this.saveConfig(config);
|
||||
return config;
|
||||
} catch (error) {
|
||||
console.error("Config-Import fehlgeschlagen", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lösche sensitive Daten aus IndexedDB + localStorage
|
||||
*/
|
||||
static async clearConfig() {
|
||||
try {
|
||||
await db.clear();
|
||||
console.log("✓ Config und Daten gelöscht");
|
||||
} catch (error) {
|
||||
console.error("Fehler beim Löschen der Config", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validiere Config-Struktur
|
||||
*/
|
||||
static validateConfig(config: ServiceConfig): {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
} {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (config.steam) {
|
||||
if (!config.steam.apiKey) errors.push("Steam: API Key fehlt");
|
||||
if (!config.steam.steamId) errors.push("Steam: Steam ID fehlt");
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get API URL for Steam refresh
|
||||
* - Development: Vite dev server proxy
|
||||
* - Production: Uberspace backend via VITE_API_URL
|
||||
*/
|
||||
static getApiUrl(endpoint: string): string {
|
||||
// Development mode: Use Vite dev server middleware
|
||||
if (import.meta.env.DEV) {
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
// Production: Use backend URL from environment
|
||||
const backendUrl = import.meta.env.VITE_API_URL;
|
||||
if (!backendUrl) {
|
||||
throw new Error(
|
||||
"Backend not configured. Set VITE_API_URL in .env.production",
|
||||
);
|
||||
}
|
||||
|
||||
const baseUrl = backendUrl.replace(/\/$/, "");
|
||||
return `${baseUrl}${endpoint}`;
|
||||
}
|
||||
}
|
||||
@@ -1,366 +0,0 @@
|
||||
/**
|
||||
* Database Service - IndexedDB für PWA-Persistenz
|
||||
* Strategie wie Voyager: IndexedDB als Primary Storage
|
||||
*/
|
||||
|
||||
export interface DbConfig {
|
||||
steam?: {
|
||||
apiKey?: string;
|
||||
steamId?: string;
|
||||
lastRefresh?: string;
|
||||
};
|
||||
gog?: {
|
||||
userId?: string;
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
lastRefresh?: string;
|
||||
};
|
||||
epic?: {
|
||||
email?: string;
|
||||
accessToken?: string;
|
||||
method?: "oauth" | "manual";
|
||||
lastRefresh?: string;
|
||||
};
|
||||
amazon?: {
|
||||
email?: string;
|
||||
manualGames?: Array<{ name: string; gameId?: string; source?: string }>;
|
||||
method?: "oauth" | "manual";
|
||||
lastRefresh?: string;
|
||||
};
|
||||
blizzard?: {
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
accessToken?: string;
|
||||
region?: "us" | "eu" | "kr" | "tw";
|
||||
lastRefresh?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Game {
|
||||
id: string;
|
||||
title: string;
|
||||
source?: string;
|
||||
sourceId?: string;
|
||||
platform?: string;
|
||||
lastPlayed?: string | null;
|
||||
playtimeHours?: number;
|
||||
url?: string;
|
||||
canonicalId?: string;
|
||||
}
|
||||
|
||||
export interface Playlist {
|
||||
id: string;
|
||||
name: string;
|
||||
gameIds: string[];
|
||||
isStatic: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const DB_NAME = "whattoplay";
|
||||
const DB_VERSION = 2;
|
||||
|
||||
class Database {
|
||||
private db: IDBDatabase | null = null;
|
||||
|
||||
async init(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
|
||||
// Config Store
|
||||
if (!db.objectStoreNames.contains("config")) {
|
||||
db.createObjectStore("config", { keyPath: "id" });
|
||||
}
|
||||
|
||||
// Games Store
|
||||
if (!db.objectStoreNames.contains("games")) {
|
||||
const gameStore = db.createObjectStore("games", { keyPath: "id" });
|
||||
gameStore.createIndex("source", "source", { unique: false });
|
||||
gameStore.createIndex("title", "title", { unique: false });
|
||||
}
|
||||
|
||||
// Settings Store
|
||||
if (!db.objectStoreNames.contains("settings")) {
|
||||
db.createObjectStore("settings", { keyPath: "key" });
|
||||
}
|
||||
|
||||
// Playlists Store
|
||||
if (!db.objectStoreNames.contains("playlists")) {
|
||||
db.createObjectStore("playlists", { keyPath: "id" });
|
||||
}
|
||||
|
||||
// Sync Log (für zukünftige Cloud-Sync)
|
||||
if (!db.objectStoreNames.contains("syncLog")) {
|
||||
db.createObjectStore("syncLog", {
|
||||
keyPath: "id",
|
||||
autoIncrement: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
request.onsuccess = () => {
|
||||
this.db = request.result;
|
||||
this.initStaticPlaylists();
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async getConfig(): Promise<DbConfig | null> {
|
||||
if (!this.db) await this.init();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = this.db!.transaction("config", "readonly");
|
||||
const store = tx.objectStore("config");
|
||||
const request = store.get("main");
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve(request.result || null);
|
||||
});
|
||||
}
|
||||
|
||||
async saveConfig(config: DbConfig): Promise<void> {
|
||||
if (!this.db) await this.init();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = this.db!.transaction("config", "readwrite");
|
||||
const store = tx.objectStore("config");
|
||||
const request = store.put({ id: "main", ...config });
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve();
|
||||
});
|
||||
}
|
||||
|
||||
async saveGames(games: Game[]): Promise<void> {
|
||||
if (!this.db) await this.init();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = this.db!.transaction("games", "readwrite");
|
||||
const store = tx.objectStore("games");
|
||||
|
||||
// Lösche alte Spiele
|
||||
const clearRequest = store.clear();
|
||||
clearRequest.onsuccess = () => {
|
||||
// Füge neue Spiele ein
|
||||
games.forEach((game) => store.add(game));
|
||||
resolve();
|
||||
};
|
||||
clearRequest.onerror = () => reject(clearRequest.error);
|
||||
});
|
||||
}
|
||||
|
||||
async saveGamesBySource(source: string, games: Game[]): Promise<void> {
|
||||
if (!this.db) await this.init();
|
||||
|
||||
// Step 1: Find IDs of existing games for this source
|
||||
const existingGames = await this.getGamesBySource(source);
|
||||
const existingIds = existingGames.map((g) => g.id);
|
||||
|
||||
// Step 2: Delete old and add new in one transaction
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = this.db!.transaction("games", "readwrite");
|
||||
const store = tx.objectStore("games");
|
||||
|
||||
for (const id of existingIds) {
|
||||
store.delete(id);
|
||||
}
|
||||
for (const game of games) {
|
||||
store.put(game);
|
||||
}
|
||||
|
||||
tx.onerror = () => reject(tx.error);
|
||||
tx.oncomplete = () => resolve();
|
||||
});
|
||||
}
|
||||
|
||||
async getGames(): Promise<Game[]> {
|
||||
if (!this.db) await this.init();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = this.db!.transaction("games", "readonly");
|
||||
const store = tx.objectStore("games");
|
||||
const request = store.getAll();
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve(request.result || []);
|
||||
});
|
||||
}
|
||||
|
||||
async getGamesBySource(source: string): Promise<Game[]> {
|
||||
if (!this.db) await this.init();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = this.db!.transaction("games", "readonly");
|
||||
const store = tx.objectStore("games");
|
||||
const index = store.index("source");
|
||||
const request = index.getAll(source);
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve(request.result || []);
|
||||
});
|
||||
}
|
||||
|
||||
async getSetting(key: string): Promise<any> {
|
||||
if (!this.db) await this.init();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = this.db!.transaction("settings", "readonly");
|
||||
const store = tx.objectStore("settings");
|
||||
const request = store.get(key);
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve(request.result?.value || null);
|
||||
});
|
||||
}
|
||||
|
||||
async setSetting(key: string, value: any): Promise<void> {
|
||||
if (!this.db) await this.init();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = this.db!.transaction("settings", "readwrite");
|
||||
const store = tx.objectStore("settings");
|
||||
const request = store.put({ key, value });
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve();
|
||||
});
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
if (!this.db) await this.init();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = this.db!.transaction(
|
||||
["config", "games", "settings", "playlists", "syncLog"],
|
||||
"readwrite",
|
||||
);
|
||||
|
||||
["config", "games", "settings", "playlists", "syncLog"].forEach(
|
||||
(storeName) => {
|
||||
tx.objectStore(storeName).clear();
|
||||
},
|
||||
);
|
||||
|
||||
tx.onerror = () => reject(tx.error);
|
||||
tx.oncomplete = () => resolve();
|
||||
});
|
||||
}
|
||||
|
||||
private async initStaticPlaylists(): Promise<void> {
|
||||
const playlists = await this.getPlaylists();
|
||||
|
||||
const statics: Array<{ id: string; name: string }> = [
|
||||
{ id: "favorites", name: "Favoriten" },
|
||||
{ id: "want-to-play", name: "Want to Play" },
|
||||
{ id: "not-interesting", name: "Not Interesting" },
|
||||
];
|
||||
|
||||
for (const s of statics) {
|
||||
if (!playlists.some((p) => p.id === s.id)) {
|
||||
await this.createPlaylist({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
gameIds: [],
|
||||
isStatic: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getPlaylists(): Promise<Playlist[]> {
|
||||
if (!this.db) await this.init();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = this.db!.transaction("playlists", "readonly");
|
||||
const store = tx.objectStore("playlists");
|
||||
const request = store.getAll();
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve(request.result || []);
|
||||
});
|
||||
}
|
||||
|
||||
async getPlaylist(id: string): Promise<Playlist | null> {
|
||||
if (!this.db) await this.init();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = this.db!.transaction("playlists", "readonly");
|
||||
const store = tx.objectStore("playlists");
|
||||
const request = store.get(id);
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve(request.result || null);
|
||||
});
|
||||
}
|
||||
|
||||
async createPlaylist(playlist: Playlist): Promise<void> {
|
||||
if (!this.db) await this.init();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = this.db!.transaction("playlists", "readwrite");
|
||||
const store = tx.objectStore("playlists");
|
||||
const request = store.put(playlist);
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve();
|
||||
});
|
||||
}
|
||||
|
||||
async addGameToPlaylist(playlistId: string, gameId: string): Promise<void> {
|
||||
const playlist = await this.getPlaylist(playlistId);
|
||||
if (!playlist) throw new Error(`Playlist ${playlistId} not found`);
|
||||
|
||||
if (!playlist.gameIds.includes(gameId)) {
|
||||
playlist.gameIds.push(gameId);
|
||||
await this.createPlaylist(playlist);
|
||||
}
|
||||
}
|
||||
|
||||
async removeGameFromPlaylist(
|
||||
playlistId: string,
|
||||
gameId: string,
|
||||
): Promise<void> {
|
||||
const playlist = await this.getPlaylist(playlistId);
|
||||
if (!playlist) throw new Error(`Playlist ${playlistId} not found`);
|
||||
|
||||
playlist.gameIds = playlist.gameIds.filter((id) => id !== gameId);
|
||||
await this.createPlaylist(playlist);
|
||||
}
|
||||
|
||||
async updatePlaylist(
|
||||
id: string,
|
||||
updates: Partial<Pick<Playlist, "name">>,
|
||||
): Promise<void> {
|
||||
const playlist = await this.getPlaylist(id);
|
||||
if (!playlist) throw new Error(`Playlist ${id} not found`);
|
||||
|
||||
Object.assign(playlist, updates);
|
||||
await this.createPlaylist(playlist);
|
||||
}
|
||||
|
||||
async deletePlaylist(id: string): Promise<void> {
|
||||
const playlist = await this.getPlaylist(id);
|
||||
if (!playlist) return;
|
||||
if (playlist.isStatic) throw new Error("Cannot delete static playlist");
|
||||
|
||||
if (!this.db) await this.init();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = this.db!.transaction("playlists", "readwrite");
|
||||
const store = tx.objectStore("playlists");
|
||||
const request = store.delete(id);
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton
|
||||
export const db = new Database();
|
||||
@@ -1,13 +0,0 @@
|
||||
:root {
|
||||
--ion-font-family:
|
||||
"-apple-system", "SF Pro Text", "SF Pro Display", system-ui, sans-serif;
|
||||
--ion-background-color: #f2f2f7;
|
||||
--ion-text-color: #1c1c1e;
|
||||
--ion-toolbar-background: #f2f2f7;
|
||||
--ion-item-background: #ffffff;
|
||||
--ion-item-border-color: rgba(60, 60, 67, 0.2);
|
||||
--ion-color-primary: #0a84ff;
|
||||
--ion-color-primary-contrast: #ffffff;
|
||||
--ion-safe-area-top: env(safe-area-inset-top);
|
||||
--ion-safe-area-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
231
styles.css
231
styles.css
@@ -1,231 +0,0 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap");
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
font-family: "Inter", system-ui, sans-serif;
|
||||
line-height: 1.5;
|
||||
--bg: #f6f7fb;
|
||||
--panel: #ffffff;
|
||||
--text: #1c1d2a;
|
||||
--muted: #5c607b;
|
||||
--accent: #4b4bff;
|
||||
--accent-weak: #e6e8ff;
|
||||
--border: #e0e3f2;
|
||||
--shadow: 0 15px 40px rgba(28, 29, 42, 0.08);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
padding: 3.5rem 6vw 2rem;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.2em;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(2rem, 3vw, 3.2rem);
|
||||
margin: 0.4rem 0 0.8rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
max-width: 520px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.primary {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.8rem 1.5rem;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
box-shadow: var(--shadow);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.primary:hover {
|
||||
filter: brightness(0.95);
|
||||
}
|
||||
|
||||
.app-main {
|
||||
padding: 0 6vw 3rem;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 1rem;
|
||||
background: var(--panel);
|
||||
padding: 1.4rem;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.control-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.6rem 0.8rem;
|
||||
background: #fdfdff;
|
||||
}
|
||||
|
||||
.summary {
|
||||
margin: 2rem 0 1.5rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
background: var(--panel);
|
||||
border-radius: 18px;
|
||||
padding: 1.2rem;
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.summary-card h3 {
|
||||
font-size: 0.95rem;
|
||||
color: var(--muted);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.summary-card p {
|
||||
font-size: 1.7rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--border);
|
||||
padding: 1.4rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.8rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge {
|
||||
background: var(--accent-weak);
|
||||
color: var(--accent);
|
||||
font-size: 0.75rem;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.tag-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.tag {
|
||||
background: #f1f2f8;
|
||||
color: #2e3046;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.sources {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.source-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: #f8f9fe;
|
||||
border-radius: 12px;
|
||||
padding: 0.4rem 0.6rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.source-item span {
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.app-footer {
|
||||
padding: 2rem 6vw 3rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.app-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": [
|
||||
"ES2020",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
import { handleSteamRefresh } from "./server/steam-api.mjs";
|
||||
import { handleGogAuth, handleGogRefresh } from "./server/gog-api.mjs";
|
||||
|
||||
const apiMiddlewarePlugin = {
|
||||
name: "api-middleware",
|
||||
configureServer(server) {
|
||||
server.middlewares.use((req, res, next) => {
|
||||
const url = req.url ?? "";
|
||||
if (url.startsWith("/api/steam/refresh")) {
|
||||
return handleSteamRefresh(req, res);
|
||||
}
|
||||
if (url.startsWith("/api/gog/auth")) {
|
||||
return handleGogAuth(req, res);
|
||||
}
|
||||
if (url.startsWith("/api/gog/refresh")) {
|
||||
return handleGogRefresh(req, res);
|
||||
}
|
||||
next();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), "");
|
||||
|
||||
return {
|
||||
base: env.VITE_BASE_PATH || "/",
|
||||
plugins: [react(), apiMiddlewarePlugin],
|
||||
server: {
|
||||
port: 5173,
|
||||
hmr: {
|
||||
overlay: true,
|
||||
},
|
||||
watch: {
|
||||
usePolling: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user