Compare commits

36 Commits
legacy ... main

Author SHA1 Message Date
84a48ac97b migrate deploy.sh to mise file task
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 10:19:17 +02:00
f29332f3dd add keycrow feature ideas, exclude features/ from biome
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 17:03:13 +01:00
1b5cff78e2 normalize project structure: src/client + src/server + src/shared, standardize biome to lineWidth 80
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 23:00:43 +01:00
6e9cd45671 update deploy script: sync drizzle migrations, run db:migrate on deploy
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 15:41:21 +01:00
c6512d0153 add missing biome platform binary for darwin-arm64
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 14:39:57 +01:00
2a2ccced90 add one-time migration script for JSON cache → PostgreSQL
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 14:39:30 +01:00
bd5df81f37 replace IGDB file caches with Drizzle/PostgreSQL, add combined /resolve endpoint
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 14:29:27 +01:00
63219afc10 add PostgreSQL + Drizzle schema for IGDB resolution and metadata caching
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 14:19:47 +01:00
ff98d7e64f fix canonical_id reset: stop wiping enrichment data on re-sync
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 14:09:32 +01:00
2d50198782 cache shuffled game order in zustand store to prevent card flicker
the discover tab re-shuffled on every mount because useGames() returns
a new array reference each time. now the shuffled ID order is stored in
zustand, only recomputed when game count or seen count actually changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 23:59:30 +01:00
2c8141660c fix IGDB resolution: drop broken category filter, match by URL prefix
the IGDB external_games category filter returns empty for all values.
filter steam/gog entries by URL prefix instead (store.steampowered.com,
gog.com). reduce batch size to 50 to stay within 500-result API limit
since each uid can return multiple platform entries.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 23:40:00 +01:00
109a9f383b add IGDB metadata enrichment, image proxy, rich game UI
server: metadata cache + fetch service, image proxy with disk cache,
POST /igdb/metadata + GET /igdb/image/:id/:size routes.
client: 002-metadata migration, enrichment wired into sync pipeline,
extracted SyncProgress component, square cover icons in list items,
genres/rating/summary on discover cards, tap-to-navigate on card stack,
rich game detail with screenshots, trailer embed, developer info.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 22:51:58 +01:00
0f8c9f331f fix default sort direction so most-played game is first
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 22:13:29 +01:00
32b9740854 default library sort to playtime desc, use GameListItem in playlists
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 22:11:40 +01:00
0ccfe16a67 full-height discover layout, info button on cards, game state dot
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 21:57:52 +01:00
28d8959c5c wire game detail navigation from library, playlists
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 21:56:16 +01:00
ac5ac570e2 add game detail component, route at /games/$gameId
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 21:52:16 +01:00
7f16657a84 refactor useGame to return { game, loading, reload }, add game detail i18n keys
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 21:50:33 +01:00
7337f38710 add app versioning, state-aware update button, disable pinch zoom
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 21:31:14 +01:00
b50fde1af5 improve discover: seeded shuffle, reusable swipe gesture hook, optimistic swipes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 21:30:50 +01:00
ee8b9aa77f refine playlists UI
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 21:30:18 +01:00
5f5d163021 improve library: game-list-item, fix sort mutation, input zoom, startTransition
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 21:24:35 +01:00
05d05ed05e overhaul settings UI, move sync logic to sync store
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 21:23:15 +01:00
db1f66ced2 add list-item component, sync store, refine layout, styles
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 21:21:15 +01:00
ee32bfd206 refine server setup, deploy script, steam icon cache
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 21:21:07 +01:00
399e7d5b89 add pwa setup, deploy script, fix build configuration
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 14:44:41 +01:00
c9c69a3265 add discover feature: tinder swipe cards, progress, like/skip
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 14:36:30 +01:00
7e71098658 add playlists feature: static + custom playlists, CRUD, add/remove games
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 14:34:25 +01:00
e51a01123e add library feature: search, sort, deduplication, progressive rendering
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 14:32:18 +01:00
9577087930 add games feature: star rating, game state, favorites, game card
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 13:59:06 +01:00
d907f26683 add settings feature: steam, gog providers, data management
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 13:57:49 +01:00
1d444e6e4e add shared frontend: router, i18n, ui store, api client
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 13:52:49 +01:00
2fdaf870b6 add pglite database layer: schema, migrations, hooks
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 13:48:42 +01:00
5ebd9dba16 add hono backend: steam, gog, igdb api proxy, fix gitignore overrides
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 13:40:43 +01:00
17b52173c7 scaffold project: vite, react 19, tailwind v4, shadcn/ui, tanstack router
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 13:32:05 +01:00
de812a0fd1 archive legacy code, begin clean rewrite
legacy branch preserves all prior code.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 12:58:11 +01:00
178 changed files with 21790 additions and 9236 deletions

View File

@@ -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
View 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

View File

@@ -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
View File

@@ -2,17 +2,18 @@ node_modules
.DS_Store
.claude
# Override global gitignore exclusions
!/src/
!**/lib/
# Secrets
.env
.env.*
!.env.*.example
!.env.1password
*.secret.*
*.key
*.pem
!.env.example
# IGDB cache (generated at runtime)
server/data/igdb-cache.json
src/server/data/
# Build outputs
dist
@@ -20,6 +21,13 @@ build
.vite
coverage
# Database
drizzle/
# Logs
*.log
npm-debug.log*
# bun
bun.lock
.mise.local.toml

2
.mise.toml Normal file
View File

@@ -0,0 +1,2 @@
[tools]
bun = "1.3.0"

78
.mise/tasks/deploy Executable file
View File

@@ -0,0 +1,78 @@
#!/usr/bin/env bash
#MISE description="Build and deploy frontend + backend to Uberspace"
set -euo pipefail
UBERSPACE_HOST="${UBERSPACE_HOST:-serve}"
REMOTE_HTML_DIR="~/www/html/whattoplay"
REMOTE_SERVICE_DIR="~/services/whattoplay"
SERVICE_NAME="whattoplay"
PORT=3001
echo "==> building frontend..."
bun run build
echo "==> syncing frontend to $REMOTE_HTML_DIR/"
ssh "$UBERSPACE_HOST" "mkdir -p $REMOTE_HTML_DIR"
rsync -avz --delete dist/ "$UBERSPACE_HOST:$REMOTE_HTML_DIR/"
echo "==> syncing project to $REMOTE_SERVICE_DIR/"
ssh "$UBERSPACE_HOST" "mkdir -p $REMOTE_SERVICE_DIR"
rsync -avz --delete \
--exclude='.git/' \
--exclude='.env' \
--exclude='dist/' \
--exclude='node_modules/' \
--exclude='.DS_Store' \
./ "$UBERSPACE_HOST:$REMOTE_SERVICE_DIR/"
echo "==> ensuring data directories exist..."
ssh "$UBERSPACE_HOST" "mkdir -p $REMOTE_SERVICE_DIR/src/server/data/steam-icons $REMOTE_SERVICE_DIR/src/server/data/igdb-images/thumb $REMOTE_SERVICE_DIR/src/server/data/igdb-images/cover_big $REMOTE_SERVICE_DIR/src/server/data/igdb-images/screenshot_med"
echo "==> installing dependencies..."
ssh "$UBERSPACE_HOST" "cd $REMOTE_SERVICE_DIR && bun install"
echo "==> creating .env if missing..."
ssh "$UBERSPACE_HOST" "test -f $REMOTE_SERVICE_DIR/.env || cat > $REMOTE_SERVICE_DIR/.env" <<'ENV'
PORT=3001
ALLOWED_ORIGIN=https://serve.uber.space
TWITCH_CLIENT_ID=
TWITCH_CLIENT_SECRET=
DATABASE_URL=
ENV
echo "==> ensuring DATABASE_URL is set..."
ssh "$UBERSPACE_HOST" "grep -q '^DATABASE_URL=' $REMOTE_SERVICE_DIR/.env || echo 'DATABASE_URL=' >> $REMOTE_SERVICE_DIR/.env"
echo "==> running database migrations..."
ssh "$UBERSPACE_HOST" "cd $REMOTE_SERVICE_DIR && bunx drizzle-kit migrate"
echo "==> setting up web backend..."
ssh "$UBERSPACE_HOST" "uberspace web backend add /whattoplay/api port $PORT --remove-prefix --force" || true
echo "==> setting up systemd service..."
ssh "$UBERSPACE_HOST" "mkdir -p ~/.config/systemd/user"
ssh "$UBERSPACE_HOST" "cat > ~/.config/systemd/user/$SERVICE_NAME.service" <<UNIT
[Unit]
Description=WhatToPlay API server
After=network.target postgresql.service
[Service]
Type=simple
WorkingDirectory=/home/serve/services/whattoplay
EnvironmentFile=/home/serve/services/whattoplay/.env
ExecStart=/usr/bin/bun run src/server/index.ts
Restart=on-failure
RestartSec=5
[Install]
WantedBy=default.target
UNIT
ssh "$UBERSPACE_HOST" "systemctl --user daemon-reload && systemctl --user enable $SERVICE_NAME && systemctl --user restart $SERVICE_NAME"
echo "==> checking service status..."
ssh "$UBERSPACE_HOST" "systemctl --user status $SERVICE_NAME --no-pager" || true
echo "==> deploy complete"
echo " frontend: https://serve.uber.space/whattoplay/"
echo " api: https://serve.uber.space/whattoplay/api/health"

5
.vscode/tasks.json vendored
View File

@@ -5,10 +5,7 @@
"label": "vite: dev server",
"type": "shell",
"command": "npm",
"args": [
"run",
"dev"
],
"args": ["run", "dev"],
"isBackground": true,
"problemMatcher": [],
"group": "build"

View File

@@ -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
View 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

View File

@@ -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

View File

@@ -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! 🎮**

View File

@@ -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
View File

@@ -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
View 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
View 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"
}

View File

@@ -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"
}
}

View File

@@ -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"

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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`

View File

@@ -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
View 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 ?? "",
},
})

View 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

File diff suppressed because it is too large Load Diff

View 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"
}
}

View 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',
},
};

View 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>

View 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;

View 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();
};

View 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;
}

View 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;

View 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;

View 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;

View 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;

View 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();

View 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();

View 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();

View 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();

View 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');
});
});
});

View 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"]
}

View File

@@ -1,20 +1,16 @@
<!doctype html>
<html lang="de">
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#0a84ff" />
<meta name="description" content="Verwalte deine Spielebibliothek und entdecke neue Spiele" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="WhatToPlay" />
<link rel="manifest" href="/manifest.json" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="theme-color" content="#0f172a" />
<title>WhatToPlay</title>
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
<link rel="apple-touch-icon" href="/whattoplay/icons/icon-192.png" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<script type="module" src="/src/client/main.tsx"></script>
</body>
</html>

10623
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,34 +1,64 @@
{
"name": "whattoplay",
"private": true,
"version": "0.0.0",
"version": "2026.03.02",
"type": "module",
"scripts": {
"dev": "op run --env-file=.env.1password -- vite",
"dev:no-op": "vite",
"build": "vite build",
"dev": "vite",
"dev:server": "bun --watch src/server/index.ts",
"dev:all": "bun run dev & bun run dev:server",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"test": "node --test server/**/*.test.mjs",
"deploy": "./deploy.sh"
"start": "bun run src/server/index.ts",
"lint": "biome check .",
"lint:fix": "biome check --write .",
"test": "vitest run",
"db:generate": "bunx drizzle-kit generate",
"db:migrate": "bunx drizzle-kit migrate",
"prepare": "simple-git-hooks"
},
"dependencies": {
"@ionic/react": "^8.0.0",
"@ionic/react-router": "^8.0.0",
"@react-spring/web": "^9.7.5",
"ionicons": "^7.2.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router": "^5.3.4",
"react-router-dom": "^5.3.4",
"react-tinder-card": "^1.6.4"
"@electric-sql/pglite": "^0.2.17",
"@hono/zod-validator": "^0.5.0",
"@tanstack/react-form": "^1.0.0",
"@tanstack/react-router": "^1.114.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"drizzle-orm": "^0.45.1",
"hono": "^4.7.0",
"lucide-react": "^0.474.0",
"postgres": "^3.4.8",
"radix-ui": "^1.4.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^3.5.0",
"zod": "^3.24.0",
"zustand": "^5.0.0"
},
"devDependencies": {
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@types/react-router": "^5.1.20",
"@types/react-router-dom": "^5.3.3",
"@vitejs/plugin-react": "^4.2.1",
"typescript": "^5.3.3",
"vite": "^5.0.0"
"@biomejs/biome": "^1.9.0",
"@tailwindcss/vite": "^4.0.0",
"@tanstack/router-plugin": "^1.114.0",
"@testing-library/react": "^16.0.0",
"@types/bun": "^1.3.10",
"@types/node": "^25.3.3",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.4.0",
"drizzle-kit": "^0.31.9",
"lint-staged": "^15.0.0",
"simple-git-hooks": "^2.11.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.0",
"vite": "^6.2.0",
"vite-plugin-pwa": "^0.21.0",
"vitest": "^3.0.0",
"workbox-window": "^7.0.0"
},
"simple-git-hooks": {
"pre-commit": "bunx lint-staged"
},
"lint-staged": {
"*.{ts,tsx,js,json}": ["biome check --write"]
}
}

View File

@@ -1,19 +1,5 @@
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
# Don't rewrite files or directories
RewriteBase /whattoplay/
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
# Don't rewrite API calls
RewriteCond %{REQUEST_URI} !^/api/
# Rewrite everything else to index.html
RewriteRule . /index.html [L]
</IfModule>
# No cache for manifest and index (PWA updates)
<FilesMatch "(manifest\.json|index\.html)$">
Header set Cache-Control "no-cache, must-revalidate"
</FilesMatch>
RewriteRule . index.html [L]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

View File

@@ -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

View File

@@ -1,23 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="96" fill="#0a84ff"/>
<g fill="white">
<!-- Gamepad body -->
<rect x="116" y="196" width="280" height="160" rx="48" ry="48"/>
<!-- Left grip -->
<rect x="136" y="296" width="60" height="80" rx="24" ry="24"/>
<!-- Right grip -->
<rect x="316" y="296" width="60" height="80" rx="24" ry="24"/>
</g>
<!-- D-pad -->
<g fill="#0a84ff">
<rect x="181" y="244" width="14" height="44" rx="3"/>
<rect x="166" y="259" width="44" height="14" rx="3"/>
</g>
<!-- Buttons -->
<circle cx="332" cy="252" r="9" fill="#0a84ff"/>
<circle cx="356" cy="268" r="9" fill="#0a84ff"/>
<circle cx="308" cy="268" r="9" fill="#0a84ff"/>
<circle cx="332" cy="284" r="9" fill="#0a84ff"/>
<!-- Play triangle (center) -->
<polygon points="240,148 280,168 240,188" fill="white"/>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="none">
<rect width="512" height="512" rx="96" fill="#0f172a"/>
<text x="256" y="320" text-anchor="middle" font-family="system-ui" font-size="240" font-weight="bold" fill="#f8fafc">W</text>
</svg>

Before

Width:  |  Height:  |  Size: 907 B

After

Width:  |  Height:  |  Size: 266 B

View File

@@ -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"
}
]
}

View File

@@ -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);
});

View File

@@ -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();

View File

@@ -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);

View File

@@ -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));

View File

@@ -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);
}

View File

@@ -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),
}),
);
}
});
}

View File

@@ -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();
}

View File

@@ -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;
});
}

View File

@@ -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}`);
});

View File

@@ -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"
}
}

View File

@@ -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),
}),
);
}
});
}

View File

@@ -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,
};
}

View File

@@ -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/,
);
});
});
});

Binary file not shown.

View File

@@ -1,5 +0,0 @@
.content {
--padding-top: 16px;
--padding-start: 16px;
--padding-end: 16px;
}

View File

@@ -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
View 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);
}

View 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>
)
}

View 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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View 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>
)
}

View 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,
}
}

View 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
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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`
}

View 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>
)
}

View 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" />}
</>
)
}

View 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>
)
}

View 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,
}
}

View 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>
)
}

View 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>
</>
)
}

View 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,
}
}

View File

@@ -0,0 +1 @@
export { usePlaylists } from "@/shared/db/hooks"

View 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>
</>
)
}

View 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>
)}
</>
)
}

View 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>
)
}

View 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>
)}
</>
)
}

View 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 }
}

View 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
View 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
View 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