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 0000000..15b7e6a Binary files /dev/null and b/public/apple-touch-icon.png differ diff --git a/public/icon-192.png b/public/icon-192.png new file mode 100644 index 0000000..914b685 Binary files /dev/null and b/public/icon-192.png differ diff --git a/public/icon-512.png b/public/icon-512.png new file mode 100644 index 0000000..cd91d41 Binary files /dev/null and b/public/icon-512.png differ diff --git a/public/icon.svg b/public/icon.svg new file mode 100644 index 0000000..1bb7065 --- /dev/null +++ b/public/icon.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + 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,