From 11c3f141d51333315aef58396c1c284af2eac0ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20F=C3=B6rtsch?= Date: Fri, 6 Feb 2026 23:18:13 +0100 Subject: [PATCH] clean up code --- .gitignore | 17 +- .vscode/tasks.json | 17 -- UBERSPACE.md | 214 +++++++------- app.js | 279 ------------------ config.local.json.example | 23 -- docs/BLIZZARD-SETUP.md | 138 --------- docs/FEATURES-OVERVIEW.md | 328 ---------------------- docs/GOG-SETUP.md | 144 ---------- index.html | 4 +- package.json | 13 +- public/.htaccess | 8 +- public/apple-touch-icon.png | Bin 0 -> 3542 bytes public/icon-192.png | Bin 0 -> 3582 bytes public/icon-512.png | Bin 0 -> 9951 bytes public/icon.svg | 23 ++ public/manifest.json | 30 +- scripts/fetch-steam.mjs | 104 ------- scripts/steam-cli.mjs | 101 ------- scripts/test-api.mjs | 75 ----- scripts/test-backend.mjs | 54 ---- scripts/test-config-load.mjs | 28 -- server/assets-api.mjs | 140 --------- server/steam-api.mjs | 35 --- src/data/tutorials.ts | 210 ++++++++++++++ src/pages/Discover/DiscoverPage.tsx | 72 ++--- src/pages/Playlists/PlaylistsPage.css | 37 ++- src/pages/Playlists/PlaylistsPage.tsx | 122 +++++++- src/pages/Settings/SettingsDetailPage.tsx | 20 +- src/services/Database.ts | 112 +++++++- styles.css | 231 --------------- vite.config.ts | 13 +- 31 files changed, 677 insertions(+), 1915 deletions(-) delete mode 100644 .vscode/tasks.json delete mode 100644 app.js delete mode 100644 config.local.json.example delete mode 100644 docs/BLIZZARD-SETUP.md delete mode 100644 docs/FEATURES-OVERVIEW.md delete mode 100644 docs/GOG-SETUP.md create mode 100644 public/apple-touch-icon.png create mode 100644 public/icon-192.png create mode 100644 public/icon-512.png create mode 100644 public/icon.svg delete mode 100644 scripts/fetch-steam.mjs delete mode 100644 scripts/steam-cli.mjs delete mode 100644 scripts/test-api.mjs delete mode 100644 scripts/test-backend.mjs delete mode 100644 scripts/test-config-load.mjs delete mode 100644 server/assets-api.mjs create mode 100644 src/data/tutorials.ts delete mode 100644 styles.css diff --git a/.gitignore b/.gitignore index 52722bf..b8529be 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,8 @@ node_modules .DS_Store +.claude -# Local config / secrets -config.local.json -*.local.json +# Secrets .env .env.* !.env.*.example @@ -20,15 +19,3 @@ coverage # Logs *.log npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* - -# Private data / exports -data/ -steam-text/ - -# Private assets (place files here) -public/private/ -src/assets/private/ -assets/private/ diff --git a/.vscode/tasks.json b/.vscode/tasks.json deleted file mode 100644 index f73b093..0000000 --- a/.vscode/tasks.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "version": "2.0.0", - "tasks": [ - { - "label": "vite: dev server", - "type": "shell", - "command": "npm", - "args": [ - "run", - "dev" - ], - "isBackground": true, - "problemMatcher": [], - "group": "build" - } - ] -} \ No newline at end of file diff --git a/UBERSPACE.md b/UBERSPACE.md index 24a7580..3de916d 100644 --- a/UBERSPACE.md +++ b/UBERSPACE.md @@ -1,153 +1,171 @@ # Uberspace Deployment -Einfacheres Setup: Hoste sowohl PWA als auch Backend auf Uberspace. +WhatToPlay wird auf einem Uberspace gehostet. Apache liefert das Frontend (SPA) aus, ein Express-Server läuft als systemd-Service und stellt die Steam API bereit. + +## Architektur + +``` +Browser (PWA) + │ + ├── / ──► Caddy ──► Apache ──► SPA (React/Ionic) + │ .htaccess Rewrite index.html + │ + └── /api/* ──► Express (:3000) ──► Steam Web API + Prefix wird entfernt api.steampowered.com +``` ## Voraussetzungen - Uberspace Account (https://uberspace.de) -- SSH Zugriff -- Node.js (bereits auf Uberspace vorinstalliert) +- SSH Zugriff (z.B. `ssh wtp`) +- Node.js (auf Uberspace vorinstalliert) -## 1. Backend deployen +## 1. Repository klonen ```bash -# SSH auf deinen Uberspace -ssh @.uberspace.de - -# Repository klonen +ssh wtp cd ~ git clone https://github.com/felixfoertsch/whattoplay.git -cd whattoplay/server +``` -# Dependencies installieren +## 2. Backend einrichten + +### Dependencies installieren + +```bash +cd ~/whattoplay/server npm install - -# Backend als Service einrichten -uberspace web backend set / --http --port 3000 ``` -### Backend als Daemon (automatischer Start) - -Erstelle `~/etc/services.d/whattoplay-server.ini`: - -```ini -[program:whattoplay-server] -directory=%(ENV_HOME)s/whattoplay/server -command=node index.js -autostart=yes -autorestart=yes -startsecs=60 -environment=PORT="3000" -``` - -Starte den Service: +### Systemd-Service erstellen ```bash -supervisorctl reread -supervisorctl update -supervisorctl start whattoplay-server -supervisorctl status +uberspace service add whattoplay 'node index.js' \ + --workdir /home/wtp/whattoplay/server \ + -e PORT=3000 \ + -e 'ALLOWED_ORIGIN=https://wtp.uber.space' ``` -## 2. PWA deployen +Das erstellt automatisch `~/.config/systemd/user/whattoplay.service`, startet und aktiviert den Service. + +### Web-Backend konfigurieren + +API-Requests unter `/api` an den Express-Server weiterleiten: ```bash -# Auf deinem lokalen Rechner -# Build mit Uberspace URL als base +uberspace web backend set /api --http --port 3000 --remove-prefix +``` + +- `--remove-prefix` sorgt dafür, dass `/api/steam/refresh` als `/steam/refresh` beim Express-Server ankommt. + +### Service verwalten + +```bash +# Status prüfen +uberspace service list +systemctl --user status whattoplay + +# Logs anzeigen +journalctl --user -u whattoplay -f + +# Neustarten (z.B. nach Code-Update) +systemctl --user restart whattoplay + +# Stoppen / Starten +systemctl --user stop whattoplay +systemctl --user start whattoplay +``` + +## 3. Frontend deployen + +### Lokal bauen und hochladen + +```bash +# .env.production anlegen (einmalig) +echo 'VITE_API_URL=https://wtp.uber.space' > .env.production +echo 'VITE_BASE_PATH=/' >> .env.production + +# Build npm run build -# Upload nach Uberspace -rsync -avz dist/ @.uberspace.de:~/html/ +# Upload +rsync -avz --delete dist/ wtp:~/html/ +``` -# Oder direkt auf Uberspace builden: +### Oder direkt auf dem Uberspace bauen + +```bash +ssh wtp cd ~/whattoplay npm install npm run build cp -r dist/* ~/html/ ``` -## 3. Vite Config anpassen +### SPA-Routing (.htaccess) -Für Uberspace Deployment brauchst du keine spezielle `base`: +Damit React Router bei direktem Aufruf von Unterseiten funktioniert, muss eine `.htaccess` im Document Root liegen: -```typescript -// vite.config.ts -export default defineConfig({ - // base: "/whattoplay/", // <- entfernen für Uberspace - plugins: [react()], - // ... -}); +```apache + + RewriteEngine On + RewriteBase / + + # Don't rewrite files or directories + RewriteCond %{REQUEST_FILENAME} !-f + RewriteCond %{REQUEST_FILENAME} !-d + + # Don't rewrite API calls + RewriteCond %{REQUEST_URI} !^/api/ + + # Rewrite everything else to index.html + RewriteRule . /index.html [L] + ``` -## 4. App Config anpassen +Die Datei liegt bereits in `public/.htaccess` und wird beim Build automatisch nach `dist/` kopiert. -Für Development kannst du die `.env` nutzen: +## 4. Updates deployen ```bash -# .env.development -VITE_API_URL=http://localhost:3000 +# Lokal +npm run build +rsync -avz --delete dist/ wtp:~/html/ -# .env.production -VITE_API_URL=https://your-username.uber.space +# Backend (auf dem Server) +ssh wtp +cd ~/whattoplay && git pull +cd server && npm install +systemctl --user restart whattoplay ``` -Dann in `ConfigService.ts`: - -```typescript -static getApiUrl(endpoint: string): string { - const baseUrl = import.meta.env.VITE_API_URL || ''; - return `${baseUrl}${endpoint}`; -} -``` - -## 5. Domain einrichten (optional) - -Falls du eine eigene Domain hast: +## 5. Domain (optional) ```bash uberspace web domain add your-domain.com ``` -Dann DNS Records setzen: +DNS Records setzen: ``` -A @ +A @ CNAME www .uberspace.de ``` -## Logs +Die Server-IP findest du mit `uberspace web domain list`. -```bash -# Server logs -supervisorctl tail whattoplay-server +## Aktueller Stand -# Webserver logs -tail -f ~/logs/webserver/access_log -``` - -## Updates deployen - -```bash -# Backend update -cd ~/whattoplay -git pull -cd server -npm install -supervisorctl restart whattoplay-server - -# PWA update -cd ~/whattoplay -npm install -npm run build -cp -r dist/* ~/html/ -``` +| Komponente | Wert | +|-----------|------| +| Server | larissa.uberspace.de | +| User | wtp | +| Domain | wtp.uber.space | +| Frontend | ~/html/ → /var/www/virtual/wtp/html/ (Caddy → Apache) | +| Backend | ~/whattoplay/server/ (Express :3000) | +| Service | systemd user service `whattoplay` | +| Web-Routing | `/` → Apache, `/api` → Port 3000 (prefix remove) | ## Kosten -Uberspace: ~5€/Monat (pay what you want, Minimum 1€) - -- Unbegrenzter Traffic -- SSH Zugriff -- Node.js, PHP, Python, Ruby Support -- MySQL/PostgreSQL Datenbanken -- Deutlich einfacher als Cloudflare Workers Setup +Uberspace: ab 1€/Monat (pay what you want, empfohlen ~5€) diff --git a/app.js b/app.js deleted file mode 100644 index 63765f3..0000000 --- a/app.js +++ /dev/null @@ -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) => ` -
-

${item.label}

-

${item.value}

-
- `, - ) - .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 = ''; - 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 = `
${error.message}
`; - } -}; - -init(); diff --git a/config.local.json.example b/config.local.json.example deleted file mode 100644 index d9fb353..0000000 --- a/config.local.json.example +++ /dev/null @@ -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" - } -} diff --git a/docs/BLIZZARD-SETUP.md b/docs/BLIZZARD-SETUP.md deleted file mode 100644 index 8145e39..0000000 --- a/docs/BLIZZARD-SETUP.md +++ /dev/null @@ -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) diff --git a/docs/FEATURES-OVERVIEW.md b/docs/FEATURES-OVERVIEW.md deleted file mode 100644 index 826d1d6..0000000 --- a/docs/FEATURES-OVERVIEW.md +++ /dev/null @@ -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({}); - -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 = { - 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 diff --git a/docs/GOG-SETUP.md b/docs/GOG-SETUP.md deleted file mode 100644 index 1bba248..0000000 --- a/docs/GOG-SETUP.md +++ /dev/null @@ -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` diff --git a/index.html b/index.html index 8697a8e..6c534cc 100644 --- a/index.html +++ b/index.html @@ -3,12 +3,14 @@ - + + + WhatToPlay diff --git a/package.json b/package.json index ba0d272..d6b0a85 100644 --- a/package.json +++ b/package.json @@ -7,15 +7,7 @@ "dev": "vite", "build": "vite build", "preview": "vite preview", - "test": "node --test server/**/*.test.mjs", - "oauth": "node workers/oauth-proxy.mjs", - "worker:dev": "wrangler dev --config workers/wrangler.toml", - "worker:deploy": "wrangler deploy --config workers/wrangler.toml", - "fetch:steam": "node scripts/fetch-steam.mjs", - "fetch:gog": "node scripts/fetch-gog.mjs", - "fetch:epic": "node scripts/fetch-epic.mjs", - "fetch:amazon": "node scripts/fetch-amazon.mjs", - "fetch:all": "node scripts/fetch-all.mjs" + "test": "node --test server/**/*.test.mjs" }, "dependencies": { "@ionic/react": "^8.0.0", @@ -35,7 +27,6 @@ "@types/react-router-dom": "^5.3.3", "@vitejs/plugin-react": "^4.2.1", "typescript": "^5.3.3", - "vite": "^5.0.0", - "wrangler": "^4.63.0" + "vite": "^5.0.0" } } diff --git a/public/.htaccess b/public/.htaccess index 08e18e5..3f0c8da 100644 --- a/public/.htaccess +++ b/public/.htaccess @@ -3,15 +3,17 @@ RewriteBase / # Don't rewrite files or directories - RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-d # Don't rewrite API calls - RewriteCond %{REQUEST_URI} !^/api/ # Rewrite everything else to index.html - RewriteRule . /index.html [L] + +# No cache for manifest and index (PWA updates) + + Header set Cache-Control "no-cache, must-revalidate" + diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..15b7e6aa3c1273d862fe395720851b5bcf835ce1 GIT binary patch literal 3542 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 b2MW!3Y3ky7E5)ArQKK<3FxN*w-Q)iS4M@){ literal 0 HcmV?d00001 diff --git a/public/icon-192.png b/public/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..914b685f935f260000d2a947557582fcd37c3ddd GIT binary patch literal 3582 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 literal 0 HcmV?d00001 diff --git a/public/icon-512.png b/public/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..cd91d41797c28d753e4a862ef3006d183a4faa93 GIT binary patch literal 9951 zcmd^lX*|{6_wVN%^IYa+%9s$oC62iWhYTUJkkmJYgUsS6oG3%bn7K@y$dJhFWX@PQ zBJ;^q=6QD9ef<9Se;?h~eR3b&`|<$$v-a9+t-aP>@4fblG&a;>qUWK9AczU6t7QT~ zaPTJ_qNN7EHvI_u;1~5BeH|_6nEaDnUl0#Lf)G;cs+nK<(wKjWt#jzg+KSDXRlvEs zF!ocl^l!svVt!YZ(s^=dX*qkGd2HLZz}M!eJ^Rlc-70yJtM^h~T#8bP5mvg`9K-OU zit%I4@2BXS&r>>BPkoHbtyRi+yc2l*j`EsrO>NCQ!N$9HXG6tbw`QSkF4@>_rDWV| zJ{`?7)il(vj9+|(A3wcK?ZXn9LD$VF+#MBarNJfpq@OcBH1n>%C{BKp;_Q_p(gO!U zE%P>3$;hIYuQ>2du%sj*{ev3IHmf)4cB6;1IEqghFYZ|Qh9k6?nMIj-BYD{;A%{7_ zcM+Um=1WwF1j?S5s0MF*|9InJFCzu+Sp^#do;#p(Exat=qP61mk~P>kr1G}Xb17<8rZZmP+*Lj$V#z{nsVrC0Xq*ROknjSXQ>DJ411=;TV5|}pxN3r*=3>tk; zciXsk*-Jy_rLeizYSgvVeyXb<%6i8|{#y(GW)oA(=)2wr8&6YuA%cVzGm4VlySw>a z@J_P+bPM*!n%jtO72!}ouo6KIH@U#?Tg;w|#}mkNbE;mQ zQzI~Eah}g(mQ5-SUt59&hsOhjw)phL8<(xwClZx9EUkS{tZGVpN_#&yMhI#0K`6-8 z;d#FVrh#BpnI{Tt`gC`lh?j@mP9k&lwj4d}-(Isv3?*Ex{OO5-YPecH$51YNt|yDk z;7OXHwY8ir@uoDttQU$P+tbQ5{0=v14^=-T=woyDuM2URZ9AzIL=Xv%*|)@o8JvuE z1e4+7Cksco0lVQO)%btrF=OkX$1+j1@Ih98XJ94~`7b5EPr08tkI^xW(6xpFxLPT+ zwW~7mnG#;QSi;?q^ND_56QiYyx6pT~vI8AeD63AVBwlzXf}~UBBUXj_6Wb+xGN(-; zQ?52)^l{r)d#sslKgD6T?^PA0*50(+PylC(lm37&sXw*e9f`HRv7$RUoO!+MuSKov z{5fw`oQTq{#Vht0tpW<}v4#y}FXn`_r>rk$V&NUbnJVuw5Mx{*^q z`!W}tE-`x>lj|Jv? zZxY#DPI~T;50tn$n%H57ziqXwgUwC1;*9Wf4MC{98esl-z^;MtVcX+0Bw=%IY~ZU#CX_ z(U6lud5}$md`Lx^L>?W?MihvBTnn>+w3T)l$=1sI*x#|*qRt`v25_QUiOma^Q}J@)r&4t%`X1Dst6!ABO!x|KAR zad`d1*?~{+(kbz&m(k5qUt*5{y`H%@bJ@zUr=|QjlZ!Qso5xX&ymUqC>$A#=wMk5E z_3&@?=a==S&t(_RT4F^c9#Sxt8RhXN#m;@1vWGqIZU)YoYd*jswzf0%WVOm1s=4?) zFyb6PW&RDtBoSoiutV4rn%Qujwtz|Su}Rj;14JHfz<@3^Hg+!52^^!~_!7>LYMudd z4qwf4dS3rCu{r8H|K(YFysmf%`3OKRy$$qZ4MF^Trfm;9-;Ns_@I99K*Z4dVn@}$< zIo3VfEn8Yo^Rfp1O*L|dI4y%Tsr{7~h*m$l1j`YCKM%@%;~|1H@p>tTasLbg^(6)> z`PWYnXwQgwoW*r~H*kX2m1?s2OxPQS3vU3uy&hiB zT+~LGRg&7SSU>?+R(;YBMUgJP%0AwnO_dF0J@8Y)E8SOOLC{3tE9W-n_~0qGWBL^y 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ν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 + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/manifest.json b/public/manifest.json index c7a2cb3..d4c7408 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -1,12 +1,30 @@ { - "name": "WhatToPlay - Game Library Manager", + "name": "WhatToPlay", "short_name": "WhatToPlay", "description": "Verwalte deine Spielebibliothek und entdecke neue Spiele", - "start_url": "/whattoplay/", - "scope": "/whattoplay/", + "start_url": "/", + "scope": "/", "display": "standalone", "orientation": "portrait", - "background_color": "#ffffff", - "theme_color": "#3880ff", - "categories": ["games", "entertainment", "utilities"] + "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" + } + ] } diff --git a/scripts/fetch-steam.mjs b/scripts/fetch-steam.mjs deleted file mode 100644 index 94a1853..0000000 --- a/scripts/fetch-steam.mjs +++ /dev/null @@ -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); -}); diff --git a/scripts/steam-cli.mjs b/scripts/steam-cli.mjs deleted file mode 100644 index 1254617..0000000 --- a/scripts/steam-cli.mjs +++ /dev/null @@ -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 "); - 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(); diff --git a/scripts/test-api.mjs b/scripts/test-api.mjs deleted file mode 100644 index 9df28ec..0000000 --- a/scripts/test-api.mjs +++ /dev/null @@ -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); diff --git a/scripts/test-backend.mjs b/scripts/test-backend.mjs deleted file mode 100644 index 3d6929a..0000000 --- a/scripts/test-backend.mjs +++ /dev/null @@ -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)); diff --git a/scripts/test-config-load.mjs b/scripts/test-config-load.mjs deleted file mode 100644 index 82e2a54..0000000 --- a/scripts/test-config-load.mjs +++ /dev/null @@ -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); -} diff --git a/server/assets-api.mjs b/server/assets-api.mjs deleted file mode 100644 index aa21b5c..0000000 --- a/server/assets-api.mjs +++ /dev/null @@ -1,140 +0,0 @@ -/** - * Assets API - Lazy-Caching von Game-Assets (Header-Images etc.) - * Beim ersten Abruf: Download von Steam CDN → Disk-Cache → Serve - * Danach: direkt von Disk - */ - -import { readFile, writeFile, mkdir } from "node:fs/promises"; -import { existsSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { dirname, join } from "node:path"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); -const DATA_DIR = join(__dirname, "..", "data", "games"); - -const STEAM_CDN = "https://cdn.cloudflare.steamstatic.com/steam/apps"; - -// 1x1 transparent PNG as fallback -const PLACEHOLDER_PNG = Buffer.from( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVQI12NgAAIABQABNjN9GQAAAAlwSFlzAAAWJQAAFiUBSVIk8AAAAA0lEQVQI12P4z8BQDwAEgAF/QualIQAAAABJRU5ErkJggg==", - "base64", -); - -function parseGameId(gameId) { - const match = gameId.match(/^(\w+)-(.+)$/); - if (!match) return null; - return { source: match[1], sourceId: match[2] }; -} - -function getCdnUrl(source, sourceId) { - if (source === "steam") { - return `${STEAM_CDN}/${sourceId}/header.jpg`; - } - return null; -} - -async function ensureGameDir(gameId) { - const dir = join(DATA_DIR, gameId); - await mkdir(dir, { recursive: true }); - return dir; -} - -async function writeMetaJson(gameDir, gameId, parsed) { - const metaPath = join(gameDir, "meta.json"); - if (existsSync(metaPath)) return; - - const meta = { - id: gameId, - source: parsed.source, - sourceId: parsed.sourceId, - headerUrl: getCdnUrl(parsed.source, parsed.sourceId), - }; - await writeFile(metaPath, JSON.stringify(meta, null, 2) + "\n", "utf-8"); -} - -async function downloadAndCache(cdnUrl, cachePath) { - const response = await fetch(cdnUrl); - if (!response.ok) return false; - - const buffer = Buffer.from(await response.arrayBuffer()); - await writeFile(cachePath, buffer); - return true; -} - -/** - * Handler: GET /api/games/{gameId}/header - */ -export async function handleGameAsset(req, res) { - if (req.method !== "GET") { - res.statusCode = 405; - res.end("Method Not Allowed"); - return; - } - - const url = req.url ?? ""; - const match = url.match(/^\/api\/games\/([^/]+)\/header/); - if (!match) { - res.statusCode = 400; - res.end("Bad Request"); - return; - } - - const gameId = match[1]; - const parsed = parseGameId(gameId); - if (!parsed) { - res.statusCode = 400; - res.end("Invalid game ID format"); - return; - } - - const gameDir = join(DATA_DIR, gameId); - const cachePath = join(gameDir, "header.jpg"); - - // Serve from cache if available - if (existsSync(cachePath)) { - try { - const data = await readFile(cachePath); - res.statusCode = 200; - res.setHeader("Content-Type", "image/jpeg"); - res.setHeader("Cache-Control", "public, max-age=86400"); - res.end(data); - return; - } catch { - // Fall through to download - } - } - - // Download from CDN - const cdnUrl = getCdnUrl(parsed.source, parsed.sourceId); - if (!cdnUrl) { - res.statusCode = 200; - res.setHeader("Content-Type", "image/png"); - res.end(PLACEHOLDER_PNG); - return; - } - - try { - await ensureGameDir(gameId); - const success = await downloadAndCache(cdnUrl, cachePath); - - if (success) { - // Write meta.json alongside - await writeMetaJson(gameDir, gameId, parsed).catch(() => {}); - - const data = await readFile(cachePath); - res.statusCode = 200; - res.setHeader("Content-Type", "image/jpeg"); - res.setHeader("Cache-Control", "public, max-age=86400"); - res.end(data); - } else { - res.statusCode = 200; - res.setHeader("Content-Type", "image/png"); - res.end(PLACEHOLDER_PNG); - } - } catch { - res.statusCode = 200; - res.setHeader("Content-Type", "image/png"); - res.end(PLACEHOLDER_PNG); - } -} diff --git a/server/steam-api.mjs b/server/steam-api.mjs index b7f95b3..c1c4430 100644 --- a/server/steam-api.mjs +++ b/server/steam-api.mjs @@ -55,38 +55,3 @@ export async function handleSteamRefresh(req, res) { } }); } - -/** - * Config Loader - lädt config.local.json für Test-Modus - */ -export async function handleConfigLoad(req, res) { - if (req.method !== "GET") { - res.statusCode = 405; - res.end("Method Not Allowed"); - return; - } - - try { - const { readFile } = await import("node:fs/promises"); - const { fileURLToPath } = await import("node:url"); - const { dirname, join } = await import("node:path"); - - const __filename = fileURLToPath(import.meta.url); - const __dirname = dirname(__filename); - const configPath = join(__dirname, "..", "config.local.json"); - - const configData = await readFile(configPath, "utf-8"); - const config = JSON.parse(configData); - - res.statusCode = 200; - res.setHeader("Content-Type", "application/json"); - res.end(JSON.stringify(config)); - } catch (error) { - res.statusCode = 404; - res.end( - JSON.stringify({ - error: "config.local.json nicht gefunden", - }), - ); - } -} diff --git a/src/data/tutorials.ts b/src/data/tutorials.ts new file mode 100644 index 0000000..024209c --- /dev/null +++ b/src/data/tutorials.ts @@ -0,0 +1,210 @@ +import { + cloudOutline, + gameControllerOutline, + globeOutline, + shieldOutline, + storefrontOutline, +} from "ionicons/icons"; + +export interface TutorialStep { + title: string; + description: string; + code?: string; + hint?: string; +} + +export interface Tutorial { + title: string; + icon: string; + steps: TutorialStep[]; + tips: string[]; +} + +export const TUTORIALS: Record = { + steam: { + title: "Steam API Key & ID einrichten", + icon: gameControllerOutline, + steps: [ + { + title: "1. Gehe zu Steam Web API", + description: + "Öffne https://steamcommunity.com/dev/apikey in deinem Browser", + code: "https://steamcommunity.com/dev/apikey", + }, + { + title: "2. Login & Registrierung", + description: + "Falls nötig, akzeptiere die Vereinbarungen und registriere dich", + hint: "Du brauchst einen Steam Account mit mindestens 5€ Spielezeit", + }, + { + title: "3. API Key kopieren", + description: "Kopiere deinen generierten API Key aus dem Textfeld", + hint: "Halte diesen Key privat! Teile ihn nicht öffentlich!", + }, + { + title: "4. Steam ID finden", + description: "Gehe zu https://www.steamcommunity.com/", + code: "https://www.steamcommunity.com/", + }, + { + title: "5. Profil öffnen", + description: "Klicke auf deinen Namen oben rechts", + hint: "Die URL sollte /profiles/[STEAM_ID]/ enthalten", + }, + { + title: "6. Steam ID kopieren", + description: "Kopiere die Nummern aus der URL (z.B. 76561197960434622)", + hint: "Das ist eine lange Nummer, keine Kurzform", + }, + ], + tips: [ + "Der API Key wird automatisch alle 24 Stunden zurückgesetzt", + "Dein Game-Profil muss auf 'Öffentlich' gestellt sein", + "Private Games werden nicht angezeigt", + ], + }, + + gog: { + title: "GOG Galaxy Login", + icon: globeOutline, + steps: [ + { + title: "1. OAuth Proxy starten", + description: "Im Terminal: npm run oauth", + code: "npm run oauth", + hint: "Startet lokalen OAuth Proxy auf Port 3001", + }, + { + title: "2. Mit GOG einloggen", + description: "Klicke auf 'Mit GOG einloggen' in der App", + hint: "Du wirst zu GOG weitergeleitet", + }, + { + title: "3. Bei GOG anmelden", + description: "Melde dich mit deinen GOG Zugangsdaten an", + hint: "Akzeptiere die Berechtigungen", + }, + { + title: "4. Automatisch verbunden", + description: "Nach der Anmeldung wirst du zurück zur App geleitet", + hint: "Dein Token wird automatisch gespeichert", + }, + ], + tips: [ + "Der OAuth Proxy muss laufen (npm run oauth)", + "Tokens werden automatisch erneuert", + "Für Production: Deploy den Worker zu Cloudflare", + ], + }, + + epic: { + title: "Epic Games (Manueller Import)", + icon: shieldOutline, + steps: [ + { + title: "1. Keine API verfügbar", + description: "Epic Games hat KEINE öffentliche API für Bibliotheken", + hint: "Auch OAuth ist nicht möglich", + }, + { + title: "2. JSON-Datei erstellen", + description: "Erstelle eine JSON-Datei mit deinen Spielen", + code: `[ + {"name": "Fortnite", "appId": "fortnite"}, + {"name": "Rocket League", "appId": "rocket-league"} +]`, + }, + { + title: "3. Datei hochladen", + description: "Klicke auf 'Games JSON importieren' und wähle deine Datei", + hint: "Unterstützt auch {games: [...]} Format", + }, + ], + tips: [ + "Epic erlaubt keinen API-Zugriff auf Libraries", + "Manuelle Import ist die einzige Option", + "Spiele-Namen aus Epic Launcher abschreiben", + ], + }, + + amazon: { + title: "Amazon Games (Manueller Import)", + icon: storefrontOutline, + steps: [ + { + title: "1. Keine API verfügbar", + description: "Amazon hat KEINE öffentliche API für Prime Gaming", + hint: "Auch OAuth ist nicht möglich", + }, + { + title: "2. Spiele-Liste erstellen", + description: "Gehe zu gaming.amazon.com und notiere deine Spiele", + code: "https://gaming.amazon.com/home", + }, + { + title: "3. JSON-Datei erstellen", + description: "Erstelle eine JSON-Datei mit deinen Spielen", + code: `[ + {"name": "Fallout 76", "source": "prime"}, + {"name": "Control", "source": "prime"} +]`, + }, + { + title: "4. Datei hochladen", + description: "Klicke auf 'Games JSON importieren' und wähle deine Datei", + hint: "source: 'prime' oder 'luna'", + }, + ], + tips: [ + "Amazon erlaubt keinen API-Zugriff", + "Manuelle Import ist die einzige Option", + "Prime Gaming Spiele wechseln monatlich", + ], + }, + + blizzard: { + title: "Blizzard OAuth Setup", + icon: cloudOutline, + steps: [ + { + title: "1. Battle.net Developers", + description: "Gehe zu https://develop.battle.net und melde dich an", + code: "https://develop.battle.net", + }, + { + title: "2. API-Zugang anfordern", + description: "Klicke auf 'Create Application' oder gehe zu API Access", + hint: "Du brauchst einen Account mit mindestens einem Blizzard-Spiel", + }, + { + title: "3. App registrieren", + description: + "Gebe einen Namen ein (z.B. 'WhatToPlay') und akzeptiere Terms", + hint: "Das ist für deine persönliche Nutzung", + }, + { + title: "4. Client ID kopieren", + description: "Kopiere die 'Client ID' aus deiner API-Anwendung", + code: "Client ID: xxx-xxx-xxx", + }, + { + title: "5. Client Secret kopieren", + description: + "Generiere und kopiere das 'Client Secret' (einmalig sichtbar!)", + hint: "Speichere es sicher! Du kannst es später nicht mehr sehen!", + }, + { + title: "6. OAuth Callback URL", + description: + "Setze die Redirect URI auf https://whattoplay.local/auth/callback", + hint: "Dies ist für lokale Entwicklung", + }, + ], + tips: [ + "Blizzard supports: WoW, Diablo, Overwatch, StarCraft, Heroes", + "Für Production brauchst du ein Backend für OAuth", + "Der API Access kann bis zu 24 Stunden dauern", + ], + }, +}; diff --git a/src/pages/Discover/DiscoverPage.tsx b/src/pages/Discover/DiscoverPage.tsx index e057930..ce96a5b 100644 --- a/src/pages/Discover/DiscoverPage.tsx +++ b/src/pages/Discover/DiscoverPage.tsx @@ -20,12 +20,10 @@ import { type SyntheticEvent, } from "react"; import TinderCard from "react-tinder-card"; -import { db, type Game } from "../../services/Database"; +import { db, type Game, type Playlist } from "../../services/Database"; import "./DiscoverPage.css"; -type SwipeResults = Record; - const formatDate = (value?: string | null) => { if (!value) return "-"; return new Date(value).toLocaleDateString("de"); @@ -39,7 +37,7 @@ const formatPlaytime = (hours?: number) => { export default function DiscoverPage() { const [games, setGames] = useState([]); - const [swipeResults, setSwipeResults] = useState({}); + const [playlists, setPlaylists] = useState([]); const [loading, setLoading] = useState(true); const [showResetAlert, setShowResetAlert] = useState(false); @@ -51,14 +49,14 @@ export default function DiscoverPage() { const load = async () => { try { setLoading(true); - const [dbGames, savedResults] = await Promise.all([ + const [dbGames, dbPlaylists] = await Promise.all([ db.getGames(), - db.getSetting("swipe_results"), + db.getPlaylists(), ]); if (active) { setGames(dbGames); - setSwipeResults(savedResults || {}); + setPlaylists(dbPlaylists); } } finally { if (active) setLoading(false); @@ -71,27 +69,20 @@ export default function DiscoverPage() { }; }, []); - const unseenGames = useMemo( - () => games.filter((g) => !(g.id in swipeResults)), - [games, swipeResults], - ); + const unseenGames = useMemo(() => { + const allSwipedGameIds = new Set(playlists.flatMap((p) => p.gameIds)); + return games.filter((g) => !allSwipedGameIds.has(g.id)); + }, [games, playlists]); - const saveSwipe = useCallback( - async (gameId: string, decision: "skip" | "interested") => { - const updated = { ...swipeResults, [gameId]: decision }; - setSwipeResults(updated); - await db.setSetting("swipe_results", updated); - }, - [swipeResults], - ); + const handleSwipe = useCallback(async (direction: string, gameId: string) => { + const playlistId = + direction === "right" ? "want-to-play" : "not-interesting"; + await db.addGameToPlaylist(playlistId, gameId); - const handleSwipe = useCallback( - (direction: string, gameId: string) => { - const decision = direction === "right" ? "interested" : "skip"; - saveSwipe(gameId, decision); - }, - [saveSwipe], - ); + // Reload playlists to update UI + const updatedPlaylists = await db.getPlaylists(); + setPlaylists(updatedPlaylists); + }, []); const swipeButton = useCallback( (direction: "left" | "right") => { @@ -107,15 +98,28 @@ export default function DiscoverPage() { ); const handleReset = useCallback(async () => { - setSwipeResults({}); - await db.setSetting("swipe_results", {}); - }, []); + // Clear both playlists + const wantToPlay = playlists.find((p) => p.id === "want-to-play"); + const notInteresting = playlists.find((p) => p.id === "not-interesting"); - const totalSwiped = Object.keys(swipeResults).length; - const interestedCount = Object.values(swipeResults).filter( - (v) => v === "interested", - ).length; - const skippedCount = totalSwiped - interestedCount; + if (wantToPlay) { + await db.createPlaylist({ ...wantToPlay, gameIds: [] }); + } + if (notInteresting) { + await db.createPlaylist({ ...notInteresting, gameIds: [] }); + } + + // Reload playlists + const updatedPlaylists = await db.getPlaylists(); + setPlaylists(updatedPlaylists); + }, [playlists]); + + const wantToPlay = playlists.find((p) => p.id === "want-to-play"); + const notInteresting = playlists.find((p) => p.id === "not-interesting"); + const totalSwiped = + (wantToPlay?.gameIds.length || 0) + (notInteresting?.gameIds.length || 0); + const interestedCount = wantToPlay?.gameIds.length || 0; + const skippedCount = notInteresting?.gameIds.length || 0; return ( diff --git a/src/pages/Playlists/PlaylistsPage.css b/src/pages/Playlists/PlaylistsPage.css index 09052bb..060a581 100644 --- a/src/pages/Playlists/PlaylistsPage.css +++ b/src/pages/Playlists/PlaylistsPage.css @@ -4,20 +4,35 @@ --padding-end: 16px; } -.playlists-placeholder { - background: #ffffff; - border-radius: 20px; - padding: 2rem; - text-align: center; - box-shadow: 0 10px 24px rgba(0, 0, 0, 0.08); +.playlists-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + gap: 1rem; } -.playlists-placeholder h2 { - margin: 0 0 0.5rem; - font-size: 1.5rem; +.playlists-container { + padding-bottom: 20px; } -.playlists-placeholder p { - margin: 0; +.playlists-empty { color: #8e8e93; + font-style: italic; + margin: 0; +} + +ion-card { + margin-bottom: 16px; +} + +ion-card-title { + display: flex; + align-items: center; +} + +ion-item { + --padding-start: 16px; + --padding-end: 16px; } diff --git a/src/pages/Playlists/PlaylistsPage.tsx b/src/pages/Playlists/PlaylistsPage.tsx index 0f6dc00..fb87f06 100644 --- a/src/pages/Playlists/PlaylistsPage.tsx +++ b/src/pages/Playlists/PlaylistsPage.tsx @@ -4,11 +4,64 @@ import { IonPage, IonTitle, IonToolbar, + IonList, + IonItem, + IonLabel, + IonCard, + IonCardHeader, + IonCardTitle, + IonCardContent, + IonBadge, + IonIcon, + IonSpinner, } from "@ionic/react"; +import { useState, useEffect } from "react"; +import { gameControllerOutline, closeCircleOutline } from "ionicons/icons"; +import { db, type Playlist, type Game } from "../../services/Database"; import "./PlaylistsPage.css"; export default function PlaylistsPage() { + const [playlists, setPlaylists] = useState([]); + const [games, setGames] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let active = true; + + const load = async () => { + try { + setLoading(true); + const [dbPlaylists, dbGames] = await Promise.all([ + db.getPlaylists(), + db.getGames(), + ]); + + if (active) { + setPlaylists(dbPlaylists); + setGames(dbGames); + } + } finally { + if (active) setLoading(false); + } + }; + + load(); + return () => { + active = false; + }; + }, []); + + const getGameById = (gameId: string) => { + return games.find((g) => g.id === gameId); + }; + + const handleRemoveGame = async (playlistId: string, gameId: string) => { + await db.removeGameFromPlaylist(playlistId, gameId); + const updatedPlaylists = await db.getPlaylists(); + setPlaylists(updatedPlaylists); + }; + return ( @@ -23,10 +76,71 @@ export default function PlaylistsPage() { -
-

Spieleplaylists

-

Erstelle und teile kuratierte Playlists deiner Lieblingsspiele.

-
+ {loading ? ( +
+ +
+ ) : ( +
+ {playlists.map((playlist) => ( + + + + {playlist.name} + + {playlist.gameIds.length} + + + + + {playlist.gameIds.length === 0 ? ( +

+ Keine Spiele in dieser Playlist +

+ ) : ( + + {playlist.gameIds.map((gameId) => { + const game = getGameById(gameId); + if (!game) return null; + return ( + + + +

{game.title}

+ {game.source && ( +

+ + {game.source} + +

+ )} +
+ + handleRemoveGame(playlist.id, gameId) + } + style={{ cursor: "pointer" }} + /> +
+ ); + })} +
+ )} +
+
+ ))} +
+ )}
); diff --git a/src/pages/Settings/SettingsDetailPage.tsx b/src/pages/Settings/SettingsDetailPage.tsx index 2248eff..ff78d3d 100644 --- a/src/pages/Settings/SettingsDetailPage.tsx +++ b/src/pages/Settings/SettingsDetailPage.tsx @@ -63,25 +63,7 @@ export default function SettingsDetailPage() { useEffect(() => { const loadConfig = async () => { - let loadedConfig = await ConfigService.loadConfig(); - - // Test-Modus: Lade config.local.json wenn --test Parameter gesetzt - const isTestMode = new URLSearchParams(window.location.search).has( - "test", - ); - if (isTestMode) { - try { - const response = await fetch("/api/config/load"); - if (response.ok) { - const testConfig = await response.json(); - loadedConfig = { ...loadedConfig, ...testConfig }; - console.log("✓ Test-Modus: config.local.json geladen", testConfig); - } - } catch (error) { - console.warn("config.local.json konnte nicht geladen werden", error); - } - } - + const loadedConfig = await ConfigService.loadConfig(); setConfig(loadedConfig); }; diff --git a/src/services/Database.ts b/src/services/Database.ts index da7e274..f7406ee 100644 --- a/src/services/Database.ts +++ b/src/services/Database.ts @@ -48,8 +48,16 @@ export interface Game { canonicalId?: string; } +export interface Playlist { + id: string; + name: string; + gameIds: string[]; + isStatic: boolean; + createdAt: string; +} + const DB_NAME = "whattoplay"; -const DB_VERSION = 1; +const DB_VERSION = 2; class Database { private db: IDBDatabase | null = null; @@ -80,6 +88,11 @@ class Database { db.createObjectStore("settings", { keyPath: "key" }); } + // Playlists Store + if (!db.objectStoreNames.contains("playlists")) { + db.createObjectStore("playlists", { keyPath: "id" }); + } + // Sync Log (für zukünftige Cloud-Sync) if (!db.objectStoreNames.contains("syncLog")) { db.createObjectStore("syncLog", { @@ -91,6 +104,7 @@ class Database { request.onsuccess = () => { this.db = request.result; + this.initStaticPlaylists(); resolve(); }; }); @@ -198,18 +212,106 @@ class Database { return new Promise((resolve, reject) => { const tx = this.db!.transaction( - ["config", "games", "settings", "syncLog"], + ["config", "games", "settings", "playlists", "syncLog"], "readwrite", ); - ["config", "games", "settings", "syncLog"].forEach((storeName) => { - tx.objectStore(storeName).clear(); - }); + ["config", "games", "settings", "playlists", "syncLog"].forEach( + (storeName) => { + tx.objectStore(storeName).clear(); + }, + ); tx.onerror = () => reject(tx.error); tx.oncomplete = () => resolve(); }); } + + private async initStaticPlaylists(): Promise { + const playlists = await this.getPlaylists(); + const hasWantToPlay = playlists.some((p) => p.id === "want-to-play"); + const hasNotInteresting = playlists.some((p) => p.id === "not-interesting"); + + if (!hasWantToPlay) { + await this.createPlaylist({ + id: "want-to-play", + name: "Want to Play", + gameIds: [], + isStatic: true, + createdAt: new Date().toISOString(), + }); + } + + if (!hasNotInteresting) { + await this.createPlaylist({ + id: "not-interesting", + name: "Not Interesting", + gameIds: [], + isStatic: true, + createdAt: new Date().toISOString(), + }); + } + } + + async getPlaylists(): Promise { + if (!this.db) await this.init(); + + return new Promise((resolve, reject) => { + const tx = this.db!.transaction("playlists", "readonly"); + const store = tx.objectStore("playlists"); + const request = store.getAll(); + + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result || []); + }); + } + + async getPlaylist(id: string): Promise { + if (!this.db) await this.init(); + + return new Promise((resolve, reject) => { + const tx = this.db!.transaction("playlists", "readonly"); + const store = tx.objectStore("playlists"); + const request = store.get(id); + + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result || null); + }); + } + + async createPlaylist(playlist: Playlist): Promise { + if (!this.db) await this.init(); + + return new Promise((resolve, reject) => { + const tx = this.db!.transaction("playlists", "readwrite"); + const store = tx.objectStore("playlists"); + const request = store.put(playlist); + + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(); + }); + } + + async addGameToPlaylist(playlistId: string, gameId: string): Promise { + const playlist = await this.getPlaylist(playlistId); + if (!playlist) throw new Error(`Playlist ${playlistId} not found`); + + if (!playlist.gameIds.includes(gameId)) { + playlist.gameIds.push(gameId); + await this.createPlaylist(playlist); + } + } + + async removeGameFromPlaylist( + playlistId: string, + gameId: string, + ): Promise { + const playlist = await this.getPlaylist(playlistId); + if (!playlist) throw new Error(`Playlist ${playlistId} not found`); + + playlist.gameIds = playlist.gameIds.filter((id) => id !== gameId); + await this.createPlaylist(playlist); + } } // Singleton diff --git a/styles.css b/styles.css deleted file mode 100644 index ac468ff..0000000 --- a/styles.css +++ /dev/null @@ -1,231 +0,0 @@ -@import url("https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap"); - -:root { - color-scheme: light; - font-family: "Inter", system-ui, sans-serif; - line-height: 1.5; - --bg: #f6f7fb; - --panel: #ffffff; - --text: #1c1d2a; - --muted: #5c607b; - --accent: #4b4bff; - --accent-weak: #e6e8ff; - --border: #e0e3f2; - --shadow: 0 15px 40px rgba(28, 29, 42, 0.08); -} - -* { - box-sizing: border-box; - margin: 0; - padding: 0; -} - -body { - background: var(--bg); - color: var(--text); - min-height: 100vh; -} - -.app-header { - display: flex; - justify-content: space-between; - align-items: center; - gap: 2rem; - padding: 3.5rem 6vw 2rem; -} - -.eyebrow { - text-transform: uppercase; - letter-spacing: 0.2em; - font-size: 0.75rem; - font-weight: 600; - color: var(--muted); -} - -h1 { - font-size: clamp(2rem, 3vw, 3.2rem); - margin: 0.4rem 0 0.8rem; -} - -.subtitle { - max-width: 520px; - color: var(--muted); -} - -.header-actions { - display: flex; - gap: 1rem; -} - -button, -input, -select { - font-family: inherit; -} - -.primary { - background: var(--accent); - color: white; - border: none; - padding: 0.8rem 1.5rem; - border-radius: 999px; - font-weight: 600; - box-shadow: var(--shadow); - cursor: pointer; -} - -.primary:hover { - filter: brightness(0.95); -} - -.app-main { - padding: 0 6vw 3rem; -} - -.controls { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: 1rem; - background: var(--panel); - padding: 1.4rem; - border-radius: 20px; - border: 1px solid var(--border); - box-shadow: var(--shadow); -} - -.control-group { - display: flex; - flex-direction: column; - gap: 0.4rem; -} - -label { - font-size: 0.85rem; - color: var(--muted); -} - -input, -select { - border-radius: 12px; - border: 1px solid var(--border); - padding: 0.6rem 0.8rem; - background: #fdfdff; -} - -.summary { - margin: 2rem 0 1.5rem; - display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: 1rem; -} - -.summary-card { - background: var(--panel); - border-radius: 18px; - padding: 1.2rem; - border: 1px solid var(--border); - box-shadow: var(--shadow); -} - -.summary-card h3 { - font-size: 0.95rem; - color: var(--muted); - margin-bottom: 0.4rem; -} - -.summary-card p { - font-size: 1.7rem; - font-weight: 700; -} - -.grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); - gap: 1.5rem; -} - -.card { - background: var(--panel); - border-radius: 20px; - border: 1px solid var(--border); - padding: 1.4rem; - display: flex; - flex-direction: column; - gap: 0.8rem; - box-shadow: var(--shadow); -} - -.card-header { - display: flex; - justify-content: space-between; - align-items: center; - gap: 1rem; -} - -.title { - font-size: 1.1rem; - font-weight: 600; -} - -.badge { - background: var(--accent-weak); - color: var(--accent); - font-size: 0.75rem; - padding: 0.2rem 0.6rem; - border-radius: 999px; - font-weight: 600; -} - -.meta { - font-size: 0.85rem; - color: var(--muted); -} - -.tag-list { - display: flex; - flex-wrap: wrap; - gap: 0.4rem; -} - -.tag { - background: #f1f2f8; - color: #2e3046; - padding: 0.2rem 0.6rem; - border-radius: 999px; - font-size: 0.75rem; -} - -.sources { - display: flex; - flex-direction: column; - gap: 0.3rem; -} - -.source-item { - display: flex; - justify-content: space-between; - align-items: center; - background: #f8f9fe; - border-radius: 12px; - padding: 0.4rem 0.6rem; - font-size: 0.78rem; - color: var(--muted); -} - -.source-item span { - font-weight: 600; - color: var(--text); -} - -.app-footer { - padding: 2rem 6vw 3rem; - color: var(--muted); - font-size: 0.85rem; -} - -@media (max-width: 720px) { - .app-header { - flex-direction: column; - align-items: flex-start; - } -} diff --git a/vite.config.ts b/vite.config.ts index 842ce20..a8c497c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,7 +1,6 @@ import react from "@vitejs/plugin-react"; import { defineConfig, loadEnv } from "vite"; -import { handleSteamRefresh, handleConfigLoad } from "./server/steam-api.mjs"; -import { handleGameAsset } from "./server/assets-api.mjs"; +import { handleSteamRefresh } from "./server/steam-api.mjs"; const apiMiddlewarePlugin = { name: "api-middleware", @@ -11,12 +10,6 @@ const apiMiddlewarePlugin = { if (url.startsWith("/api/steam/refresh")) { return handleSteamRefresh(req, res); } - if (url.startsWith("/api/config/load")) { - return handleConfigLoad(req, res); - } - if (url.startsWith("/api/games/")) { - return handleGameAsset(req, res); - } next(); }); }, @@ -26,9 +19,7 @@ export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), ""); return { - // GitHub Pages: /whattoplay/ - // Uberspace: / - base: env.VITE_BASE_PATH || "/whattoplay/", + base: env.VITE_BASE_PATH || "/", plugins: [react(), apiMiddlewarePlugin], server: { port: 5173,