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
|
.DS_Store
|
||||||
.claude
|
.claude
|
||||||
|
|
||||||
|
# Override global gitignore exclusions
|
||||||
|
!/src/
|
||||||
|
!**/lib/
|
||||||
|
|
||||||
# Secrets
|
# Secrets
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.*.example
|
!.env.*.example
|
||||||
!.env.1password
|
!.env.example
|
||||||
*.secret.*
|
|
||||||
*.key
|
|
||||||
*.pem
|
|
||||||
|
|
||||||
# IGDB cache (generated at runtime)
|
# IGDB cache (generated at runtime)
|
||||||
server/data/igdb-cache.json
|
src/server/data/
|
||||||
|
|
||||||
# Build outputs
|
# Build outputs
|
||||||
dist
|
dist
|
||||||
@@ -20,6 +21,13 @@ build
|
|||||||
.vite
|
.vite
|
||||||
coverage
|
coverage
|
||||||
|
|
||||||
|
# Database
|
||||||
|
drizzle/
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
npm-debug.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"
|
||||||
7
.vscode/tasks.json
vendored
7
.vscode/tasks.json
vendored
@@ -5,13 +5,10 @@
|
|||||||
"label": "vite: dev server",
|
"label": "vite: dev server",
|
||||||
"type": "shell",
|
"type": "shell",
|
||||||
"command": "npm",
|
"command": "npm",
|
||||||
"args": [
|
"args": ["run", "dev"],
|
||||||
"run",
|
|
||||||
"dev"
|
|
||||||
],
|
|
||||||
"isBackground": true,
|
"isBackground": true,
|
||||||
"problemMatcher": [],
|
"problemMatcher": [],
|
||||||
"group": "build"
|
"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>
|
<!doctype html>
|
||||||
<html lang="de">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
<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="#0a84ff" />
|
<meta name="theme-color" content="#0f172a" />
|
||||||
<meta name="description" content="Verwalte deine Spielebibliothek und entdecke neue Spiele" />
|
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
|
||||||
<meta name="apple-mobile-web-app-title" content="WhatToPlay" />
|
|
||||||
<link rel="manifest" href="/manifest.json" />
|
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
|
||||||
<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png" />
|
|
||||||
<title>WhatToPlay</title>
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/client/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
10631
package-lock.json
generated
10631
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",
|
"name": "whattoplay",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "2026.03.02",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "op run --env-file=.env.1password -- vite",
|
"dev": "vite",
|
||||||
"dev:no-op": "vite",
|
"dev:server": "bun --watch src/server/index.ts",
|
||||||
"build": "vite build",
|
"dev:all": "bun run dev & bun run dev:server",
|
||||||
|
"build": "tsc --noEmit && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "node --test server/**/*.test.mjs",
|
"start": "bun run src/server/index.ts",
|
||||||
"deploy": "./deploy.sh"
|
"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": {
|
"dependencies": {
|
||||||
"@ionic/react": "^8.0.0",
|
"@electric-sql/pglite": "^0.2.17",
|
||||||
"@ionic/react-router": "^8.0.0",
|
"@hono/zod-validator": "^0.5.0",
|
||||||
"@react-spring/web": "^9.7.5",
|
"@tanstack/react-form": "^1.0.0",
|
||||||
"ionicons": "^7.2.0",
|
"@tanstack/react-router": "^1.114.0",
|
||||||
"react": "^18.2.0",
|
"class-variance-authority": "^0.7.1",
|
||||||
"react-dom": "^18.2.0",
|
"clsx": "^2.1.1",
|
||||||
"react-router": "^5.3.4",
|
"drizzle-orm": "^0.45.1",
|
||||||
"react-router-dom": "^5.3.4",
|
"hono": "^4.7.0",
|
||||||
"react-tinder-card": "^1.6.4"
|
"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": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.2.0",
|
"@biomejs/biome": "^1.9.0",
|
||||||
"@types/react-dom": "^18.2.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
"@types/react-router": "^5.1.20",
|
"@tanstack/router-plugin": "^1.114.0",
|
||||||
"@types/react-router-dom": "^5.3.3",
|
"@testing-library/react": "^16.0.0",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@types/bun": "^1.3.10",
|
||||||
"typescript": "^5.3.3",
|
"@types/node": "^25.3.3",
|
||||||
"vite": "^5.0.0"
|
"@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
|
||||||
RewriteEngine On
|
RewriteBase /whattoplay/
|
||||||
RewriteBase /
|
|
||||||
|
|
||||||
# Don't rewrite files or directories
|
|
||||||
RewriteCond %{REQUEST_FILENAME} !-f
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
RewriteCond %{REQUEST_FILENAME} !-d
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
|
RewriteRule . index.html [L]
|
||||||
# Don't rewrite API calls
|
|
||||||
RewriteCond %{REQUEST_URI} !^/api/
|
|
||||||
|
|
||||||
# Rewrite everything else to index.html
|
|
||||||
RewriteRule . /index.html [L]
|
|
||||||
</IfModule>
|
|
||||||
|
|
||||||
# No cache for manifest and index (PWA updates)
|
|
||||||
<FilesMatch "(manifest\.json|index\.html)$">
|
|
||||||
Header set Cache-Control "no-cache, must-revalidate"
|
|
||||||
</FilesMatch>
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.5 KiB |
@@ -1,20 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>Clear Storage</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h2>Clearing Storage...</h2>
|
|
||||||
<script>
|
|
||||||
// Clear localStorage
|
|
||||||
localStorage.clear();
|
|
||||||
|
|
||||||
// Clear IndexedDB
|
|
||||||
indexedDB.deleteDatabase("whattoplay");
|
|
||||||
|
|
||||||
document.write("<p>✓ localStorage cleared</p>");
|
|
||||||
document.write("<p>✓ IndexedDB deleted</p>");
|
|
||||||
document.write("<br><p>Close this tab and reload the app.</p>");
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.5 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 9.7 KiB |
@@ -1,23 +1,4 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="none">
|
||||||
<rect width="512" height="512" rx="96" fill="#0a84ff"/>
|
<rect width="512" height="512" rx="96" fill="#0f172a"/>
|
||||||
<g fill="white">
|
<text x="256" y="320" text-anchor="middle" font-family="system-ui" font-size="240" font-weight="bold" fill="#f8fafc">W</text>
|
||||||
<!-- 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>
|
</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