Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 84a48ac97b | |||
| f29332f3dd | |||
| 1b5cff78e2 | |||
| 6e9cd45671 | |||
| c6512d0153 | |||
| 2a2ccced90 | |||
| bd5df81f37 | |||
| 63219afc10 | |||
| ff98d7e64f | |||
| 2d50198782 | |||
| 2c8141660c | |||
| 109a9f383b | |||
| 0f8c9f331f | |||
| 32b9740854 | |||
| 0ccfe16a67 | |||
| 28d8959c5c | |||
| ac5ac570e2 | |||
| 7f16657a84 | |||
| 7337f38710 | |||
| b50fde1af5 | |||
| ee8b9aa77f | |||
| 5f5d163021 | |||
| 05d05ed05e | |||
| db1f66ced2 | |||
| ee32bfd206 | |||
| 399e7d5b89 | |||
| c9c69a3265 | |||
| 7e71098658 | |||
| e51a01123e | |||
| 9577087930 | |||
| d907f26683 | |||
| 1d444e6e4e | |||
| 2fdaf870b6 | |||
| 5ebd9dba16 | |||
| 17b52173c7 | |||
| de812a0fd1 |
@@ -1,2 +0,0 @@
|
||||
TWITCH_CLIENT_ID=op://Private/WhatToPlay/TWITCH_CLIENT_ID
|
||||
TWITCH_CLIENT_SECRET=op://Private/WhatToPlay/TWITCH_CLIENT_SECRET
|
||||
9
.env.example
Normal file
9
.env.example
Normal file
@@ -0,0 +1,9 @@
|
||||
# Frontend (only needed if API is on a different origin)
|
||||
VITE_API_URL=
|
||||
|
||||
# Server
|
||||
PORT=3001
|
||||
ALLOWED_ORIGIN=http://localhost:5173
|
||||
TWITCH_CLIENT_ID=
|
||||
TWITCH_CLIENT_SECRET=
|
||||
DATABASE_URL=postgresql://localhost:5432/whattoplay
|
||||
@@ -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=/
|
||||
18
.gitignore
vendored
18
.gitignore
vendored
@@ -2,17 +2,18 @@ node_modules
|
||||
.DS_Store
|
||||
.claude
|
||||
|
||||
# Override global gitignore exclusions
|
||||
!/src/
|
||||
!**/lib/
|
||||
|
||||
# Secrets
|
||||
.env
|
||||
.env.*
|
||||
!.env.*.example
|
||||
!.env.1password
|
||||
*.secret.*
|
||||
*.key
|
||||
*.pem
|
||||
!.env.example
|
||||
|
||||
# IGDB cache (generated at runtime)
|
||||
server/data/igdb-cache.json
|
||||
src/server/data/
|
||||
|
||||
# Build outputs
|
||||
dist
|
||||
@@ -20,6 +21,13 @@ build
|
||||
.vite
|
||||
coverage
|
||||
|
||||
# Database
|
||||
drizzle/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# bun
|
||||
bun.lock
|
||||
.mise.local.toml
|
||||
|
||||
2
.mise.toml
Normal file
2
.mise.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
[tools]
|
||||
bun = "1.3.0"
|
||||
78
.mise/tasks/deploy
Executable file
78
.mise/tasks/deploy
Executable file
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env bash
|
||||
#MISE description="Build and deploy frontend + backend to Uberspace"
|
||||
set -euo pipefail
|
||||
|
||||
UBERSPACE_HOST="${UBERSPACE_HOST:-serve}"
|
||||
REMOTE_HTML_DIR="~/www/html/whattoplay"
|
||||
REMOTE_SERVICE_DIR="~/services/whattoplay"
|
||||
SERVICE_NAME="whattoplay"
|
||||
PORT=3001
|
||||
|
||||
echo "==> building frontend..."
|
||||
bun run build
|
||||
|
||||
echo "==> syncing frontend to $REMOTE_HTML_DIR/"
|
||||
ssh "$UBERSPACE_HOST" "mkdir -p $REMOTE_HTML_DIR"
|
||||
rsync -avz --delete dist/ "$UBERSPACE_HOST:$REMOTE_HTML_DIR/"
|
||||
|
||||
echo "==> syncing project to $REMOTE_SERVICE_DIR/"
|
||||
ssh "$UBERSPACE_HOST" "mkdir -p $REMOTE_SERVICE_DIR"
|
||||
rsync -avz --delete \
|
||||
--exclude='.git/' \
|
||||
--exclude='.env' \
|
||||
--exclude='dist/' \
|
||||
--exclude='node_modules/' \
|
||||
--exclude='.DS_Store' \
|
||||
./ "$UBERSPACE_HOST:$REMOTE_SERVICE_DIR/"
|
||||
|
||||
echo "==> ensuring data directories exist..."
|
||||
ssh "$UBERSPACE_HOST" "mkdir -p $REMOTE_SERVICE_DIR/src/server/data/steam-icons $REMOTE_SERVICE_DIR/src/server/data/igdb-images/thumb $REMOTE_SERVICE_DIR/src/server/data/igdb-images/cover_big $REMOTE_SERVICE_DIR/src/server/data/igdb-images/screenshot_med"
|
||||
|
||||
echo "==> installing dependencies..."
|
||||
ssh "$UBERSPACE_HOST" "cd $REMOTE_SERVICE_DIR && bun install"
|
||||
|
||||
echo "==> creating .env if missing..."
|
||||
ssh "$UBERSPACE_HOST" "test -f $REMOTE_SERVICE_DIR/.env || cat > $REMOTE_SERVICE_DIR/.env" <<'ENV'
|
||||
PORT=3001
|
||||
ALLOWED_ORIGIN=https://serve.uber.space
|
||||
TWITCH_CLIENT_ID=
|
||||
TWITCH_CLIENT_SECRET=
|
||||
DATABASE_URL=
|
||||
ENV
|
||||
|
||||
echo "==> ensuring DATABASE_URL is set..."
|
||||
ssh "$UBERSPACE_HOST" "grep -q '^DATABASE_URL=' $REMOTE_SERVICE_DIR/.env || echo 'DATABASE_URL=' >> $REMOTE_SERVICE_DIR/.env"
|
||||
|
||||
echo "==> running database migrations..."
|
||||
ssh "$UBERSPACE_HOST" "cd $REMOTE_SERVICE_DIR && bunx drizzle-kit migrate"
|
||||
|
||||
echo "==> setting up web backend..."
|
||||
ssh "$UBERSPACE_HOST" "uberspace web backend add /whattoplay/api port $PORT --remove-prefix --force" || true
|
||||
|
||||
echo "==> setting up systemd service..."
|
||||
ssh "$UBERSPACE_HOST" "mkdir -p ~/.config/systemd/user"
|
||||
ssh "$UBERSPACE_HOST" "cat > ~/.config/systemd/user/$SERVICE_NAME.service" <<UNIT
|
||||
[Unit]
|
||||
Description=WhatToPlay API server
|
||||
After=network.target postgresql.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/home/serve/services/whattoplay
|
||||
EnvironmentFile=/home/serve/services/whattoplay/.env
|
||||
ExecStart=/usr/bin/bun run src/server/index.ts
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
UNIT
|
||||
|
||||
ssh "$UBERSPACE_HOST" "systemctl --user daemon-reload && systemctl --user enable $SERVICE_NAME && systemctl --user restart $SERVICE_NAME"
|
||||
|
||||
echo "==> checking service status..."
|
||||
ssh "$UBERSPACE_HOST" "systemctl --user status $SERVICE_NAME --no-pager" || true
|
||||
|
||||
echo "==> deploy complete"
|
||||
echo " frontend: https://serve.uber.space/whattoplay/"
|
||||
echo " api: https://serve.uber.space/whattoplay/api/health"
|
||||
5
.vscode/tasks.json
vendored
5
.vscode/tasks.json
vendored
@@ -5,10 +5,7 @@
|
||||
"label": "vite: dev server",
|
||||
"type": "shell",
|
||||
"command": "npm",
|
||||
"args": [
|
||||
"run",
|
||||
"dev"
|
||||
],
|
||||
"args": ["run", "dev"],
|
||||
"isBackground": true,
|
||||
"problemMatcher": [],
|
||||
"group": "build"
|
||||
|
||||
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.
|
||||
55
CLAUDE.md
Normal file
55
CLAUDE.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# whattoplay — Game Discovery App
|
||||
|
||||
Game recommendation and collection management tool with Steam/GOG integration and IGDB metadata.
|
||||
|
||||
## Stack
|
||||
|
||||
- **Frontend:** React 19, Vite, Tailwind CSS 4, TanStack Router (file-based), Zustand, PGlite
|
||||
- **Backend:** Hono (Bun), Drizzle ORM, PostgreSQL, Twitch/IGDB API
|
||||
- **Linting:** Biome (tabs, 80 chars, double quotes)
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── client/ ← React PWA (features/, routes/, shared/)
|
||||
├── server/ ← Hono API (features/, shared/)
|
||||
└── shared/ ← isomorphic code
|
||||
```
|
||||
|
||||
## Local Development
|
||||
|
||||
```bash
|
||||
bun install
|
||||
bun run dev # frontend (Vite)
|
||||
bun run dev:server # backend (Bun --watch)
|
||||
bun run dev:all # both
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
```bash
|
||||
mise run deploy
|
||||
```
|
||||
|
||||
Deploys to Uberspace (`serve.uber.space`):
|
||||
- Frontend → `/var/www/virtual/serve/html/whattoplay/`
|
||||
- Backend → `~/services/whattoplay/` (systemd: `whattoplay.service`, port 3001)
|
||||
- Route: `/whattoplay/api/*` → port 3001 (prefix removed)
|
||||
|
||||
## Environment Variables
|
||||
|
||||
See `.env.example`:
|
||||
- `DATABASE_URL` — PostgreSQL connection string
|
||||
- `PORT` — server port (default 3001)
|
||||
- `ALLOWED_ORIGIN` — CORS origin
|
||||
- `TWITCH_CLIENT_ID`, `TWITCH_CLIENT_SECRET` — Twitch API credentials (for IGDB)
|
||||
|
||||
## Database
|
||||
|
||||
PostgreSQL via Drizzle ORM. Migrations in `drizzle/`.
|
||||
|
||||
```bash
|
||||
bun run db:generate
|
||||
bun run db:migrate
|
||||
```
|
||||
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();
|
||||
26
biome.json
Normal file
26
biome.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.9.0/schema.json",
|
||||
"files": {
|
||||
"ignore": ["src/client/routeTree.gen.ts", "drizzle/", "dist/", "features/"]
|
||||
},
|
||||
"organizeImports": {
|
||||
"enabled": true
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "tab",
|
||||
"lineWidth": 80
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "double",
|
||||
"semicolons": "asNeeded"
|
||||
}
|
||||
}
|
||||
}
|
||||
20
components.json
Normal file
20
components.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/shared/components/ui",
|
||||
"utils": "@/shared/lib/utils",
|
||||
"ui": "@/shared/components/ui",
|
||||
"lib": "@/shared/lib",
|
||||
"hooks": "@/shared/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
@@ -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/)
|
||||
10
drizzle.config.ts
Normal file
10
drizzle.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from "drizzle-kit"
|
||||
|
||||
export default defineConfig({
|
||||
dialect: "postgresql",
|
||||
schema: "./src/server/shared/db/schema/*",
|
||||
out: "./drizzle",
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL ?? "",
|
||||
},
|
||||
})
|
||||
80
features/keycrow/README.md
Normal file
80
features/keycrow/README.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# KeyCrow - Steam Key Trading Platform with Escrow
|
||||
|
||||
Technical foundation for a automated Steam key trading platform with escrow system.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Client/App │
|
||||
└──────────────────────────┬──────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Backend API (Express) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Routes: auth | listings | transactions | theoretical │
|
||||
└──────┬──────────────┬──────────────────┬───────────────────┘
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌──────────────┐ ┌──────────────┐ ┌──────────────────────────┐
|
||||
│ Store │ │ Encryption │ │ Services │
|
||||
│ (In-Memory) │ │ Service │ │ - PaymentProvider (Mock) │
|
||||
│ │ │ (AES) │ │ - KeyActivationProvider │
|
||||
└──────────────┘ └──────────────┘ └──────────────────────────┘
|
||||
```
|
||||
|
||||
## What's Implemented
|
||||
|
||||
### Realistic Flow (Production-Ready Pattern)
|
||||
1. **Seller** creates a listing with encrypted Steam key
|
||||
2. **Buyer** purchases via escrow (payment held)
|
||||
3. **Platform** delivers decrypted key to buyer
|
||||
4. **Buyer** confirms key works → money released to seller
|
||||
5. **Buyer** reports failure → dispute, refund initiated
|
||||
|
||||
### Theoretica/Ideal Flow (Mock Only)
|
||||
- Automated server-side key activation on buyer's Steam account
|
||||
- **DISABLED by default** - requires `ALLOW_THEORETICAL_ACTIVATION=true`
|
||||
- Clearly marked as potentially violating Steam ToS
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Authentication
|
||||
- `POST /auth/register` - Register user
|
||||
- `GET /auth/me` - Get current user
|
||||
- `POST /auth/auth/steam/login` - Steam login (mock)
|
||||
|
||||
### Listings
|
||||
- `POST /listings` - Create listing
|
||||
- `GET /listings` - Get active listings
|
||||
- `GET /listings/:id` - Get listing by ID
|
||||
- `GET /listings/seller/me` - Get seller's listings
|
||||
- `DELETE /listings/:id` - Cancel listing
|
||||
|
||||
### Transactions
|
||||
- `POST /transactions` - Create purchase (escrow hold)
|
||||
- `GET /transactions/:id` - Get transaction
|
||||
- `GET /transactions/:id/key` - Get decrypted key (buyer only)
|
||||
- `POST /transactions/:id/confirm` - Confirm key works/failed
|
||||
- `GET /transactions/buyer/me` - Buyer's transactions
|
||||
- `GET /transactions/seller/me` - Seller's transactions
|
||||
|
||||
### Theoretical (Mock)
|
||||
- `POST /theoretical/activate` - Attempt automated activation
|
||||
|
||||
## Environment Variables
|
||||
|
||||
```bash
|
||||
PORT=3000
|
||||
ENCRYPTION_KEY=your-256-bit-key
|
||||
STEAM_API_KEY=your-steam-api-key
|
||||
STEAM_REDIRECT_URI=http://localhost:3000/auth/steam/callback
|
||||
ALLOW_THEORETICAL_ACTIVATION=false
|
||||
```
|
||||
|
||||
## Legal Notice
|
||||
|
||||
This implementation is a **technical proof-of-concept**. Automated Steam key activation is likely to violate Steam's Terms of Service unless you have an official partnership with Valve.
|
||||
|
||||
The "theoretical" module is clearly marked and disabled by default. Use at your own risk.
|
||||
5617
features/keycrow/package-lock.json
generated
Normal file
5617
features/keycrow/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
features/keycrow/package.json
Normal file
34
features/keycrow/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "keycrow-opencode",
|
||||
"version": "2026.02.18",
|
||||
"description": "Steam Key Trading Platform with Escrow - Technical Foundation",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc && cp -r src/gui dist/",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "ts-node src/index.ts",
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcrypt": "^5.1.1",
|
||||
"cors": "^2.8.5",
|
||||
"crypto-js": "^4.2.0",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"uuid": "^9.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/crypto-js": "^4.2.1",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jest": "^29.5.11",
|
||||
"@types/node": "^20.10.0",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"@types/uuid": "^9.0.7",
|
||||
"jest": "^29.7.0",
|
||||
"supertest": "^7.2.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.3.2"
|
||||
}
|
||||
}
|
||||
16
features/keycrow/src/config/index.ts
Normal file
16
features/keycrow/src/config/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export const config = {
|
||||
port: process.env.PORT ? parseInt(process.env.PORT) : 3000,
|
||||
encryption: {
|
||||
key: process.env.ENCRYPTION_KEY || 'default-dev-key-change-in-prod',
|
||||
},
|
||||
steam: {
|
||||
apiKey: process.env.STEAM_API_KEY || '',
|
||||
redirectUri: process.env.STEAM_REDIRECT_URI || 'http://localhost:3000/auth/steam/callback',
|
||||
},
|
||||
escrow: {
|
||||
holdDurationDays: 7,
|
||||
},
|
||||
features: {
|
||||
allowTheoreticalActivation: process.env.ALLOW_THEORETICAL_ACTIVATION === 'true',
|
||||
},
|
||||
};
|
||||
246
features/keycrow/src/gui/index.html
Normal file
246
features/keycrow/src/gui/index.html
Normal file
@@ -0,0 +1,246 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>KeyCrow Admin</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; padding: 20px; }
|
||||
.container { max-width: 1200px; margin: 0 auto; }
|
||||
h1 { color: #333; margin-bottom: 20px; }
|
||||
h2 { color: #555; margin: 20px 0 10px; }
|
||||
.card { background: white; border-radius: 8px; padding: 20px; margin-bottom: 20px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
||||
.form-group { margin-bottom: 15px; }
|
||||
label { display: block; margin-bottom: 5px; color: #666; }
|
||||
input, select { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; }
|
||||
button { background: #007AFF; color: white; border: none; padding: 12px 20px; border-radius: 6px; cursor: pointer; font-size: 14px; }
|
||||
button:hover { background: #0056b3; }
|
||||
button.secondary { background: #6c757d; }
|
||||
button.danger { background: #dc3545; }
|
||||
button.success { background: #28a745; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { padding: 12px; text-align: left; border-bottom: 1px solid #eee; }
|
||||
th { background: #f8f9fa; font-weight: 600; }
|
||||
.status { display: inline-block; padding: 4px 8px; border-radius: 4px; font-size: 12px; }
|
||||
.status.ACTIVE { background: #d4edda; color: #155724; }
|
||||
.status.SOLD { background: #cce5ff; color: #004085; }
|
||||
.status.HELD { background: #fff3cd; color: #856404; }
|
||||
.status.RELEASED { background: #d4edda; color: #155724; }
|
||||
.status.REFUNDED { background: #f8d7da; color: #721c24; }
|
||||
.status.PENDING { background: #fff3cd; color: #856404; }
|
||||
.tabs { display: flex; gap: 10px; margin-bottom: 20px; }
|
||||
.tab { padding: 10px 20px; background: white; border: 1px solid #ddd; border-radius: 6px; cursor: pointer; }
|
||||
.tab.active { background: #007AFF; color: white; border-color: #007AFF; }
|
||||
.tab-content { display: none; }
|
||||
.tab-content.active { display: block; }
|
||||
.alert { padding: 15px; border-radius: 6px; margin-bottom: 15px; }
|
||||
.alert.error { background: #f8d7da; color: #721c24; }
|
||||
.alert.success { background: #d4edda; color: #155724; }
|
||||
.user-info { background: #e9ecef; padding: 10px; border-radius: 4px; margin-bottom: 20px; }
|
||||
pre { background: #f8f9fa; padding: 10px; border-radius: 4px; overflow-x: auto; font-size: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>KeyCrow Admin</h1>
|
||||
|
||||
<div class="card">
|
||||
<h2>Session</h2>
|
||||
<div class="form-group">
|
||||
<label>User ID (for testing)</label>
|
||||
<input type="text" id="userId" placeholder="Enter user ID from registration">
|
||||
</div>
|
||||
<button onclick="setUser()">Set User</button>
|
||||
<span id="currentUser" style="margin-left: 15px; color: #666;"></span>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<div class="tab active" onclick="showTab('listings')">Listings</div>
|
||||
<div class="tab" onclick="showTab('transactions')">Transactions</div>
|
||||
<div class="tab" onclick="showTab('create')">Create Listing</div>
|
||||
</div>
|
||||
|
||||
<div id="listings" class="tab-content active">
|
||||
<div class="card">
|
||||
<h2>Active Listings</h2>
|
||||
<button class="secondary" onclick="loadListings()">Refresh</button>
|
||||
<table style="margin-top: 15px;">
|
||||
<thead>
|
||||
<tr><th>ID</th><th>Game</th><th>Platform</th><th>Price</th><th>Status</th><th>Actions</th></tr>
|
||||
</thead>
|
||||
<tbody id="listingsTable"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="transactions" class="tab-content">
|
||||
<div class="card">
|
||||
<h2>Transactions</h2>
|
||||
<button class="secondary" onclick="loadTransactions()">Refresh</button>
|
||||
<table style="margin-top: 15px;">
|
||||
<thead>
|
||||
<tr><th>ID</th><th>Listing</th><th>Amount</th><th>Escrow</th><th>Status</th><th>Key</th><th>Confirm</th></tr>
|
||||
</thead>
|
||||
<tbody id="transactionsTable"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="create" class="tab-content">
|
||||
<div class="card">
|
||||
<h2>Create New Listing</h2>
|
||||
<div class="form-group">
|
||||
<label>Game Title</label>
|
||||
<input type="text" id="gameTitle" placeholder="e.g., Cyberpunk 2077">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Platform</label>
|
||||
<select id="platform">
|
||||
<option value="STEAM">Steam</option>
|
||||
<option value="GOG">GOG</option>
|
||||
<option value="EPIC">Epic</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Price</label>
|
||||
<input type="number" id="price" step="0.01" placeholder="9.99">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Steam Key</label>
|
||||
<input type="text" id="key" placeholder="XXXX-XXXX-XXXX">
|
||||
</div>
|
||||
<button onclick="createListing()">Create Listing</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="alert" class="alert" style="display: none;"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let currentUser = null;
|
||||
|
||||
function showTab(tabId) {
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
|
||||
event.target.classList.add('active');
|
||||
document.getElementById(tabId).classList.add('active');
|
||||
}
|
||||
|
||||
function showAlert(message, type = 'success') {
|
||||
const alert = document.getElementById('alert');
|
||||
alert.className = `alert ${type}`;
|
||||
alert.textContent = message;
|
||||
alert.style.display = 'block';
|
||||
setTimeout(() => alert.style.display = 'none', 5000);
|
||||
}
|
||||
|
||||
function setUser() {
|
||||
currentUser = document.getElementById('userId').value;
|
||||
if (currentUser) {
|
||||
document.getElementById('currentUser').textContent = `Current user: ${currentUser.substring(0, 8)}...`;
|
||||
}
|
||||
}
|
||||
|
||||
async function api(method, endpoint, body = null) {
|
||||
const options = { method, headers: { 'Content-Type': 'application/json' } };
|
||||
if (currentUser) options.headers['x-user-id'] = currentUser;
|
||||
if (body) options.body = JSON.stringify(body);
|
||||
const res = await fetch(endpoint, options);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function loadListings() {
|
||||
const result = await api('GET', '/listings');
|
||||
const tbody = document.getElementById('listingsTable');
|
||||
if (!result.data?.listings?.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="6">No listings found</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = result.data.listings.map(l => `
|
||||
<tr>
|
||||
<td>${l.id.substring(0, 8)}...</td>
|
||||
<td>${l.gameTitle}</td>
|
||||
<td>${l.platform}</td>
|
||||
<td>${l.price} ${l.currency}</td>
|
||||
<td><span class="status ${l.status}">${l.status}</span></td>
|
||||
<td><button class="secondary" onclick="deleteListing('${l.id}')">Cancel</button></td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function createListing() {
|
||||
const data = {
|
||||
gameTitle: document.getElementById('gameTitle').value,
|
||||
platform: document.getElementById('platform').value,
|
||||
price: parseFloat(document.getElementById('price').value),
|
||||
currency: 'EUR',
|
||||
key: document.getElementById('key').value
|
||||
};
|
||||
const result = await api('POST', '/listings', data);
|
||||
if (result.success) {
|
||||
showAlert('Listing created!');
|
||||
document.getElementById('gameTitle').value = '';
|
||||
document.getElementById('price').value = '';
|
||||
document.getElementById('key').value = '';
|
||||
loadListings();
|
||||
} else {
|
||||
showAlert(result.error || 'Failed to create listing', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteListing(id) {
|
||||
if (!confirm('Cancel this listing?')) return;
|
||||
const result = await api('DELETE', `/listings/${id}`);
|
||||
if (result.success) {
|
||||
showAlert('Listing cancelled');
|
||||
loadListings();
|
||||
} else {
|
||||
showAlert(result.error, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTransactions() {
|
||||
const result = await api('GET', '/transactions/buyer/me');
|
||||
const tbody = document.getElementById('transactionsTable');
|
||||
if (!result.data?.transactions?.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="7">No transactions found</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = result.data.transactions.map(t => `
|
||||
<tr>
|
||||
<td>${t.id.substring(0, 8)}...</td>
|
||||
<td>${t.listingId.substring(0, 8)}...</td>
|
||||
<td>${t.amount} ${t.currency}</td>
|
||||
<td><span class="status ${t.escrowStatus}">${t.escrowStatus}</span></td>
|
||||
<td><span class="status ${t.transactionStatus}">${t.transactionStatus}</span></td>
|
||||
<td>${t.keyDelivered ? '✓' : '<button class="secondary" onclick="getKey(\'' + t.id + '\')">Get Key</button>'}</td>
|
||||
<td>${t.transactionStatus === 'PENDING' ? '<button class="success" onclick="confirmTx(\'' + t.id + '\', \'SUCCESS\')">✓</button> <button class="danger" onclick="confirmTx(\'' + t.id + '\', \'FAILED\')">✗</button>' : '-'}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function getKey(txId) {
|
||||
const result = await api('GET', `/transactions/${txId}/key`);
|
||||
if (result.success && result.data.key) {
|
||||
showAlert(`Key: ${result.data.key}`);
|
||||
loadTransactions();
|
||||
} else {
|
||||
showAlert(result.error || 'Failed to get key', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmTx(txId, status) {
|
||||
const result = await api('POST', `/transactions/${txId}/confirm`, { status });
|
||||
if (result.success) {
|
||||
showAlert(`Transaction ${status === 'SUCCESS' ? 'confirmed' : 'disputed'}`);
|
||||
loadTransactions();
|
||||
} else {
|
||||
showAlert(result.error, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
loadListings();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
43
features/keycrow/src/index.ts
Normal file
43
features/keycrow/src/index.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import express, { Express } from 'express';
|
||||
import cors from 'cors';
|
||||
import path from 'path';
|
||||
import { config } from './config';
|
||||
import authRoutes from './routes/auth';
|
||||
import listingsRoutes from './routes/listings';
|
||||
import transactionsRoutes from './routes/transactions';
|
||||
import theoreticalRoutes from './routes/theoretical';
|
||||
|
||||
const app: Express = express();
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
app.use(express.static(path.join(__dirname, 'gui')));
|
||||
|
||||
app.get('/', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, 'gui', 'index.html'));
|
||||
});
|
||||
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
app.use('/auth', authRoutes);
|
||||
app.use('/listings', listingsRoutes);
|
||||
app.use('/transactions', transactionsRoutes);
|
||||
app.use('/theoretical', theoreticalRoutes);
|
||||
|
||||
app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
console.error('Unhandled error:', err);
|
||||
res.status(500).json({ success: false, error: 'Internal server error' });
|
||||
});
|
||||
|
||||
const startServer = () => {
|
||||
app.listen(config.port, () => {
|
||||
console.log(`Server running on port ${config.port}`);
|
||||
console.log(`Theoretical activation: ${config.features.allowTheoreticalActivation ? 'ENABLED' : 'DISABLED'}`);
|
||||
});
|
||||
};
|
||||
|
||||
startServer();
|
||||
|
||||
export default app;
|
||||
50
features/keycrow/src/middleware/auth.ts
Normal file
50
features/keycrow/src/middleware/auth.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { store } from '../services/Store';
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
userId?: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface AuthRequest extends Request {
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export const mockAuthMiddleware = (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
const userId = req.headers['x-user-id'] as string;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(401).json({ success: false, error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const user = store.getUserById(userId);
|
||||
if (!user) {
|
||||
return res.status(401).json({ success: false, error: 'User not found' });
|
||||
}
|
||||
|
||||
req.userId = userId;
|
||||
next();
|
||||
};
|
||||
|
||||
export const requireSteamAuth = (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
const userId = req.headers['x-user-id'] as string;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(401).json({ success: false, error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const user = store.getUserById(userId);
|
||||
if (!user) {
|
||||
return res.status(401).json({ success: false, error: 'User not found' });
|
||||
}
|
||||
|
||||
if (!user.steamId) {
|
||||
return res.status(403).json({ success: false, error: 'Steam authentication required' });
|
||||
}
|
||||
|
||||
req.userId = userId;
|
||||
next();
|
||||
};
|
||||
65
features/keycrow/src/models/index.ts
Normal file
65
features/keycrow/src/models/index.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
export interface User {
|
||||
id: string;
|
||||
steamId?: string;
|
||||
username: string;
|
||||
email: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export type ListingStatus = 'ACTIVE' | 'SOLD' | 'CANCELLED' | 'EXPIRED';
|
||||
export type Platform = 'STEAM' | 'GOG' | 'EPIC' | 'OTHER';
|
||||
|
||||
export interface Listing {
|
||||
id: string;
|
||||
sellerId: string;
|
||||
gameTitle: string;
|
||||
platform: Platform;
|
||||
price: number;
|
||||
currency: string;
|
||||
keyEncrypted: string;
|
||||
status: ListingStatus;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export type EscrowStatus = 'PENDING' | 'HELD' | 'RELEASED' | 'REFUNDED';
|
||||
export type TransactionStatus = 'PENDING' | 'COMPLETED' | 'DISPUTED' | 'CANCELLED';
|
||||
|
||||
export interface Transaction {
|
||||
id: string;
|
||||
listingId: string;
|
||||
buyerId: string;
|
||||
sellerId: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
escrowStatus: EscrowStatus;
|
||||
transactionStatus: TransactionStatus;
|
||||
holdId?: string;
|
||||
keyDelivered: boolean;
|
||||
confirmedAt?: Date;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface CreateListingDto {
|
||||
gameTitle: string;
|
||||
platform: Platform;
|
||||
price: number;
|
||||
currency?: string;
|
||||
key: string;
|
||||
}
|
||||
|
||||
export interface CreateTransactionDto {
|
||||
listingId: string;
|
||||
}
|
||||
|
||||
export interface ConfirmTransactionDto {
|
||||
status: 'SUCCESS' | 'FAILED';
|
||||
}
|
||||
|
||||
export interface ApiResponse<T = unknown> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
}
|
||||
75
features/keycrow/src/routes/auth.ts
Normal file
75
features/keycrow/src/routes/auth.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { store } from '../services/Store';
|
||||
import { ApiResponse } from '../models';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.post('/register', (req: Request, res: Response) => {
|
||||
const { username, email } = req.body;
|
||||
|
||||
if (!username || !email) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Username and email required'
|
||||
});
|
||||
}
|
||||
|
||||
const user = store.createUser({ username, email });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { user: { id: user.id, username: user.username, email: user.email } },
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/me', (req: Request, res: Response) => {
|
||||
const userId = req.headers['x-user-id'] as string;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(401).json({ success: false, error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const user = store.getUserById(userId);
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ success: false, error: 'User not found' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { user },
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/auth/steam/login', (req: Request, res: Response) => {
|
||||
const { steamId, username } = req.body;
|
||||
|
||||
if (!steamId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Steam ID required'
|
||||
});
|
||||
}
|
||||
|
||||
let user = store.getUserBySteamId(steamId);
|
||||
|
||||
if (!user) {
|
||||
user = store.createUser({
|
||||
username: username || `SteamUser_${steamId}`,
|
||||
email: '',
|
||||
steamId,
|
||||
});
|
||||
} else {
|
||||
store.updateUser(user.id, { steamId });
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
user: { id: user.id, username: user.username, steamId: user.steamId },
|
||||
message: 'Steam login successful (mock)',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
121
features/keycrow/src/routes/listings.ts
Normal file
121
features/keycrow/src/routes/listings.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { store } from '../services/Store';
|
||||
import { encryptionService } from '../utils/encryption';
|
||||
import { mockAuthMiddleware } from '../middleware/auth';
|
||||
import { CreateListingDto } from '../models';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.post('/', mockAuthMiddleware, (req, res: Response) => {
|
||||
const { gameTitle, platform, price, currency = 'EUR', key } = req.body as CreateListingDto;
|
||||
|
||||
if (!gameTitle || !platform || !price || !key) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'gameTitle, platform, price, and key are required',
|
||||
});
|
||||
}
|
||||
|
||||
if (price <= 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Price must be greater than 0',
|
||||
});
|
||||
}
|
||||
|
||||
const keyEncrypted = encryptionService.encrypt(key);
|
||||
|
||||
const listing = store.createListing({
|
||||
sellerId: req.userId!,
|
||||
gameTitle,
|
||||
platform,
|
||||
price,
|
||||
currency,
|
||||
keyEncrypted,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
listing: {
|
||||
id: listing.id,
|
||||
gameTitle: listing.gameTitle,
|
||||
platform: listing.platform,
|
||||
price: listing.price,
|
||||
currency: listing.currency,
|
||||
status: listing.status,
|
||||
createdAt: listing.createdAt,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/', (req, res: Response) => {
|
||||
const listings = store.getActiveListings();
|
||||
|
||||
const safeListings = listings.map(l => ({
|
||||
id: l.id,
|
||||
gameTitle: l.gameTitle,
|
||||
platform: l.platform,
|
||||
price: l.price,
|
||||
currency: l.currency,
|
||||
sellerId: l.sellerId,
|
||||
status: l.status,
|
||||
createdAt: l.createdAt,
|
||||
}));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { listings: safeListings },
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/:id', (req, res: Response) => {
|
||||
const listing = store.getListingById(req.params.id);
|
||||
|
||||
if (!listing) {
|
||||
return res.status(404).json({ success: false, error: 'Listing not found' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { listing },
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/seller/me', mockAuthMiddleware, (req, res: Response) => {
|
||||
const listings = store.getListingsBySeller(req.userId!);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { listings },
|
||||
});
|
||||
});
|
||||
|
||||
router.delete('/:id', mockAuthMiddleware, (req, res: Response) => {
|
||||
const listing = store.getListingById(req.params.id);
|
||||
|
||||
if (!listing) {
|
||||
return res.status(404).json({ success: false, error: 'Listing not found' });
|
||||
}
|
||||
|
||||
if (listing.sellerId !== req.userId) {
|
||||
return res.status(403).json({ success: false, error: 'Forbidden' });
|
||||
}
|
||||
|
||||
if (listing.status !== 'ACTIVE') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Can only cancel active listings'
|
||||
});
|
||||
}
|
||||
|
||||
store.updateListing(listing.id, { status: 'CANCELLED' });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { message: 'Listing cancelled' },
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
35
features/keycrow/src/routes/theoretical.ts
Normal file
35
features/keycrow/src/routes/theoretical.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Router, Response } from 'express';
|
||||
import { keyActivationProvider } from '../services/KeyActivationProvider';
|
||||
import { mockAuthMiddleware, requireSteamAuth } from '../middleware/auth';
|
||||
import { config } from '../config';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.post('/activate', requireSteamAuth, async (req, res: Response) => {
|
||||
if (!config.features.allowTheoreticalActivation) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: 'Automated key activation is disabled. This feature is for demonstration only.',
|
||||
});
|
||||
}
|
||||
|
||||
const { key } = req.body;
|
||||
|
||||
if (!key) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Key is required',
|
||||
});
|
||||
}
|
||||
|
||||
const user = req.userId;
|
||||
|
||||
const result = await keyActivationProvider.activateKey(user!, key);
|
||||
|
||||
res.json({
|
||||
success: result.success,
|
||||
data: result,
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
225
features/keycrow/src/routes/transactions.ts
Normal file
225
features/keycrow/src/routes/transactions.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import { Router, Response } from 'express';
|
||||
import { store } from '../services/Store';
|
||||
import { paymentProvider } from '../services/PaymentProvider';
|
||||
import { keyActivationProvider } from '../services/KeyActivationProvider';
|
||||
import { encryptionService } from '../utils/encryption';
|
||||
import { mockAuthMiddleware, requireSteamAuth } from '../middleware/auth';
|
||||
import { config } from '../config';
|
||||
import { CreateTransactionDto } from '../models';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.post('/', mockAuthMiddleware, async (req, res: Response) => {
|
||||
const { listingId } = req.body as CreateTransactionDto;
|
||||
|
||||
if (!listingId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'listingId is required',
|
||||
});
|
||||
}
|
||||
|
||||
const listing = store.getListingById(listingId);
|
||||
|
||||
if (!listing) {
|
||||
return res.status(404).json({ success: false, error: 'Listing not found' });
|
||||
}
|
||||
|
||||
if (listing.status !== 'ACTIVE') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Listing is not available',
|
||||
});
|
||||
}
|
||||
|
||||
if (listing.sellerId === req.userId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Cannot buy your own listing',
|
||||
});
|
||||
}
|
||||
|
||||
const holdResult = await paymentProvider.createHold(listing.price, listing.currency);
|
||||
|
||||
if (!holdResult.success) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: holdResult.error || 'Payment failed',
|
||||
});
|
||||
}
|
||||
|
||||
const transaction = store.createTransaction({
|
||||
listingId: listing.id,
|
||||
buyerId: req.userId!,
|
||||
sellerId: listing.sellerId,
|
||||
amount: listing.price,
|
||||
currency: listing.currency,
|
||||
escrowStatus: 'HELD',
|
||||
transactionStatus: 'PENDING',
|
||||
holdId: holdResult.holdId,
|
||||
keyDelivered: false,
|
||||
});
|
||||
|
||||
store.updateListing(listing.id, { status: 'SOLD' });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
transaction: {
|
||||
id: transaction.id,
|
||||
listingId: transaction.listingId,
|
||||
amount: transaction.amount,
|
||||
currency: transaction.currency,
|
||||
escrowStatus: transaction.escrowStatus,
|
||||
transactionStatus: transaction.transactionStatus,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/:id', mockAuthMiddleware, (req, res: Response) => {
|
||||
const transaction = store.getTransactionById(req.params.id);
|
||||
|
||||
if (!transaction) {
|
||||
return res.status(404).json({ success: false, error: 'Transaction not found' });
|
||||
}
|
||||
|
||||
if (transaction.buyerId !== req.userId && transaction.sellerId !== req.userId) {
|
||||
return res.status(403).json({ success: false, error: 'Forbidden' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { transaction },
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/:id/key', mockAuthMiddleware, (req, res: Response) => {
|
||||
const transaction = store.getTransactionById(req.params.id);
|
||||
|
||||
if (!transaction) {
|
||||
return res.status(404).json({ success: false, error: 'Transaction not found' });
|
||||
}
|
||||
|
||||
if (transaction.buyerId !== req.userId) {
|
||||
return res.status(403).json({ success: false, error: 'Forbidden' });
|
||||
}
|
||||
|
||||
if (transaction.escrowStatus !== 'HELD') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Key only available when payment is held in escrow',
|
||||
});
|
||||
}
|
||||
|
||||
if (transaction.keyDelivered) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
keyAlreadyDelivered: true,
|
||||
message: 'Key was already delivered in this transaction',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const listing = store.getListingById(transaction.listingId);
|
||||
if (!listing) {
|
||||
return res.status(404).json({ success: false, error: 'Listing not found' });
|
||||
}
|
||||
|
||||
const key = encryptionService.decrypt(listing.keyEncrypted);
|
||||
|
||||
store.updateTransaction(transaction.id, { keyDelivered: true });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { key },
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/:id/confirm', requireSteamAuth, async (req, res: Response) => {
|
||||
const { status } = req.body;
|
||||
|
||||
if (!status || !['SUCCESS', 'FAILED'].includes(status)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'status must be SUCCESS or FAILED',
|
||||
});
|
||||
}
|
||||
|
||||
const transaction = store.getTransactionById(req.params.id);
|
||||
|
||||
if (!transaction) {
|
||||
return res.status(404).json({ success: false, error: 'Transaction not found' });
|
||||
}
|
||||
|
||||
if (transaction.buyerId !== req.userId) {
|
||||
return res.status(403).json({ success: false, error: 'Forbidden' });
|
||||
}
|
||||
|
||||
if (transaction.transactionStatus !== 'PENDING') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Transaction already confirmed or disputed',
|
||||
});
|
||||
}
|
||||
|
||||
if (status === 'SUCCESS') {
|
||||
if (transaction.holdId) {
|
||||
await paymentProvider.release(transaction.holdId);
|
||||
}
|
||||
|
||||
store.updateTransaction(transaction.id, {
|
||||
escrowStatus: 'RELEASED',
|
||||
transactionStatus: 'COMPLETED',
|
||||
confirmedAt: new Date(),
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
message: 'Key confirmed. Payment released to seller.',
|
||||
escrowStatus: 'RELEASED',
|
||||
},
|
||||
});
|
||||
} else {
|
||||
if (transaction.holdId) {
|
||||
await paymentProvider.refund(transaction.holdId);
|
||||
}
|
||||
|
||||
store.updateTransaction(transaction.id, {
|
||||
escrowStatus: 'REFUNDED',
|
||||
transactionStatus: 'DISPUTED',
|
||||
confirmedAt: new Date(),
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
message: 'Key marked as failed. Payment refunded to buyer.',
|
||||
escrowStatus: 'REFUNDED',
|
||||
transactionStatus: 'DISPUTED',
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/buyer/me', mockAuthMiddleware, (req, res: Response) => {
|
||||
const transactions = store.getTransactionsByBuyer(req.userId!);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { transactions },
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/seller/me', mockAuthMiddleware, (req, res: Response) => {
|
||||
const transactions = store.getTransactionsBySeller(req.userId!);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { transactions },
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
49
features/keycrow/src/services/KeyActivationProvider.ts
Normal file
49
features/keycrow/src/services/KeyActivationProvider.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
export interface ActivationResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
productId?: string;
|
||||
purchaseId?: string;
|
||||
}
|
||||
|
||||
export interface KeyActivationProvider {
|
||||
activateKey(steamId: string, key: string): Promise<ActivationResult>;
|
||||
}
|
||||
|
||||
export class MockKeyActivationProvider implements KeyActivationProvider {
|
||||
async activateKey(steamId: string, key: string): Promise<ActivationResult> {
|
||||
await this.simulateDelay();
|
||||
|
||||
if (!key || key.length < 5) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid key format',
|
||||
};
|
||||
}
|
||||
|
||||
if (key.toLowerCase().includes('invalid')) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Key has already been redeemed',
|
||||
};
|
||||
}
|
||||
|
||||
if (key.toLowerCase().includes('expired')) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Key has expired',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
productId: `prod_${Math.random().toString(36).substr(2, 9)}`,
|
||||
purchaseId: `purch_${Math.random().toString(36).substr(2, 9)}`,
|
||||
};
|
||||
}
|
||||
|
||||
private simulateDelay(): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, 500));
|
||||
}
|
||||
}
|
||||
|
||||
export const keyActivationProvider = new MockKeyActivationProvider();
|
||||
53
features/keycrow/src/services/PaymentProvider.ts
Normal file
53
features/keycrow/src/services/PaymentProvider.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
export type PaymentResult = {
|
||||
success: boolean;
|
||||
holdId?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export interface PaymentProvider {
|
||||
createHold(amount: number, currency: string): Promise<PaymentResult>;
|
||||
release(holdId: string): Promise<{ success: boolean; error?: string }>;
|
||||
refund(holdId: string): Promise<{ success: boolean; error?: string }>;
|
||||
}
|
||||
|
||||
export class MockPaymentProvider implements PaymentProvider {
|
||||
private holds: Map<string, { amount: number; currency: string; released: boolean }> = new Map();
|
||||
|
||||
async createHold(amount: number, currency: string): Promise<PaymentResult> {
|
||||
const holdId = `hold_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
this.holds.set(holdId, { amount, currency, released: false });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
holdId,
|
||||
};
|
||||
}
|
||||
|
||||
async release(holdId: string): Promise<{ success: boolean; error?: string }> {
|
||||
const hold = this.holds.get(holdId);
|
||||
|
||||
if (!hold) {
|
||||
return { success: false, error: 'Hold not found' };
|
||||
}
|
||||
|
||||
if (hold.released) {
|
||||
return { success: false, error: 'Hold already released' };
|
||||
}
|
||||
|
||||
hold.released = true;
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async refund(holdId: string): Promise<{ success: boolean; error?: string }> {
|
||||
const hold = this.holds.get(holdId);
|
||||
|
||||
if (!hold) {
|
||||
return { success: false, error: 'Hold not found' };
|
||||
}
|
||||
|
||||
this.holds.delete(holdId);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
export const paymentProvider = new MockPaymentProvider();
|
||||
103
features/keycrow/src/services/Store.ts
Normal file
103
features/keycrow/src/services/Store.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { User, Listing, Transaction } from '../models';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
class InMemoryStore {
|
||||
users: Map<string, User> = new Map();
|
||||
listings: Map<string, Listing> = new Map();
|
||||
transactions: Map<string, Transaction> = new Map();
|
||||
|
||||
createUser(data: Omit<User, 'id' | 'createdAt' | 'updatedAt'>): User {
|
||||
const user: User = {
|
||||
...data,
|
||||
id: uuidv4(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
this.users.set(user.id, user);
|
||||
return user;
|
||||
}
|
||||
|
||||
getUserById(id: string): User | undefined {
|
||||
return this.users.get(id);
|
||||
}
|
||||
|
||||
getUserBySteamId(steamId: string): User | undefined {
|
||||
return Array.from(this.users.values()).find(u => u.steamId === steamId);
|
||||
}
|
||||
|
||||
updateUser(id: string, data: Partial<User>): User | undefined {
|
||||
const user = this.users.get(id);
|
||||
if (!user) return undefined;
|
||||
|
||||
const updated = { ...user, ...data, updatedAt: new Date() };
|
||||
this.users.set(id, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
createListing(data: Omit<Listing, 'id' | 'status' | 'createdAt' | 'updatedAt'>): Listing {
|
||||
const listing: Listing = {
|
||||
...data,
|
||||
id: uuidv4(),
|
||||
status: 'ACTIVE',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
this.listings.set(listing.id, listing);
|
||||
return listing;
|
||||
}
|
||||
|
||||
getListingById(id: string): Listing | undefined {
|
||||
return this.listings.get(id);
|
||||
}
|
||||
|
||||
getActiveListings(): Listing[] {
|
||||
return Array.from(this.listings.values()).filter(l => l.status === 'ACTIVE');
|
||||
}
|
||||
|
||||
getListingsBySeller(sellerId: string): Listing[] {
|
||||
return Array.from(this.listings.values()).filter(l => l.sellerId === sellerId);
|
||||
}
|
||||
|
||||
updateListing(id: string, data: Partial<Listing>): Listing | undefined {
|
||||
const listing = this.listings.get(id);
|
||||
if (!listing) return undefined;
|
||||
|
||||
const updated = { ...listing, ...data, updatedAt: new Date() };
|
||||
this.listings.set(id, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
createTransaction(data: Omit<Transaction, 'id' | 'createdAt' | 'updatedAt'>): Transaction {
|
||||
const transaction: Transaction = {
|
||||
...data,
|
||||
id: uuidv4(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
this.transactions.set(transaction.id, transaction);
|
||||
return transaction;
|
||||
}
|
||||
|
||||
getTransactionById(id: string): Transaction | undefined {
|
||||
return this.transactions.get(id);
|
||||
}
|
||||
|
||||
getTransactionsByBuyer(buyerId: string): Transaction[] {
|
||||
return Array.from(this.transactions.values()).filter(t => t.buyerId === buyerId);
|
||||
}
|
||||
|
||||
getTransactionsBySeller(sellerId: string): Transaction[] {
|
||||
return Array.from(this.transactions.values()).filter(t => t.sellerId === sellerId);
|
||||
}
|
||||
|
||||
updateTransaction(id: string, data: Partial<Transaction>): Transaction | undefined {
|
||||
const transaction = this.transactions.get(id);
|
||||
if (!transaction) return undefined;
|
||||
|
||||
const updated = { ...transaction, ...data, updatedAt: new Date() };
|
||||
this.transactions.set(id, updated);
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
|
||||
export const store = new InMemoryStore();
|
||||
33
features/keycrow/src/utils/encryption.ts
Normal file
33
features/keycrow/src/utils/encryption.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import CryptoJS from 'crypto-js';
|
||||
import { config } from '../config';
|
||||
|
||||
export class EncryptionService {
|
||||
private static instance: EncryptionService;
|
||||
private readonly key: string;
|
||||
|
||||
private constructor() {
|
||||
this.key = config.encryption.key;
|
||||
}
|
||||
|
||||
static getInstance(): EncryptionService {
|
||||
if (!EncryptionService.instance) {
|
||||
EncryptionService.instance = new EncryptionService();
|
||||
}
|
||||
return EncryptionService.instance;
|
||||
}
|
||||
|
||||
encrypt(plainText: string): string {
|
||||
return CryptoJS.AES.encrypt(plainText, this.key).toString();
|
||||
}
|
||||
|
||||
decrypt(cipherText: string): string {
|
||||
const bytes = CryptoJS.AES.decrypt(cipherText, this.key);
|
||||
return bytes.toString(CryptoJS.enc.Utf8);
|
||||
}
|
||||
|
||||
hash(data: string): string {
|
||||
return CryptoJS.SHA256(data).toString();
|
||||
}
|
||||
}
|
||||
|
||||
export const encryptionService = EncryptionService.getInstance();
|
||||
94
features/keycrow/tests/api.test.ts
Normal file
94
features/keycrow/tests/api.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import request from 'supertest';
|
||||
import app from '../src/index';
|
||||
|
||||
describe('KeyCrow API', () => {
|
||||
let sellerId: string;
|
||||
let buyerId: string;
|
||||
let listingId: string;
|
||||
let transactionId: string;
|
||||
const testKey = 'ABCD-EFGH-IJKL-MNOP';
|
||||
|
||||
describe('Auth Flow', () => {
|
||||
it('should register a seller', async () => {
|
||||
const res = await request(app)
|
||||
.post('/auth/register')
|
||||
.send({ username: 'seller1', email: 'seller@test.com' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
sellerId = res.body.data.user.id;
|
||||
});
|
||||
|
||||
it('should register a buyer with steam', async () => {
|
||||
const res = await request(app)
|
||||
.post('/auth/auth/steam/login')
|
||||
.send({ steamId: '76561198000000001', username: 'buyer1' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
buyerId = res.body.data.user.id;
|
||||
});
|
||||
});
|
||||
|
||||
describe('Listings Flow', () => {
|
||||
it('should create a listing', async () => {
|
||||
const res = await request(app)
|
||||
.post('/listings')
|
||||
.set('x-user-id', sellerId)
|
||||
.send({
|
||||
gameTitle: 'Test Game',
|
||||
platform: 'STEAM',
|
||||
price: 9.99,
|
||||
currency: 'EUR',
|
||||
key: testKey,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
listingId = res.body.data.listing.id;
|
||||
});
|
||||
|
||||
it('should get active listings', async () => {
|
||||
const res = await request(app).get('/listings');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.data.listings.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Transaction Flow (Escrow)', () => {
|
||||
it('should create a purchase with escrow hold', async () => {
|
||||
const res = await request(app)
|
||||
.post('/transactions')
|
||||
.set('x-user-id', buyerId)
|
||||
.send({ listingId });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.data.transaction.escrowStatus).toBe('HELD');
|
||||
transactionId = res.body.data.transaction.id;
|
||||
});
|
||||
|
||||
it('should deliver key to buyer', async () => {
|
||||
const res = await request(app)
|
||||
.get(`/transactions/${transactionId}/key`)
|
||||
.set('x-user-id', buyerId);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.data.key).toBe(testKey);
|
||||
});
|
||||
|
||||
it('should confirm success and release escrow', async () => {
|
||||
const res = await request(app)
|
||||
.post(`/transactions/${transactionId}/confirm`)
|
||||
.set('x-user-id', buyerId)
|
||||
.send({ status: 'SUCCESS' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.data.escrowStatus).toBe('RELEASED');
|
||||
});
|
||||
});
|
||||
});
|
||||
19
features/keycrow/tsconfig.json
Normal file
19
features/keycrow/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2020"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "tests"]
|
||||
}
|
||||
18
index.html
18
index.html
@@ -1,20 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<html lang="en">
|
||||
<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" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
<title>WhatToPlay</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||
<link rel="apple-touch-icon" href="/whattoplay/icons/icon-192.png" />
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<script type="module" src="/src/client/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
10623
package-lock.json
generated
10623
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
74
package.json
74
package.json
@@ -1,34 +1,64 @@
|
||||
{
|
||||
"name": "whattoplay",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "2026.03.02",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "op run --env-file=.env.1password -- vite",
|
||||
"dev:no-op": "vite",
|
||||
"build": "vite build",
|
||||
"dev": "vite",
|
||||
"dev:server": "bun --watch src/server/index.ts",
|
||||
"dev:all": "bun run dev & bun run dev:server",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "node --test server/**/*.test.mjs",
|
||||
"deploy": "./deploy.sh"
|
||||
"start": "bun run src/server/index.ts",
|
||||
"lint": "biome check .",
|
||||
"lint:fix": "biome check --write .",
|
||||
"test": "vitest run",
|
||||
"db:generate": "bunx drizzle-kit generate",
|
||||
"db:migrate": "bunx drizzle-kit migrate",
|
||||
"prepare": "simple-git-hooks"
|
||||
},
|
||||
"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"
|
||||
"@electric-sql/pglite": "^0.2.17",
|
||||
"@hono/zod-validator": "^0.5.0",
|
||||
"@tanstack/react-form": "^1.0.0",
|
||||
"@tanstack/react-router": "^1.114.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"hono": "^4.7.0",
|
||||
"lucide-react": "^0.474.0",
|
||||
"postgres": "^3.4.8",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"zod": "^3.24.0",
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
"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"
|
||||
"@biomejs/biome": "^1.9.0",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@tanstack/router-plugin": "^1.114.0",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@types/bun": "^1.3.10",
|
||||
"@types/node": "^25.3.3",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.4.0",
|
||||
"drizzle-kit": "^0.31.9",
|
||||
"lint-staged": "^15.0.0",
|
||||
"simple-git-hooks": "^2.11.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^6.2.0",
|
||||
"vite-plugin-pwa": "^0.21.0",
|
||||
"vitest": "^3.0.0",
|
||||
"workbox-window": "^7.0.0"
|
||||
},
|
||||
"simple-git-hooks": {
|
||||
"pre-commit": "bunx lint-staged"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{ts,tsx,js,json}": ["biome check --write"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,5 @@
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
RewriteBase /
|
||||
|
||||
# Don't rewrite files or directories
|
||||
RewriteBase /whattoplay/
|
||||
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>
|
||||
RewriteRule . index.html [L]
|
||||
|
||||
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 +1,4 @@
|
||||
<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 xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="none">
|
||||
<rect width="512" height="512" rx="96" fill="#0f172a"/>
|
||||
<text x="256" y="320" text-anchor="middle" font-family="system-ui" font-size="240" font-weight="bold" fill="#f8fafc">W</text>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 907 B After Width: | Height: | Size: 266 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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
BIN
src/.sync-conflict-20260301-130309-TZ5MTB7.DS_Store
Normal file
BIN
src/.sync-conflict-20260301-130309-TZ5MTB7.DS_Store
Normal file
Binary file not shown.
@@ -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>
|
||||
);
|
||||
}
|
||||
105
src/client/app.css
Normal file
105
src/client/app.css
Normal file
@@ -0,0 +1,105 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--animate-in: enter;
|
||||
--animate-out: exit;
|
||||
}
|
||||
|
||||
@keyframes enter {
|
||||
from {
|
||||
opacity: var(--tw-enter-opacity, 1);
|
||||
transform: translate3d(
|
||||
var(--tw-enter-translate-x, 0),
|
||||
var(--tw-enter-translate-y, 0),
|
||||
0
|
||||
)
|
||||
scale3d(
|
||||
var(--tw-enter-scale, 1),
|
||||
var(--tw-enter-scale, 1),
|
||||
var(--tw-enter-scale, 1)
|
||||
)
|
||||
rotate(var(--tw-enter-rotate, 0));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes exit {
|
||||
to {
|
||||
opacity: var(--tw-exit-opacity, 1);
|
||||
transform: translate3d(
|
||||
var(--tw-exit-translate-x, 0),
|
||||
var(--tw-exit-translate-y, 0),
|
||||
0
|
||||
)
|
||||
scale3d(
|
||||
var(--tw-exit-scale, 1),
|
||||
var(--tw-exit-scale, 1),
|
||||
var(--tw-exit-scale, 1)
|
||||
)
|
||||
rotate(var(--tw-exit-rotate, 0));
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.965 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.965 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.965 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.985 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.3 0 0);
|
||||
--input: oklch(0.3 0 0);
|
||||
--ring: oklch(0.556 0 0);
|
||||
}
|
||||
81
src/client/features/discover/components/card-stack.tsx
Normal file
81
src/client/features/discover/components/card-stack.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { Game } from "@/shared/db/schema"
|
||||
import { useSwipeGesture } from "@/shared/hooks/use-swipe-gesture"
|
||||
import { useNavigate } from "@tanstack/react-router"
|
||||
import { useEffect } from "react"
|
||||
import { GameDiscoverCard } from "./game-discover-card"
|
||||
|
||||
const apiBase = import.meta.env.BASE_URL.replace(/\/$/, "")
|
||||
|
||||
function getSteamHeaderImage(sourceId: string): string {
|
||||
return `https://cdn.akamai.steamstatic.com/steam/apps/${sourceId}/header.jpg`
|
||||
}
|
||||
|
||||
function getPreloadUrl(game: Game): string | null {
|
||||
if (game.cover_image_id)
|
||||
return `${apiBase}/api/igdb/image/${game.cover_image_id}/cover_big`
|
||||
if (game.source === "steam") return getSteamHeaderImage(game.source_id)
|
||||
return null
|
||||
}
|
||||
|
||||
interface CardStackProps {
|
||||
games: Game[]
|
||||
onSwipeLeft: () => void
|
||||
onSwipeRight: () => void
|
||||
}
|
||||
|
||||
export function CardStack({
|
||||
games,
|
||||
onSwipeLeft,
|
||||
onSwipeRight,
|
||||
}: CardStackProps) {
|
||||
const navigate = useNavigate()
|
||||
const topGame = games[0]
|
||||
|
||||
const { offsetX, isDragging, handlers } = useSwipeGesture({
|
||||
threshold: 80,
|
||||
onSwipeLeft,
|
||||
onSwipeRight,
|
||||
onTap: topGame
|
||||
? () => navigate({ to: "/games/$gameId", params: { gameId: topGame.id } })
|
||||
: undefined,
|
||||
})
|
||||
|
||||
// Preload 4th card's image so it's cached before entering the visible stack
|
||||
const preloadGame = games[3]
|
||||
useEffect(() => {
|
||||
const url = preloadGame ? getPreloadUrl(preloadGame) : null
|
||||
if (!url) return
|
||||
const img = new Image()
|
||||
img.src = url
|
||||
}, [preloadGame])
|
||||
|
||||
const visibleCards = games.slice(0, 3)
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
{visibleCards.map((game, i) => {
|
||||
const isTop = i === 0
|
||||
const scale = 1 - i * 0.05
|
||||
const translateY = i * 8
|
||||
|
||||
return (
|
||||
<div
|
||||
key={game.id}
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
transform: isTop
|
||||
? `translateX(${offsetX}px) rotate(${offsetX * 0.05}deg)`
|
||||
: `scale(${scale}) translateY(${translateY}px)`,
|
||||
transition: isDragging && isTop ? "none" : "transform 0.2s ease",
|
||||
zIndex: 3 - i,
|
||||
touchAction: isTop ? "none" : undefined,
|
||||
}}
|
||||
{...(isTop ? handlers : {})}
|
||||
>
|
||||
<GameDiscoverCard game={game} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
21
src/client/features/discover/components/discover-done.tsx
Normal file
21
src/client/features/discover/components/discover-done.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { t } from "@/shared/i18n"
|
||||
|
||||
interface DiscoverDoneProps {
|
||||
seenCount: number
|
||||
onReset: () => void
|
||||
}
|
||||
|
||||
export function DiscoverDone({ seenCount, onReset }: DiscoverDoneProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 py-12 text-center">
|
||||
<h2 className="text-xl font-bold">{t("discover.done.title")}</h2>
|
||||
<p className="text-muted-foreground">
|
||||
{t("discover.done.message")} ({seenCount} reviewed)
|
||||
</p>
|
||||
<Button variant="outline" onClick={onReset}>
|
||||
{t("discover.reset")}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { t } from "@/shared/i18n"
|
||||
|
||||
interface DiscoverProgressProps {
|
||||
progress: number
|
||||
seenCount: number
|
||||
totalCount: number
|
||||
}
|
||||
|
||||
export function DiscoverProgress({
|
||||
progress,
|
||||
seenCount,
|
||||
totalCount,
|
||||
}: DiscoverProgressProps) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>{t("discover.progress")}</span>
|
||||
<span>
|
||||
{seenCount} / {totalCount}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { formatPlaytime, gameStateColors } from "@/features/games/schema"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import type { Game } from "@/shared/db/schema"
|
||||
|
||||
interface GameDiscoverCardProps {
|
||||
game: Game
|
||||
}
|
||||
|
||||
function getSteamHeaderImage(sourceId: string): string {
|
||||
return `https://cdn.akamai.steamstatic.com/steam/apps/${sourceId}/header.jpg`
|
||||
}
|
||||
|
||||
function parseJsonArray(text: string | null): string[] {
|
||||
if (!text) return []
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function GameDiscoverCard({ game }: GameDiscoverCardProps) {
|
||||
const imageUrl =
|
||||
game.source === "steam" ? getSteamHeaderImage(game.source_id) : null
|
||||
const dotColor =
|
||||
game.game_state !== "not_set" ? gameStateColors[game.game_state] : null
|
||||
const genres = parseJsonArray(game.genres).slice(0, 3)
|
||||
const rating =
|
||||
game.aggregated_rating != null ? Math.round(game.aggregated_rating) : null
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden rounded-xl border bg-card shadow-lg">
|
||||
{imageUrl && (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={game.title}
|
||||
className="w-full flex-1 object-cover min-h-0"
|
||||
/>
|
||||
)}
|
||||
<div className="shrink-0 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="truncate text-lg font-bold">{game.title}</h3>
|
||||
<Badge variant="secondary" className="shrink-0">
|
||||
{game.source}
|
||||
</Badge>
|
||||
{dotColor && (
|
||||
<span
|
||||
className={`inline-block h-2.5 w-2.5 shrink-0 rounded-full ${dotColor}`}
|
||||
/>
|
||||
)}
|
||||
{rating != null && (
|
||||
<Badge variant="outline" className="ml-auto shrink-0">
|
||||
{rating}%
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{genres.length > 0 && (
|
||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||
{genres.map((g) => (
|
||||
<Badge key={g} variant="secondary" className="text-[10px]">
|
||||
{g}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{game.summary && (
|
||||
<p className="mt-1.5 line-clamp-2 text-xs text-muted-foreground">
|
||||
{game.summary}
|
||||
</p>
|
||||
)}
|
||||
{!game.summary && game.playtime_hours > 0 && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{formatPlaytime(game.playtime_hours)} played
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
33
src/client/features/discover/components/swipe-buttons.tsx
Normal file
33
src/client/features/discover/components/swipe-buttons.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Check, X } from "lucide-react"
|
||||
|
||||
interface SwipeButtonsProps {
|
||||
onSkip: () => void
|
||||
onLike: () => void
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
export function SwipeButtons({ onSkip, onLike, disabled }: SwipeButtonsProps) {
|
||||
return (
|
||||
<div className="flex justify-center gap-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="size-14 rounded-full border-2 border-red-500 text-red-500"
|
||||
onClick={onSkip}
|
||||
disabled={disabled}
|
||||
>
|
||||
<X className="h-6 w-6" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="size-14 rounded-full border-2 border-green-500 text-green-500"
|
||||
onClick={onLike}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Check className="h-6 w-6" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
74
src/client/features/discover/hooks/use-discover.ts
Normal file
74
src/client/features/discover/hooks/use-discover.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useGames, usePlaylist, usePlaylistMutations } from "@/shared/db/hooks"
|
||||
import type { Game } from "@/shared/db/schema"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { useDiscoverStore } from "../store"
|
||||
|
||||
export function useDiscover() {
|
||||
const { games: allGames } = useGames()
|
||||
const { games: wantToPlayGames, reload: reloadWtp } =
|
||||
usePlaylist("want-to-play")
|
||||
const { games: notIntGames, reload: reloadNi } =
|
||||
usePlaylist("not-interesting")
|
||||
const { addGame } = usePlaylistMutations()
|
||||
const { currentIndex, setCurrentIndex, updateShuffledGames, reset } =
|
||||
useDiscoverStore()
|
||||
const [ready, setReady] = useState(false)
|
||||
const [localSeenIds, setLocalSeenIds] = useState<Set<string>>(new Set())
|
||||
|
||||
const seenIds = useMemo(() => {
|
||||
const ids = new Set<string>()
|
||||
for (const g of wantToPlayGames) ids.add(g.id)
|
||||
for (const g of notIntGames) ids.add(g.id)
|
||||
for (const id of localSeenIds) ids.add(id)
|
||||
return ids
|
||||
}, [wantToPlayGames, notIntGames, localSeenIds])
|
||||
|
||||
const unseenGames = useMemo(
|
||||
() => updateShuffledGames(allGames, seenIds),
|
||||
[allGames, seenIds, updateShuffledGames],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (allGames.length > 0) setReady(true)
|
||||
}, [allGames.length])
|
||||
|
||||
const currentGame: Game | null = unseenGames[currentIndex] ?? null
|
||||
const isDone = ready && unseenGames.length === 0
|
||||
const progress =
|
||||
allGames.length > 0
|
||||
? ((allGames.length - unseenGames.length) / allGames.length) * 100
|
||||
: 0
|
||||
|
||||
const swipeRight = useCallback(() => {
|
||||
if (!currentGame) return
|
||||
setLocalSeenIds((prev) => new Set(prev).add(currentGame.id))
|
||||
addGame("want-to-play", currentGame.id)
|
||||
}, [currentGame, addGame])
|
||||
|
||||
const swipeLeft = useCallback(() => {
|
||||
if (!currentGame) return
|
||||
setLocalSeenIds((prev) => new Set(prev).add(currentGame.id))
|
||||
addGame("not-interesting", currentGame.id)
|
||||
}, [currentGame, addGame])
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
reset()
|
||||
setLocalSeenIds(new Set())
|
||||
reloadWtp()
|
||||
reloadNi()
|
||||
}, [reset, reloadWtp, reloadNi])
|
||||
|
||||
return {
|
||||
currentGame,
|
||||
unseenGames,
|
||||
currentIndex,
|
||||
setCurrentIndex,
|
||||
isDone,
|
||||
progress,
|
||||
totalCount: allGames.length,
|
||||
seenCount: allGames.length - unseenGames.length,
|
||||
swipeRight,
|
||||
swipeLeft,
|
||||
reset: handleReset,
|
||||
}
|
||||
}
|
||||
82
src/client/features/discover/store.ts
Normal file
82
src/client/features/discover/store.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import type { Game } from "@/shared/db/schema"
|
||||
import { create } from "zustand"
|
||||
|
||||
interface DiscoverState {
|
||||
currentIndex: number
|
||||
seed: number
|
||||
animatingDirection: "left" | "right" | null
|
||||
/** Cached shuffled game order (game IDs) */
|
||||
shuffledIds: string[]
|
||||
/** Fingerprint of the input used to produce shuffledIds */
|
||||
shuffleKey: string
|
||||
setCurrentIndex: (index: number) => void
|
||||
setAnimatingDirection: (dir: "left" | "right" | null) => void
|
||||
/** Update the shuffled order only when the underlying game list changes */
|
||||
updateShuffledGames: (games: Game[], seenIds: Set<string>) => Game[]
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
function buildShuffleKey(
|
||||
games: Game[],
|
||||
seenIds: Set<string>,
|
||||
seed: number,
|
||||
): string {
|
||||
return `${games.length}:${seenIds.size}:${seed}`
|
||||
}
|
||||
|
||||
export const useDiscoverStore = create<DiscoverState>((set, get) => ({
|
||||
currentIndex: 0,
|
||||
seed: Math.random(),
|
||||
animatingDirection: null,
|
||||
shuffledIds: [],
|
||||
shuffleKey: "",
|
||||
setCurrentIndex: (currentIndex) => set({ currentIndex }),
|
||||
setAnimatingDirection: (animatingDirection) => set({ animatingDirection }),
|
||||
updateShuffledGames: (games, seenIds) => {
|
||||
const { seed, shuffleKey, shuffledIds } = get()
|
||||
const key = buildShuffleKey(games, seenIds, seed)
|
||||
if (key === shuffleKey && shuffledIds.length > 0) {
|
||||
// Reuse cached order — just resolve IDs back to game objects
|
||||
const byId = new Map(games.map((g) => [g.id, g]))
|
||||
return shuffledIds.flatMap((id) => {
|
||||
if (seenIds.has(id)) return []
|
||||
const g = byId.get(id)
|
||||
return g ? [g] : []
|
||||
})
|
||||
}
|
||||
const unseen = games.filter((g) => !seenIds.has(g.id))
|
||||
const shuffled = seededShuffle(unseen, seed)
|
||||
set({ shuffledIds: shuffled.map((g) => g.id), shuffleKey: key })
|
||||
return shuffled
|
||||
},
|
||||
reset: () =>
|
||||
set({
|
||||
currentIndex: 0,
|
||||
seed: Math.random(),
|
||||
animatingDirection: null,
|
||||
shuffledIds: [],
|
||||
shuffleKey: "",
|
||||
}),
|
||||
}))
|
||||
|
||||
/** Mulberry32 — fast seeded 32-bit PRNG */
|
||||
export function mulberry32(seed: number): () => number {
|
||||
let s = seed | 0 || 1
|
||||
return () => {
|
||||
s = (s + 0x6d2b79f5) | 0
|
||||
let t = Math.imul(s ^ (s >>> 15), 1 | s)
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
/** Fisher-Yates shuffle using a seeded PRNG */
|
||||
export function seededShuffle<T>(arr: readonly T[], seed: number): T[] {
|
||||
const result = arr.slice()
|
||||
const rng = mulberry32(Math.floor(seed * 2147483647))
|
||||
for (let i = result.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(rng() * (i + 1))
|
||||
;[result[i], result[j]] = [result[j], result[i]]
|
||||
}
|
||||
return result
|
||||
}
|
||||
42
src/client/features/games/components/favorite-button.tsx
Normal file
42
src/client/features/games/components/favorite-button.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { usePlaylistMutations, useUpdateGame } from "@/shared/db/hooks"
|
||||
import { Heart } from "lucide-react"
|
||||
import { useCallback } from "react"
|
||||
|
||||
interface FavoriteButtonProps {
|
||||
gameId: string
|
||||
isFavorite: boolean
|
||||
onChange?: () => void
|
||||
}
|
||||
|
||||
export function FavoriteButton({
|
||||
gameId,
|
||||
isFavorite,
|
||||
onChange,
|
||||
}: FavoriteButtonProps) {
|
||||
const updateGame = useUpdateGame()
|
||||
const { addGame, removeGame } = usePlaylistMutations()
|
||||
|
||||
const toggle = useCallback(async () => {
|
||||
const newVal = !isFavorite
|
||||
await updateGame(gameId, { is_favorite: newVal })
|
||||
if (newVal) {
|
||||
await addGame("favorites", gameId)
|
||||
} else {
|
||||
await removeGame("favorites", gameId)
|
||||
}
|
||||
onChange?.()
|
||||
}, [gameId, isFavorite, updateGame, addGame, removeGame, onChange])
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
className="transition-colors"
|
||||
aria-label={isFavorite ? "Remove from favorites" : "Add to favorites"}
|
||||
>
|
||||
<Heart
|
||||
className={`h-5 w-5 ${isFavorite ? "fill-red-500 text-red-500" : "text-muted-foreground"}`}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
54
src/client/features/games/components/game-card.tsx
Normal file
54
src/client/features/games/components/game-card.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Card, CardContent } from "@/shared/components/ui/card"
|
||||
import type { Game } from "@/shared/db/schema"
|
||||
import { formatPlaytime } from "../schema"
|
||||
import { FavoriteButton } from "./favorite-button"
|
||||
import { GameStateSelect } from "./game-state-select"
|
||||
import { StarRating } from "./star-rating"
|
||||
|
||||
interface GameCardProps {
|
||||
game: Game
|
||||
onUpdate?: () => void
|
||||
}
|
||||
|
||||
export function GameCard({ game, onUpdate }: GameCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="truncate font-medium">{game.title}</h3>
|
||||
<Badge variant="secondary" className="shrink-0 text-xs">
|
||||
{game.source}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-3 text-xs text-muted-foreground">
|
||||
{game.playtime_hours > 0 && (
|
||||
<span>{formatPlaytime(game.playtime_hours)}</span>
|
||||
)}
|
||||
{game.last_played && <span>Last: {game.last_played}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<FavoriteButton
|
||||
gameId={game.id}
|
||||
isFavorite={game.is_favorite}
|
||||
onChange={onUpdate}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<StarRating
|
||||
gameId={game.id}
|
||||
rating={game.rating}
|
||||
onChange={onUpdate}
|
||||
/>
|
||||
<GameStateSelect
|
||||
gameId={game.id}
|
||||
state={game.game_state}
|
||||
onChange={onUpdate}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
179
src/client/features/games/components/game-detail.tsx
Normal file
179
src/client/features/games/components/game-detail.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { useGame } from "@/shared/db/hooks"
|
||||
import type { Game } from "@/shared/db/schema"
|
||||
import { t } from "@/shared/i18n"
|
||||
import { ExternalLink } from "lucide-react"
|
||||
import { formatPlaytime } from "../schema"
|
||||
import { FavoriteButton } from "./favorite-button"
|
||||
import { GameStateSelect } from "./game-state-select"
|
||||
import { StarRating } from "./star-rating"
|
||||
|
||||
const apiBase = import.meta.env.BASE_URL.replace(/\/$/, "")
|
||||
|
||||
function getSteamHeaderImage(sourceId: string): string {
|
||||
return `https://cdn.akamai.steamstatic.com/steam/apps/${sourceId}/header.jpg`
|
||||
}
|
||||
|
||||
function parseJsonArray(text: string | null): string[] {
|
||||
if (!text) return []
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
interface GameDetailProps {
|
||||
gameId: string
|
||||
}
|
||||
|
||||
export function GameDetail({ gameId }: GameDetailProps) {
|
||||
const { game, loading, reload } = useGame(gameId)
|
||||
|
||||
if (loading) return null
|
||||
|
||||
if (!game) {
|
||||
return (
|
||||
<p className="py-8 text-center text-muted-foreground">
|
||||
{t("game.notFound")}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return <GameDetailContent game={game} onUpdate={reload} />
|
||||
}
|
||||
|
||||
function GameDetailContent({
|
||||
game,
|
||||
onUpdate,
|
||||
}: { game: Game; onUpdate: () => void }) {
|
||||
const imageUrl =
|
||||
game.source === "steam" ? getSteamHeaderImage(game.source_id) : null
|
||||
const genres = parseJsonArray(game.genres)
|
||||
const developers = parseJsonArray(game.developers)
|
||||
const screenshots = parseJsonArray(game.screenshots)
|
||||
const videoIds = parseJsonArray(game.video_ids)
|
||||
const rating =
|
||||
game.aggregated_rating != null ? Math.round(game.aggregated_rating) : null
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{imageUrl && (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={game.title}
|
||||
className="w-full rounded-xl object-cover aspect-video"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-xl font-bold">{game.title}</h2>
|
||||
<Badge variant="secondary" className="shrink-0">
|
||||
{game.source}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-3 text-sm text-muted-foreground">
|
||||
{game.playtime_hours > 0 && (
|
||||
<span>{formatPlaytime(game.playtime_hours)}</span>
|
||||
)}
|
||||
{game.last_played && (
|
||||
<span>
|
||||
{t("game.lastPlayed")}: {game.last_played}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<FavoriteButton
|
||||
gameId={game.id}
|
||||
isFavorite={game.is_favorite}
|
||||
onChange={onUpdate}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{genres.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{genres.map((g) => (
|
||||
<Badge key={g} variant="secondary">
|
||||
{g}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<StarRating
|
||||
gameId={game.id}
|
||||
rating={game.rating}
|
||||
onChange={onUpdate}
|
||||
/>
|
||||
{rating != null && <Badge variant="outline">{rating}%</Badge>}
|
||||
</div>
|
||||
<GameStateSelect
|
||||
gameId={game.id}
|
||||
state={game.game_state}
|
||||
onChange={onUpdate}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(developers.length > 0 || game.release_date) && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{developers.length > 0 && <>by {developers.join(", ")}</>}
|
||||
{developers.length > 0 && game.release_date && " · "}
|
||||
{game.release_date && <>{game.release_date}</>}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{game.url && (
|
||||
<a
|
||||
href={game.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 rounded-lg border px-4 py-2 text-sm hover:bg-muted"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
{t("game.openStore")}
|
||||
</a>
|
||||
)}
|
||||
|
||||
{game.summary && (
|
||||
<div>
|
||||
<h3 className="mb-1 text-sm font-semibold">{t("game.summary")}</h3>
|
||||
<p className="text-sm text-muted-foreground">{game.summary}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{screenshots.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-semibold">
|
||||
{t("game.screenshots")}
|
||||
</h3>
|
||||
<div className="flex snap-x gap-2 overflow-x-auto pb-2">
|
||||
{screenshots.map((id) => (
|
||||
<img
|
||||
key={id}
|
||||
src={`${apiBase}/api/igdb/image/${id}/screenshot_med`}
|
||||
alt=""
|
||||
className="h-40 shrink-0 snap-start rounded-lg object-cover"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{videoIds.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-semibold">{t("game.trailer")}</h3>
|
||||
<iframe
|
||||
src={`https://www.youtube-nocookie.com/embed/${videoIds[0]}`}
|
||||
className="aspect-video w-full rounded-lg"
|
||||
allowFullScreen
|
||||
title={t("game.trailer")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
63
src/client/features/games/components/game-list-item.tsx
Normal file
63
src/client/features/games/components/game-list-item.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { ListItem } from "@/shared/components/ui/list-item"
|
||||
import type { Game } from "@/shared/db/schema"
|
||||
import { formatPlaytime } from "../schema"
|
||||
import { gameStateColors } from "../schema"
|
||||
|
||||
const apiBase = import.meta.env.BASE_URL.replace(/\/$/, "")
|
||||
|
||||
interface GameListItemProps {
|
||||
game: Game
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
export function GameListItem({ game, onClick }: GameListItemProps) {
|
||||
const coverUrl = game.cover_image_id
|
||||
? `${apiBase}/api/igdb/image/${game.cover_image_id}/thumb`
|
||||
: game.source === "steam"
|
||||
? `${apiBase}/api/steam/icon/${game.source_id}`
|
||||
: undefined
|
||||
|
||||
const imgClass = game.cover_image_id
|
||||
? "h-10 w-10 rounded object-cover"
|
||||
: "h-10 w-16 rounded object-cover"
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
link
|
||||
title={game.title}
|
||||
subtitle={
|
||||
game.playtime_hours > 0
|
||||
? formatPlaytime(game.playtime_hours)
|
||||
: undefined
|
||||
}
|
||||
media={
|
||||
coverUrl ? (
|
||||
<img src={coverUrl} alt="" className={imgClass} />
|
||||
) : (
|
||||
<span className="flex h-10 w-10 items-center justify-center rounded bg-muted text-[10px] font-medium text-muted-foreground">
|
||||
{game.source.toUpperCase()}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
after={<GameListItemAfter game={game} />}
|
||||
onClick={onClick}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function GameListItemAfter({ game }: { game: Game }) {
|
||||
const ratingText =
|
||||
game.rating >= 0 ? `★ ${Math.round(game.rating / 2)}/5` : null
|
||||
const dotColor =
|
||||
game.game_state !== "not_set" ? gameStateColors[game.game_state] : null
|
||||
|
||||
return (
|
||||
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{ratingText && <span>{ratingText}</span>}
|
||||
{dotColor && (
|
||||
<span className={`inline-block h-2 w-2 rounded-full ${dotColor}`} />
|
||||
)}
|
||||
{game.is_favorite && <span className="text-red-500">♥</span>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
41
src/client/features/games/components/game-state-select.tsx
Normal file
41
src/client/features/games/components/game-state-select.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useUpdateGame } from "@/shared/db/hooks"
|
||||
import type { GameState } from "@/shared/db/schema"
|
||||
import { gameStateColors, gameStateLabels } from "../schema"
|
||||
|
||||
interface GameStateSelectProps {
|
||||
gameId: string
|
||||
state: GameState
|
||||
onChange?: () => void
|
||||
}
|
||||
|
||||
const states = Object.keys(gameStateLabels) as GameState[]
|
||||
|
||||
export function GameStateSelect({
|
||||
gameId,
|
||||
state,
|
||||
onChange,
|
||||
}: GameStateSelectProps) {
|
||||
const updateGame = useUpdateGame()
|
||||
|
||||
const handleChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
await updateGame(gameId, { game_state: e.target.value as GameState })
|
||||
onChange?.()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`h-2 w-2 rounded-full ${gameStateColors[state]}`} />
|
||||
<select
|
||||
value={state}
|
||||
onChange={handleChange}
|
||||
className="h-8 rounded-lg border border-gray-300 bg-transparent px-2 text-xs"
|
||||
>
|
||||
{states.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{gameStateLabels[s]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
70
src/client/features/games/components/star-rating.tsx
Normal file
70
src/client/features/games/components/star-rating.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useUpdateGame } from "@/shared/db/hooks"
|
||||
import { Star } from "lucide-react"
|
||||
|
||||
interface StarRatingProps {
|
||||
gameId: string
|
||||
rating: number
|
||||
onChange?: () => void
|
||||
}
|
||||
|
||||
export function StarRating({ gameId, rating, onChange }: StarRatingProps) {
|
||||
const updateGame = useUpdateGame()
|
||||
const stars = rating < 0 ? 0 : rating / 2
|
||||
|
||||
const handleClick = async (starIndex: number, isHalf: boolean) => {
|
||||
const newRating = isHalf ? starIndex * 2 - 1 : starIndex * 2
|
||||
const finalRating = newRating === rating ? -1 : newRating
|
||||
await updateGame(gameId, { rating: finalRating })
|
||||
onChange?.()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-0.5">
|
||||
{[1, 2, 3, 4, 5].map((i) => {
|
||||
const filled = stars >= i
|
||||
const halfFilled = !filled && stars >= i - 0.5
|
||||
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
className="relative h-5 w-5 text-yellow-500"
|
||||
aria-label={`Rate ${i} stars`}
|
||||
>
|
||||
{/* left half click */}
|
||||
<span
|
||||
className="absolute inset-y-0 left-0 w-1/2 cursor-pointer"
|
||||
onClick={() => handleClick(i, true)}
|
||||
onKeyDown={() => {}}
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
/>
|
||||
{/* right half click */}
|
||||
<span
|
||||
className="absolute inset-y-0 right-0 w-1/2 cursor-pointer"
|
||||
onClick={() => handleClick(i, false)}
|
||||
onKeyDown={() => {}}
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
/>
|
||||
{filled ? (
|
||||
<Star className="h-5 w-5 fill-current" />
|
||||
) : halfFilled ? (
|
||||
<div className="relative">
|
||||
<Star className="h-5 w-5 text-muted-foreground/30" />
|
||||
<div
|
||||
className="absolute inset-0 overflow-hidden"
|
||||
style={{ width: "50%" }}
|
||||
>
|
||||
<Star className="h-5 w-5 fill-current" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Star className="h-5 w-5 text-muted-foreground/30" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
36
src/client/features/games/schema.ts
Normal file
36
src/client/features/games/schema.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { GameState } from "@/shared/db/schema"
|
||||
|
||||
export const gameStateLabels: Record<GameState, string> = {
|
||||
not_set: "Not Set",
|
||||
wishlisted: "Wishlisted",
|
||||
playlisted: "Playlisted",
|
||||
playing: "Playing",
|
||||
finished: "Finished",
|
||||
perfected: "Perfected",
|
||||
abandoned: "Abandoned",
|
||||
bad_game: "Bad Game",
|
||||
}
|
||||
|
||||
export const gameStateColors: Record<GameState, string> = {
|
||||
not_set: "bg-gray-400",
|
||||
wishlisted: "bg-purple-500",
|
||||
playlisted: "bg-blue-500",
|
||||
playing: "bg-green-500",
|
||||
finished: "bg-emerald-600",
|
||||
perfected: "bg-yellow-500",
|
||||
abandoned: "bg-red-500",
|
||||
bad_game: "bg-red-700",
|
||||
}
|
||||
|
||||
export function formatRating(rating: number): string {
|
||||
if (rating < 0) return "Unrated"
|
||||
const stars = rating / 2
|
||||
const full = Math.floor(stars)
|
||||
const half = stars % 1 >= 0.5 ? "½" : ""
|
||||
return `${"★".repeat(full)}${half}${"☆".repeat(5 - full - (half ? 1 : 0))}`
|
||||
}
|
||||
|
||||
export function formatPlaytime(hours: number): string {
|
||||
if (hours < 1) return `${Math.round(hours * 60)}m`
|
||||
return `${hours.toFixed(1)}h`
|
||||
}
|
||||
28
src/client/features/library/components/library-header.tsx
Normal file
28
src/client/features/library/components/library-header.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { formatPlaytime } from "@/features/games/schema"
|
||||
import { t } from "@/shared/i18n"
|
||||
|
||||
interface LibraryHeaderProps {
|
||||
totalCount: number
|
||||
totalPlaytime: number
|
||||
}
|
||||
|
||||
export function LibraryHeader({
|
||||
totalCount,
|
||||
totalPlaytime,
|
||||
}: LibraryHeaderProps) {
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<h1 className="text-2xl font-bold">{t("library.title")}</h1>
|
||||
<div className="mt-1 flex gap-4 text-sm text-muted-foreground">
|
||||
<span>
|
||||
{totalCount} {t("library.games")}
|
||||
</span>
|
||||
{totalPlaytime > 0 && (
|
||||
<span>
|
||||
{formatPlaytime(totalPlaytime)} {t("library.hours")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
56
src/client/features/library/components/library-list.tsx
Normal file
56
src/client/features/library/components/library-list.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { GameListItem } from "@/features/games/components/game-list-item"
|
||||
import type { Game } from "@/shared/db/schema"
|
||||
import { t } from "@/shared/i18n"
|
||||
import { useNavigate } from "@tanstack/react-router"
|
||||
import { useEffect, useRef } from "react"
|
||||
|
||||
interface LibraryListProps {
|
||||
games: Game[]
|
||||
hasMore: boolean
|
||||
loadMore: () => void
|
||||
onUpdate: () => void
|
||||
}
|
||||
|
||||
export function LibraryList({ games, hasMore, loadMore }: LibraryListProps) {
|
||||
const sentinelRef = useRef<HTMLDivElement>(null)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasMore || !sentinelRef.current) return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting) loadMore()
|
||||
},
|
||||
{ threshold: 0.1 },
|
||||
)
|
||||
|
||||
observer.observe(sentinelRef.current)
|
||||
return () => observer.disconnect()
|
||||
}, [hasMore, loadMore])
|
||||
|
||||
if (games.length === 0) {
|
||||
return (
|
||||
<p className="py-8 text-center text-muted-foreground">
|
||||
{t("library.empty")}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="divide-y rounded-lg border bg-card">
|
||||
{games.map((game) => (
|
||||
<GameListItem
|
||||
key={game.id}
|
||||
game={game}
|
||||
onClick={() =>
|
||||
navigate({ to: "/games/$gameId", params: { gameId: game.id } })
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{hasMore && <div ref={sentinelRef} className="h-8" />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
51
src/client/features/library/components/library-search.tsx
Normal file
51
src/client/features/library/components/library-search.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { t } from "@/shared/i18n"
|
||||
import { useUiStore } from "@/shared/stores/ui-store"
|
||||
import { ArrowDownAZ, ArrowUpAZ } from "lucide-react"
|
||||
import { startTransition } from "react"
|
||||
|
||||
export function LibrarySearch() {
|
||||
const {
|
||||
searchText,
|
||||
setSearchText,
|
||||
sortBy,
|
||||
setSortBy,
|
||||
sortDirection,
|
||||
toggleSortDirection,
|
||||
} = useUiStore()
|
||||
|
||||
return (
|
||||
<div className="mb-4 flex gap-2">
|
||||
<input
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
placeholder={t("library.search")}
|
||||
className="flex-1 rounded-lg border border-input bg-transparent px-3 py-2 text-base"
|
||||
/>
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) =>
|
||||
startTransition(() =>
|
||||
setSortBy(e.target.value as "title" | "playtime" | "lastPlayed"),
|
||||
)
|
||||
}
|
||||
className="w-32 rounded-lg border border-input bg-transparent px-2 py-2 text-sm"
|
||||
>
|
||||
<option value="title">{t("library.sort.title")}</option>
|
||||
<option value="playtime">{t("library.sort.playtime")}</option>
|
||||
<option value="lastPlayed">{t("library.sort.lastPlayed")}</option>
|
||||
</select>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => startTransition(() => toggleSortDirection())}
|
||||
>
|
||||
{sortDirection === "asc" ? (
|
||||
<ArrowDownAZ className="h-4 w-4" />
|
||||
) : (
|
||||
<ArrowUpAZ className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
104
src/client/features/library/hooks/use-library.ts
Normal file
104
src/client/features/library/hooks/use-library.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { useGames } from "@/shared/db/hooks"
|
||||
import type { Game } from "@/shared/db/schema"
|
||||
import { useUiStore } from "@/shared/stores/ui-store"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
|
||||
function normalizeTitle(title: string): string {
|
||||
return title.toLowerCase().replace(/[^a-z0-9]/g, "")
|
||||
}
|
||||
|
||||
function mergeGames(games: Game[]): Game[] {
|
||||
const merged = new Map<string, Game>()
|
||||
|
||||
for (const game of games) {
|
||||
const key = game.canonical_id ?? `title:${normalizeTitle(game.title)}`
|
||||
const existing = merged.get(key)
|
||||
|
||||
if (existing) {
|
||||
merged.set(key, {
|
||||
...existing,
|
||||
playtime_hours: existing.playtime_hours + game.playtime_hours,
|
||||
last_played:
|
||||
existing.last_played && game.last_played
|
||||
? existing.last_played > game.last_played
|
||||
? existing.last_played
|
||||
: game.last_played
|
||||
: existing.last_played || game.last_played,
|
||||
rating: existing.rating >= 0 ? existing.rating : game.rating,
|
||||
game_state:
|
||||
existing.game_state !== "not_set"
|
||||
? existing.game_state
|
||||
: game.game_state,
|
||||
is_favorite: existing.is_favorite || game.is_favorite,
|
||||
})
|
||||
} else {
|
||||
merged.set(key, { ...game })
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(merged.values())
|
||||
}
|
||||
|
||||
const INITIAL_BATCH = 50
|
||||
const BATCH_SIZE = 50
|
||||
|
||||
export function useLibrary() {
|
||||
const { games: allGames, loading, reload } = useGames()
|
||||
const { searchText, sortBy, sortDirection } = useUiStore()
|
||||
const [visibleCount, setVisibleCount] = useState(INITIAL_BATCH)
|
||||
|
||||
const merged = useMemo(() => mergeGames(allGames), [allGames])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const result = searchText
|
||||
? merged.filter((g) =>
|
||||
g.title.toLowerCase().includes(searchText.toLowerCase()),
|
||||
)
|
||||
: merged.slice()
|
||||
result.sort((a, b) => {
|
||||
const dir = sortDirection === "asc" ? 1 : -1
|
||||
switch (sortBy) {
|
||||
case "playtime":
|
||||
return (b.playtime_hours - a.playtime_hours) * dir
|
||||
case "lastPlayed": {
|
||||
const aDate = a.last_played ?? ""
|
||||
const bDate = b.last_played ?? ""
|
||||
return bDate.localeCompare(aDate) * dir
|
||||
}
|
||||
default:
|
||||
return a.title.localeCompare(b.title) * dir
|
||||
}
|
||||
})
|
||||
return result
|
||||
}, [merged, searchText, sortBy, sortDirection])
|
||||
|
||||
const visible = useMemo(
|
||||
() => filtered.slice(0, visibleCount),
|
||||
[filtered, visibleCount],
|
||||
)
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
setVisibleCount((c) => Math.min(c + BATCH_SIZE, filtered.length))
|
||||
}, [filtered.length])
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional reset when filters change
|
||||
useEffect(() => {
|
||||
setVisibleCount(INITIAL_BATCH)
|
||||
}, [searchText, sortBy, sortDirection])
|
||||
|
||||
const totalPlaytime = useMemo(
|
||||
() => merged.reduce((sum, g) => sum + g.playtime_hours, 0),
|
||||
[merged],
|
||||
)
|
||||
|
||||
return {
|
||||
games: visible,
|
||||
totalCount: merged.length,
|
||||
filteredCount: filtered.length,
|
||||
totalPlaytime,
|
||||
hasMore: visibleCount < filtered.length,
|
||||
loadMore,
|
||||
loading,
|
||||
reload,
|
||||
}
|
||||
}
|
||||
145
src/client/features/playlists/components/playlist-detail.tsx
Normal file
145
src/client/features/playlists/components/playlist-detail.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import { GameListItem } from "@/features/games/components/game-list-item"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { t } from "@/shared/i18n"
|
||||
import { useNavigate } from "@tanstack/react-router"
|
||||
import { Plus, Trash2 } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { usePlaylistDetail } from "../hooks/use-playlist-detail"
|
||||
|
||||
interface PlaylistDetailProps {
|
||||
playlistId: string
|
||||
}
|
||||
|
||||
export function PlaylistDetail({ playlistId }: PlaylistDetailProps) {
|
||||
const {
|
||||
playlist,
|
||||
games,
|
||||
loading,
|
||||
reload,
|
||||
searchText,
|
||||
setSearchText,
|
||||
searchResults,
|
||||
addGame,
|
||||
removeGame,
|
||||
rename,
|
||||
deletePlaylist,
|
||||
} = usePlaylistDetail(playlistId)
|
||||
const navigate = useNavigate()
|
||||
const [editingName, setEditingName] = useState(false)
|
||||
const [name, setName] = useState("")
|
||||
|
||||
if (loading || !playlist) return null
|
||||
|
||||
const isCustom = !playlist.is_static
|
||||
|
||||
const handleStartRename = () => {
|
||||
setName(playlist.name)
|
||||
setEditingName(true)
|
||||
}
|
||||
|
||||
const handleFinishRename = () => {
|
||||
if (name.trim() && name !== playlist.name) {
|
||||
rename(name.trim())
|
||||
}
|
||||
setEditingName(false)
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!window.confirm(`Delete "${playlist.name}"?`)) return
|
||||
await deletePlaylist()
|
||||
navigate({ to: "/playlists" })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
{editingName ? (
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onBlur={handleFinishRename}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleFinishRename()}
|
||||
// biome-ignore lint/a11y/noAutofocus: intentional focus on inline rename
|
||||
autoFocus
|
||||
className="flex-1 rounded-lg border border-input bg-transparent px-3 py-2 text-xl font-bold"
|
||||
/>
|
||||
) : (
|
||||
<h2
|
||||
className={`text-xl font-bold ${isCustom ? "cursor-pointer" : ""}`}
|
||||
onClick={isCustom ? handleStartRename : undefined}
|
||||
onKeyDown={undefined}
|
||||
role={isCustom ? "button" : undefined}
|
||||
tabIndex={isCustom ? 0 : undefined}
|
||||
>
|
||||
{playlist.name}
|
||||
</h2>
|
||||
)}
|
||||
{isCustom && (
|
||||
<Button variant="ghost" onClick={handleDelete}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<input
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
placeholder={t("playlists.addGames")}
|
||||
className="w-full rounded-lg border border-input bg-transparent px-3 py-2 text-sm"
|
||||
/>
|
||||
{searchResults.length > 0 && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{searchResults.map((game) => (
|
||||
<button
|
||||
key={game.id}
|
||||
type="button"
|
||||
className="flex w-full cursor-pointer items-center justify-between rounded-lg border p-3"
|
||||
onClick={() => {
|
||||
addGame(game)
|
||||
setSearchText("")
|
||||
}}
|
||||
>
|
||||
<span className="truncate text-sm">{game.title}</span>
|
||||
<Plus className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{games.length === 0 ? (
|
||||
<div>
|
||||
<p className="py-4 text-center text-muted-foreground">
|
||||
{t("playlists.noGames")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y rounded-lg border bg-card">
|
||||
{games.map((game) => (
|
||||
<div key={game.id} className="flex items-center">
|
||||
<div className="min-w-0 flex-1">
|
||||
<GameListItem
|
||||
game={game}
|
||||
onClick={() =>
|
||||
navigate({
|
||||
to: "/games/$gameId",
|
||||
params: { gameId: game.id },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="shrink-0"
|
||||
onClick={() => removeGame(game.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
101
src/client/features/playlists/components/playlists-list.tsx
Normal file
101
src/client/features/playlists/components/playlists-list.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { ListItem } from "@/shared/components/ui/list-item"
|
||||
import { usePlaylistMutations, usePlaylists } from "@/shared/db/hooks"
|
||||
import { t } from "@/shared/i18n"
|
||||
import { useNavigate } from "@tanstack/react-router"
|
||||
import { Heart, ListMusic, ThumbsDown, ThumbsUp, Trash2 } from "lucide-react"
|
||||
|
||||
const staticIcons: Record<
|
||||
string,
|
||||
React.ComponentType<{ className?: string }>
|
||||
> = {
|
||||
favorites: Heart,
|
||||
"want-to-play": ThumbsUp,
|
||||
"not-interesting": ThumbsDown,
|
||||
}
|
||||
|
||||
export function PlaylistsList() {
|
||||
const { playlists, reload } = usePlaylists()
|
||||
const { createPlaylist, deletePlaylist } = usePlaylistMutations()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleCreate = async () => {
|
||||
await createPlaylist("New Playlist")
|
||||
reload()
|
||||
}
|
||||
|
||||
const handleDelete = async (e: React.MouseEvent, id: string) => {
|
||||
e.stopPropagation()
|
||||
await deletePlaylist(id)
|
||||
reload()
|
||||
}
|
||||
|
||||
const staticPlaylists = playlists.filter((p) => p.is_static)
|
||||
const customPlaylists = playlists.filter((p) => !p.is_static)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="divide-y rounded-lg border bg-card">
|
||||
{staticPlaylists.map((p) => {
|
||||
const Icon = staticIcons[p.id] ?? ListMusic
|
||||
return (
|
||||
<ListItem
|
||||
key={p.id}
|
||||
link
|
||||
title={p.name}
|
||||
media={<Icon className="h-5 w-5 text-muted-foreground" />}
|
||||
after={
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{p.game_count}
|
||||
</span>
|
||||
}
|
||||
onClick={() =>
|
||||
navigate({
|
||||
to: "/playlists/$playlistId",
|
||||
params: { playlistId: p.id },
|
||||
})
|
||||
}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{customPlaylists.length > 0 && (
|
||||
<div className="mt-4 divide-y rounded-lg border bg-card">
|
||||
{customPlaylists.map((p) => (
|
||||
<ListItem
|
||||
key={p.id}
|
||||
link
|
||||
title={p.name}
|
||||
media={<ListMusic className="h-5 w-5 text-muted-foreground" />}
|
||||
after={
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{p.game_count}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleDelete(e, p.id)}
|
||||
className="p-1 text-muted-foreground"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
onClick={() =>
|
||||
navigate({
|
||||
to: "/playlists/$playlistId",
|
||||
params: { playlistId: p.id },
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4">
|
||||
<Button onClick={handleCreate}>{t("playlists.create")}</Button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
63
src/client/features/playlists/hooks/use-playlist-detail.ts
Normal file
63
src/client/features/playlists/hooks/use-playlist-detail.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { useGames, usePlaylist, usePlaylistMutations } from "@/shared/db/hooks"
|
||||
import type { Game } from "@/shared/db/schema"
|
||||
import { useCallback, useMemo, useState } from "react"
|
||||
|
||||
export function usePlaylistDetail(id: string) {
|
||||
const { playlist, games, loading, reload } = usePlaylist(id)
|
||||
const { addGame, removeGame, renamePlaylist, deletePlaylist } =
|
||||
usePlaylistMutations()
|
||||
const { games: allGames } = useGames()
|
||||
const [searchText, setSearchText] = useState("")
|
||||
|
||||
const gameIds = useMemo(() => new Set(games.map((g) => g.id)), [games])
|
||||
|
||||
const searchResults = useMemo(() => {
|
||||
if (!searchText) return []
|
||||
const q = searchText.toLowerCase()
|
||||
return allGames
|
||||
.filter((g) => !gameIds.has(g.id) && g.title.toLowerCase().includes(q))
|
||||
.slice(0, 20)
|
||||
}, [allGames, gameIds, searchText])
|
||||
|
||||
const handleAddGame = useCallback(
|
||||
async (game: Game) => {
|
||||
await addGame(id, game.id)
|
||||
reload()
|
||||
},
|
||||
[id, addGame, reload],
|
||||
)
|
||||
|
||||
const handleRemoveGame = useCallback(
|
||||
async (gameId: string) => {
|
||||
await removeGame(id, gameId)
|
||||
reload()
|
||||
},
|
||||
[id, removeGame, reload],
|
||||
)
|
||||
|
||||
const handleRename = useCallback(
|
||||
async (name: string) => {
|
||||
await renamePlaylist(id, name)
|
||||
reload()
|
||||
},
|
||||
[id, renamePlaylist, reload],
|
||||
)
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
await deletePlaylist(id)
|
||||
}, [id, deletePlaylist])
|
||||
|
||||
return {
|
||||
playlist,
|
||||
games,
|
||||
loading,
|
||||
reload,
|
||||
searchText,
|
||||
setSearchText,
|
||||
searchResults,
|
||||
addGame: handleAddGame,
|
||||
removeGame: handleRemoveGame,
|
||||
rename: handleRename,
|
||||
deletePlaylist: handleDelete,
|
||||
}
|
||||
}
|
||||
1
src/client/features/playlists/hooks/use-playlists.ts
Normal file
1
src/client/features/playlists/hooks/use-playlists.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { usePlaylists } from "@/shared/db/hooks"
|
||||
113
src/client/features/settings/components/data-settings.tsx
Normal file
113
src/client/features/settings/components/data-settings.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
import { ListItem } from "@/shared/components/ui/list-item"
|
||||
import { t } from "@/shared/i18n"
|
||||
import { useRef, useState } from "react"
|
||||
import { useDataManagement } from "../hooks/use-data-management"
|
||||
|
||||
export function DataSettings() {
|
||||
const { exportData, importData, clearAll } = useDataManagement()
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const [status, setStatus] = useState<string | null>(null)
|
||||
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||
|
||||
const handleImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
try {
|
||||
await importData(file)
|
||||
setStatus("Import complete")
|
||||
} catch {
|
||||
setStatus("Import failed")
|
||||
}
|
||||
}
|
||||
|
||||
const handleClear = async () => {
|
||||
setConfirmOpen(false)
|
||||
await clearAll()
|
||||
setStatus("All data cleared")
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="divide-y rounded-lg border bg-card">
|
||||
<ListItem
|
||||
title={t("settings.data.export")}
|
||||
after={
|
||||
<Button size="sm" variant="outline" onClick={exportData}>
|
||||
{t("settings.data.export")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<ListItem
|
||||
title={t("settings.data.import")}
|
||||
after={
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
>
|
||||
{t("settings.data.import")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleImport}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<div className="mt-4 divide-y rounded-lg border bg-card">
|
||||
<ListItem
|
||||
title={t("settings.data.clear")}
|
||||
after={
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-red-500"
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
>
|
||||
{t("settings.data.clear")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{status && (
|
||||
<div className="mt-4">
|
||||
<p className="text-sm text-muted-foreground">{status}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<DialogContent showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("settings.data.clear")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("settings.data.clearConfirm")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setConfirmOpen(false)}>
|
||||
{t("general.cancel")}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleClear}>
|
||||
{t("general.confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
129
src/client/features/settings/components/gog-settings.tsx
Normal file
129
src/client/features/settings/components/gog-settings.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { SyncProgress } from "@/shared/components/sync-progress"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { ListItem } from "@/shared/components/ui/list-item"
|
||||
import { useConfig, useSaveConfig } from "@/shared/db/hooks"
|
||||
import { t } from "@/shared/i18n"
|
||||
import { useSyncStore } from "@/shared/stores/sync-store"
|
||||
import { useState } from "react"
|
||||
|
||||
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"
|
||||
|
||||
export function GogSettings() {
|
||||
const gogConfig = useConfig<{
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
userId: string
|
||||
}>("gog")
|
||||
const saveConfig = useSaveConfig()
|
||||
const lastSync = useConfig<string>("gog_last_sync")
|
||||
const { syncing, error, lastCount, progress } = useSyncStore((s) => s.gog)
|
||||
const connectGog = useSyncStore((s) => s.connectGog)
|
||||
const syncGogGames = useSyncStore((s) => s.syncGogGames)
|
||||
|
||||
const [code, setCode] = useState("")
|
||||
const isConnected = Boolean(gogConfig?.accessToken)
|
||||
|
||||
const handleConnect = async () => {
|
||||
const tokens = await connectGog(code)
|
||||
if (tokens) {
|
||||
setCode("")
|
||||
}
|
||||
}
|
||||
|
||||
const handleSync = () => {
|
||||
if (gogConfig) {
|
||||
syncGogGames(gogConfig.accessToken, gogConfig.refreshToken)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDisconnect = async () => {
|
||||
await saveConfig("gog", null)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isConnected ? (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<ol className="list-inside list-decimal space-y-1 text-sm text-muted-foreground">
|
||||
<li>Open the GOG login page below</li>
|
||||
<li>Log in with your GOG account</li>
|
||||
<li>Copy the authorization code from the URL</li>
|
||||
<li>Paste the code below</li>
|
||||
</ol>
|
||||
<a
|
||||
href={GOG_AUTH_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-block text-sm text-blue-500 underline"
|
||||
>
|
||||
Open GOG Login →
|
||||
</a>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
{/* biome-ignore lint/a11y/noLabelWithoutControl: Input is inside the label */}
|
||||
<label className="space-y-1">
|
||||
<span className="text-sm font-medium">
|
||||
{t("settings.gog.code")}
|
||||
</span>
|
||||
<Input
|
||||
type="text"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
placeholder="Paste authorization code"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<Button onClick={handleConnect} disabled={syncing || !code}>
|
||||
{syncing ? t("settings.syncing") : t("settings.gog.connect")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="divide-y rounded-lg border bg-card">
|
||||
<ListItem title="Account" after={gogConfig?.userId} />
|
||||
</div>
|
||||
{lastSync && (
|
||||
<div className="mt-4 divide-y rounded-lg border bg-card">
|
||||
<ListItem
|
||||
title={t("settings.lastSync")}
|
||||
after={new Date(lastSync).toLocaleString()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4 flex gap-2">
|
||||
<Button onClick={handleSync} disabled={syncing}>
|
||||
{syncing ? t("settings.syncing") : t("settings.steam.sync")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-red-500"
|
||||
onClick={handleDisconnect}
|
||||
>
|
||||
{t("settings.gog.disconnect")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<SyncProgress progress={progress} />
|
||||
|
||||
{error && (
|
||||
<div className="mt-4">
|
||||
<p className="text-sm text-red-500">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
{lastCount !== null && (
|
||||
<div className="mt-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("settings.syncSuccess", { count: lastCount })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
161
src/client/features/settings/components/settings-list.tsx
Normal file
161
src/client/features/settings/components/settings-list.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { useRegisterSW } from "virtual:pwa-register/react"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { ListItem } from "@/shared/components/ui/list-item"
|
||||
import { useConfig } from "@/shared/db/hooks"
|
||||
import { t } from "@/shared/i18n"
|
||||
import { api } from "@/shared/lib/api"
|
||||
import { useNavigate } from "@tanstack/react-router"
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
|
||||
const providers = [
|
||||
{ id: "steam", label: "Steam" },
|
||||
{ id: "gog", label: "GOG" },
|
||||
] as const
|
||||
|
||||
export function SettingsList() {
|
||||
const steamConfig = useConfig<{ apiKey: string; steamId: string }>("steam")
|
||||
const gogConfig = useConfig<{ accessToken: string }>("gog")
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [testState, setTestState] = useState<
|
||||
"idle" | "testing" | "ok" | "failed"
|
||||
>("idle")
|
||||
|
||||
const {
|
||||
needRefresh: [needRefresh],
|
||||
updateServiceWorker,
|
||||
} = useRegisterSW()
|
||||
|
||||
const isConnected = (id: string) => {
|
||||
if (id === "steam") return Boolean(steamConfig?.apiKey)
|
||||
if (id === "gog") return Boolean(gogConfig?.accessToken)
|
||||
return false
|
||||
}
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
setTestState("testing")
|
||||
try {
|
||||
const res = await api.health.$get()
|
||||
if (res.ok) {
|
||||
setTestState("ok")
|
||||
} else {
|
||||
setTestState("failed")
|
||||
}
|
||||
} catch {
|
||||
setTestState("failed")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-4">
|
||||
<h3 className="pt-4 pb-1 text-xs font-medium uppercase text-muted-foreground">
|
||||
{t("settings.app")}
|
||||
</h3>
|
||||
<div className="divide-y rounded-lg border bg-card">
|
||||
<ListItem
|
||||
title={
|
||||
needRefresh
|
||||
? t("settings.updateAvailable")
|
||||
: t("settings.appUpToDate")
|
||||
}
|
||||
after={
|
||||
needRefresh ? (
|
||||
<Button size="sm" onClick={() => updateServiceWorker()}>
|
||||
{t("settings.updateApp")}
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("settings.upToDate")}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<p className="px-1 pt-1 text-xs text-muted-foreground/60">
|
||||
v{__APP_VERSION__}
|
||||
</p>
|
||||
|
||||
<h3 className="pt-4 pb-1 text-xs font-medium uppercase text-muted-foreground">
|
||||
{t("settings.server")}
|
||||
</h3>
|
||||
<div className="divide-y rounded-lg border bg-card">
|
||||
<ListItem
|
||||
title={t("settings.connection")}
|
||||
after={
|
||||
<div className="flex items-center gap-2">
|
||||
{testState === "testing" && (
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
)}
|
||||
{testState === "ok" && (
|
||||
<span className="text-sm font-medium text-green-600">
|
||||
{t("settings.connectionOk")}
|
||||
</span>
|
||||
)}
|
||||
{testState === "failed" && (
|
||||
<span className="text-sm font-medium text-red-500">
|
||||
{t("settings.connectionFailed")}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleTestConnection}
|
||||
disabled={testState === "testing"}
|
||||
>
|
||||
{t("settings.testConnection")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h3 className="pt-4 pb-1 text-xs font-medium uppercase text-muted-foreground">
|
||||
{t("settings.providers")}
|
||||
</h3>
|
||||
<div className="divide-y rounded-lg border bg-card">
|
||||
{providers.map((p) => (
|
||||
<ListItem
|
||||
key={p.id}
|
||||
link
|
||||
title={p.label}
|
||||
after={
|
||||
<Badge
|
||||
className={
|
||||
isConnected(p.id)
|
||||
? "bg-green-500 text-white"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}
|
||||
>
|
||||
{isConnected(p.id) ? "Connected" : "Not configured"}
|
||||
</Badge>
|
||||
}
|
||||
onClick={() =>
|
||||
navigate({
|
||||
to: "/settings/$provider",
|
||||
params: { provider: p.id },
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h3 className="pt-4 pb-1 text-xs font-medium uppercase text-muted-foreground">
|
||||
{t("settings.data")}
|
||||
</h3>
|
||||
<div className="divide-y rounded-lg border bg-card">
|
||||
<ListItem
|
||||
link
|
||||
title={t("settings.data")}
|
||||
onClick={() =>
|
||||
navigate({
|
||||
to: "/settings/$provider",
|
||||
params: { provider: "data" },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
104
src/client/features/settings/components/steam-settings.tsx
Normal file
104
src/client/features/settings/components/steam-settings.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { SyncProgress } from "@/shared/components/sync-progress"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { ListItem } from "@/shared/components/ui/list-item"
|
||||
import { useConfig } from "@/shared/db/hooks"
|
||||
import { t } from "@/shared/i18n"
|
||||
import { useSyncStore } from "@/shared/stores/sync-store"
|
||||
import { useState } from "react"
|
||||
|
||||
export function SteamSettings() {
|
||||
const savedConfig = useConfig<{ apiKey: string; steamId: string }>("steam")
|
||||
const { syncing, error, lastCount, progress } = useSyncStore((s) => s.steam)
|
||||
const syncSteam = useSyncStore((s) => s.syncSteam)
|
||||
const lastSync = useConfig<string>("steam_last_sync")
|
||||
|
||||
const [apiKey, setApiKey] = useState("")
|
||||
const [steamId, setSteamId] = useState("")
|
||||
const [initialized, setInitialized] = useState(false)
|
||||
|
||||
if (savedConfig && !initialized) {
|
||||
setApiKey(savedConfig.apiKey || "")
|
||||
setSteamId(savedConfig.steamId || "")
|
||||
setInitialized(true)
|
||||
}
|
||||
|
||||
const handleSync = () => {
|
||||
syncSteam({ apiKey, steamId: steamId.trim() })
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("settings.steam.instructions")}
|
||||
</p>
|
||||
<a
|
||||
href="https://steamcommunity.com/dev/apikey"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-block text-sm text-blue-500 underline"
|
||||
>
|
||||
steamcommunity.com/dev/apikey →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
{/* biome-ignore lint/a11y/noLabelWithoutControl: Input is inside the label */}
|
||||
<label className="space-y-1">
|
||||
<span className="text-sm font-medium">
|
||||
{t("settings.steam.steamId")}
|
||||
</span>
|
||||
<Input
|
||||
type="text"
|
||||
value={steamId}
|
||||
onChange={(e) => setSteamId(e.target.value)}
|
||||
placeholder="Steam ID or profile URL"
|
||||
/>
|
||||
</label>
|
||||
{/* biome-ignore lint/a11y/noLabelWithoutControl: Input is inside the label */}
|
||||
<label className="space-y-1">
|
||||
<span className="text-sm font-medium">
|
||||
{t("settings.steam.apiKey")}
|
||||
</span>
|
||||
<Input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder="Your Steam Web API Key"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<Button onClick={handleSync} disabled={syncing || !apiKey || !steamId}>
|
||||
{syncing ? t("settings.syncing") : t("settings.steam.sync")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<SyncProgress progress={progress} />
|
||||
|
||||
{error && (
|
||||
<div className="mt-4">
|
||||
<p className="text-sm text-red-500">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
{!syncing && lastCount !== null && (
|
||||
<div className="mt-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("settings.syncSuccess", { count: lastCount })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lastSync && (
|
||||
<div className="mt-4 divide-y rounded-lg border bg-card">
|
||||
<ListItem
|
||||
title={t("settings.lastSync")}
|
||||
after={new Date(lastSync).toLocaleString()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
101
src/client/features/settings/hooks/use-data-management.ts
Normal file
101
src/client/features/settings/hooks/use-data-management.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { getDb } from "@/shared/db/client"
|
||||
import { useCallback } from "react"
|
||||
|
||||
export function useDataManagement() {
|
||||
const exportData = useCallback(async () => {
|
||||
const db = await getDb()
|
||||
const games = await db.query("SELECT * FROM games")
|
||||
const playlists = await db.query("SELECT * FROM playlists")
|
||||
const playlistGames = await db.query("SELECT * FROM playlist_games")
|
||||
const config = await db.query("SELECT * FROM config")
|
||||
|
||||
const data = {
|
||||
version: "2026.03.01",
|
||||
exportedAt: new Date().toISOString(),
|
||||
games: games.rows,
|
||||
playlists: playlists.rows,
|
||||
playlistGames: playlistGames.rows,
|
||||
config: config.rows,
|
||||
}
|
||||
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], {
|
||||
type: "application/json",
|
||||
})
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = `whattoplay-export-${new Date().toISOString().slice(0, 10)}.json`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}, [])
|
||||
|
||||
const importData = useCallback(async (file: File) => {
|
||||
const text = await file.text()
|
||||
const data = JSON.parse(text)
|
||||
const db = await getDb()
|
||||
|
||||
if (data.games) {
|
||||
for (const game of data.games) {
|
||||
await db.query(
|
||||
`INSERT INTO games (id, title, source, source_id, platform, last_played, playtime_hours, url, canonical_id, rating, game_state, is_favorite)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
title = $2, last_played = $6, playtime_hours = $7, url = $8, canonical_id = $9,
|
||||
rating = $10, game_state = $11, is_favorite = $12, updated_at = NOW()`,
|
||||
[
|
||||
game.id,
|
||||
game.title,
|
||||
game.source,
|
||||
game.source_id,
|
||||
game.platform,
|
||||
game.last_played,
|
||||
game.playtime_hours,
|
||||
game.url,
|
||||
game.canonical_id,
|
||||
game.rating ?? -1,
|
||||
game.game_state ?? "not_set",
|
||||
game.is_favorite ?? false,
|
||||
],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (data.playlists) {
|
||||
for (const pl of data.playlists) {
|
||||
await db.query(
|
||||
"INSERT INTO playlists (id, name, is_static) VALUES ($1, $2, $3) ON CONFLICT (id) DO NOTHING",
|
||||
[pl.id, pl.name, pl.is_static],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (data.playlistGames) {
|
||||
for (const pg of data.playlistGames) {
|
||||
await db.query(
|
||||
"INSERT INTO playlist_games (playlist_id, game_id) VALUES ($1, $2) ON CONFLICT DO NOTHING",
|
||||
[pg.playlist_id, pg.game_id],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (data.config) {
|
||||
for (const cfg of data.config) {
|
||||
await db.query(
|
||||
`INSERT INTO config (key, value, updated_at) VALUES ($1, $2, NOW())
|
||||
ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()`,
|
||||
[cfg.key, JSON.stringify(cfg.value)],
|
||||
)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const clearAll = useCallback(async () => {
|
||||
const db = await getDb()
|
||||
await db.query("DELETE FROM playlist_games")
|
||||
await db.query("DELETE FROM games")
|
||||
await db.query("DELETE FROM playlists WHERE is_static = FALSE")
|
||||
await db.query("DELETE FROM config")
|
||||
}, [])
|
||||
|
||||
return { exportData, importData, clearAll }
|
||||
}
|
||||
14
src/client/features/settings/schema.ts
Normal file
14
src/client/features/settings/schema.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const steamConfigSchema = z.object({
|
||||
apiKey: z.string().min(1, "API key is required"),
|
||||
steamId: z.string().min(1, "Steam ID is required"),
|
||||
})
|
||||
export type SteamConfig = z.infer<typeof steamConfigSchema>
|
||||
|
||||
export const gogConfigSchema = z.object({
|
||||
accessToken: z.string(),
|
||||
refreshToken: z.string(),
|
||||
userId: z.string(),
|
||||
})
|
||||
export type GogConfig = z.infer<typeof gogConfigSchema>
|
||||
25
src/client/main.tsx
Normal file
25
src/client/main.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { RouterProvider, createRouter } from "@tanstack/react-router"
|
||||
import { StrictMode } from "react"
|
||||
import { createRoot } from "react-dom/client"
|
||||
import { routeTree } from "./routeTree.gen"
|
||||
import "./app.css"
|
||||
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
basepath: "/whattoplay",
|
||||
})
|
||||
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: typeof router
|
||||
}
|
||||
}
|
||||
|
||||
const root = document.getElementById("root")
|
||||
if (!root) throw new Error("Root element not found")
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<RouterProvider router={router} />
|
||||
</StrictMode>,
|
||||
)
|
||||
210
src/client/routeTree.gen.ts
Normal file
210
src/client/routeTree.gen.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
/* eslint-disable */
|
||||
|
||||
// @ts-nocheck
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
|
||||
// This file was automatically generated by TanStack Router.
|
||||
// You should NOT make any changes in this file as it will be overwritten.
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as SettingsIndexRouteImport } from './routes/settings/index'
|
||||
import { Route as PlaylistsIndexRouteImport } from './routes/playlists/index'
|
||||
import { Route as LibraryIndexRouteImport } from './routes/library/index'
|
||||
import { Route as DiscoverIndexRouteImport } from './routes/discover/index'
|
||||
import { Route as SettingsProviderRouteImport } from './routes/settings/$provider'
|
||||
import { Route as PlaylistsPlaylistIdRouteImport } from './routes/playlists/$playlistId'
|
||||
import { Route as GamesGameIdRouteImport } from './routes/games/$gameId'
|
||||
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const SettingsIndexRoute = SettingsIndexRouteImport.update({
|
||||
id: '/settings/',
|
||||
path: '/settings/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const PlaylistsIndexRoute = PlaylistsIndexRouteImport.update({
|
||||
id: '/playlists/',
|
||||
path: '/playlists/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const LibraryIndexRoute = LibraryIndexRouteImport.update({
|
||||
id: '/library/',
|
||||
path: '/library/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const DiscoverIndexRoute = DiscoverIndexRouteImport.update({
|
||||
id: '/discover/',
|
||||
path: '/discover/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const SettingsProviderRoute = SettingsProviderRouteImport.update({
|
||||
id: '/settings/$provider',
|
||||
path: '/settings/$provider',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const PlaylistsPlaylistIdRoute = PlaylistsPlaylistIdRouteImport.update({
|
||||
id: '/playlists/$playlistId',
|
||||
path: '/playlists/$playlistId',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const GamesGameIdRoute = GamesGameIdRouteImport.update({
|
||||
id: '/games/$gameId',
|
||||
path: '/games/$gameId',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/games/$gameId': typeof GamesGameIdRoute
|
||||
'/playlists/$playlistId': typeof PlaylistsPlaylistIdRoute
|
||||
'/settings/$provider': typeof SettingsProviderRoute
|
||||
'/discover/': typeof DiscoverIndexRoute
|
||||
'/library/': typeof LibraryIndexRoute
|
||||
'/playlists/': typeof PlaylistsIndexRoute
|
||||
'/settings/': typeof SettingsIndexRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/games/$gameId': typeof GamesGameIdRoute
|
||||
'/playlists/$playlistId': typeof PlaylistsPlaylistIdRoute
|
||||
'/settings/$provider': typeof SettingsProviderRoute
|
||||
'/discover': typeof DiscoverIndexRoute
|
||||
'/library': typeof LibraryIndexRoute
|
||||
'/playlists': typeof PlaylistsIndexRoute
|
||||
'/settings': typeof SettingsIndexRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/games/$gameId': typeof GamesGameIdRoute
|
||||
'/playlists/$playlistId': typeof PlaylistsPlaylistIdRoute
|
||||
'/settings/$provider': typeof SettingsProviderRoute
|
||||
'/discover/': typeof DiscoverIndexRoute
|
||||
'/library/': typeof LibraryIndexRoute
|
||||
'/playlists/': typeof PlaylistsIndexRoute
|
||||
'/settings/': typeof SettingsIndexRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/games/$gameId'
|
||||
| '/playlists/$playlistId'
|
||||
| '/settings/$provider'
|
||||
| '/discover/'
|
||||
| '/library/'
|
||||
| '/playlists/'
|
||||
| '/settings/'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/'
|
||||
| '/games/$gameId'
|
||||
| '/playlists/$playlistId'
|
||||
| '/settings/$provider'
|
||||
| '/discover'
|
||||
| '/library'
|
||||
| '/playlists'
|
||||
| '/settings'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/games/$gameId'
|
||||
| '/playlists/$playlistId'
|
||||
| '/settings/$provider'
|
||||
| '/discover/'
|
||||
| '/library/'
|
||||
| '/playlists/'
|
||||
| '/settings/'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
GamesGameIdRoute: typeof GamesGameIdRoute
|
||||
PlaylistsPlaylistIdRoute: typeof PlaylistsPlaylistIdRoute
|
||||
SettingsProviderRoute: typeof SettingsProviderRoute
|
||||
DiscoverIndexRoute: typeof DiscoverIndexRoute
|
||||
LibraryIndexRoute: typeof LibraryIndexRoute
|
||||
PlaylistsIndexRoute: typeof PlaylistsIndexRoute
|
||||
SettingsIndexRoute: typeof SettingsIndexRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/settings/': {
|
||||
id: '/settings/'
|
||||
path: '/settings'
|
||||
fullPath: '/settings/'
|
||||
preLoaderRoute: typeof SettingsIndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/playlists/': {
|
||||
id: '/playlists/'
|
||||
path: '/playlists'
|
||||
fullPath: '/playlists/'
|
||||
preLoaderRoute: typeof PlaylistsIndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/library/': {
|
||||
id: '/library/'
|
||||
path: '/library'
|
||||
fullPath: '/library/'
|
||||
preLoaderRoute: typeof LibraryIndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/discover/': {
|
||||
id: '/discover/'
|
||||
path: '/discover'
|
||||
fullPath: '/discover/'
|
||||
preLoaderRoute: typeof DiscoverIndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/settings/$provider': {
|
||||
id: '/settings/$provider'
|
||||
path: '/settings/$provider'
|
||||
fullPath: '/settings/$provider'
|
||||
preLoaderRoute: typeof SettingsProviderRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/playlists/$playlistId': {
|
||||
id: '/playlists/$playlistId'
|
||||
path: '/playlists/$playlistId'
|
||||
fullPath: '/playlists/$playlistId'
|
||||
preLoaderRoute: typeof PlaylistsPlaylistIdRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/games/$gameId': {
|
||||
id: '/games/$gameId'
|
||||
path: '/games/$gameId'
|
||||
fullPath: '/games/$gameId'
|
||||
preLoaderRoute: typeof GamesGameIdRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
GamesGameIdRoute: GamesGameIdRoute,
|
||||
PlaylistsPlaylistIdRoute: PlaylistsPlaylistIdRoute,
|
||||
SettingsProviderRoute: SettingsProviderRoute,
|
||||
DiscoverIndexRoute: DiscoverIndexRoute,
|
||||
LibraryIndexRoute: LibraryIndexRoute,
|
||||
PlaylistsIndexRoute: PlaylistsIndexRoute,
|
||||
SettingsIndexRoute: SettingsIndexRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
._addFileTypes<FileRouteTypes>()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user