clean up code

This commit is contained in:
2026-02-06 23:18:13 +01:00
parent 3cac486f6f
commit 11c3f141d5
31 changed files with 677 additions and 1915 deletions

17
.gitignore vendored
View File

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

17
.vscode/tasks.json vendored
View File

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

View File

@@ -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 <username>@<servername>.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/ <username>@<servername>.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
<IfModule mod_rewrite.c>
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]
</IfModule>
```
## 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 @ <IP von uberspace>
A @ <IP von Uberspace Server>
CNAME www <servername>.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€)

279
app.js
View File

@@ -1,279 +0,0 @@
const sourcesConfigUrl = "./data/sources.json";
const state = {
allGames: [],
mergedGames: [],
search: "",
sourceFilter: "all",
sortBy: "title",
sources: [],
};
const ui = {
grid: document.getElementById("gamesGrid"),
summary: document.getElementById("summary"),
searchInput: document.getElementById("searchInput"),
sourceFilter: document.getElementById("sourceFilter"),
sortSelect: document.getElementById("sortSelect"),
refreshButton: document.getElementById("refreshButton"),
template: document.getElementById("gameCardTemplate"),
};
const normalizeTitle = (title) =>
title.toLowerCase().replace(/[:\-]/g, " ").replace(/\s+/g, " ").trim();
const toDateValue = (value) => (value ? new Date(value).getTime() : 0);
const mergeGames = (games) => {
const map = new Map();
games.forEach((game) => {
const key = game.canonicalId || normalizeTitle(game.title);
const entry = map.get(key) || {
title: game.title,
canonicalId: key,
platforms: new Set(),
sources: [],
tags: new Set(),
lastPlayed: null,
playtimeHours: 0,
};
entry.platforms.add(game.platform);
game.tags?.forEach((tag) => entry.tags.add(tag));
entry.sources.push({
name: game.source,
id: game.id,
url: game.url,
platform: game.platform,
});
if (
game.lastPlayed &&
(!entry.lastPlayed || game.lastPlayed > entry.lastPlayed)
) {
entry.lastPlayed = game.lastPlayed;
}
if (Number.isFinite(game.playtimeHours)) {
entry.playtimeHours += game.playtimeHours;
}
map.set(key, entry);
});
return Array.from(map.values()).map((entry) => ({
...entry,
platforms: Array.from(entry.platforms),
tags: Array.from(entry.tags),
}));
};
const sortGames = (games, sortBy) => {
const sorted = [...games];
sorted.sort((a, b) => {
if (sortBy === "lastPlayed") {
return toDateValue(b.lastPlayed) - toDateValue(a.lastPlayed);
}
if (sortBy === "platforms") {
return b.platforms.length - a.platforms.length;
}
return a.title.localeCompare(b.title, "de");
});
return sorted;
};
const filterGames = () => {
const query = state.search.trim().toLowerCase();
let filtered = [...state.mergedGames];
if (state.sourceFilter !== "all") {
filtered = filtered.filter((game) =>
game.sources.some((source) => source.name === state.sourceFilter),
);
}
if (query) {
filtered = filtered.filter((game) => {
const haystack = [
game.title,
...game.platforms,
...game.tags,
...game.sources.map((source) => source.name),
]
.join(" ")
.toLowerCase();
return haystack.includes(query);
});
}
return sortGames(filtered, state.sortBy);
};
const renderSummary = (games) => {
const totalGames = state.mergedGames.length;
const totalSources = state.sources.length;
const duplicates = state.allGames.length - state.mergedGames.length;
const totalPlaytime = state.allGames.reduce(
(sum, game) => sum + (game.playtimeHours || 0),
0,
);
ui.summary.innerHTML = [
{
label: "Konsolidierte Spiele",
value: totalGames,
},
{
label: "Quellen",
value: totalSources,
},
{
label: "Zusammengeführte Duplikate",
value: Math.max(duplicates, 0),
},
{
label: "Gesamte Spielzeit (h)",
value: totalPlaytime.toFixed(1),
},
]
.map(
(item) => `
<div class="summary-card">
<h3>${item.label}</h3>
<p>${item.value}</p>
</div>
`,
)
.join("");
};
const renderGames = (games) => {
ui.grid.innerHTML = "";
games.forEach((game) => {
const card = ui.template.content.cloneNode(true);
card.querySelector(".title").textContent = game.title;
card.querySelector(".badge").textContent =
`${game.platforms.length} Plattformen`;
card.querySelector(".meta").textContent = game.lastPlayed
? `Zuletzt gespielt: ${new Date(game.lastPlayed).toLocaleDateString("de")}`
: "Noch nicht gespielt";
const tagList = card.querySelector(".tag-list");
game.tags.slice(0, 4).forEach((tag) => {
const span = document.createElement("span");
span.className = "tag";
span.textContent = tag;
tagList.appendChild(span);
});
if (!game.tags.length) {
const span = document.createElement("span");
span.className = "tag";
span.textContent = "Ohne Tags";
tagList.appendChild(span);
}
const sources = card.querySelector(".sources");
game.sources.forEach((source) => {
const item = document.createElement("div");
item.className = "source-item";
const name = document.createElement("span");
name.textContent = source.name;
const details = document.createElement("p");
details.textContent = `${source.platform} · ${source.id}`;
item.append(name, details);
sources.appendChild(item);
});
ui.grid.appendChild(card);
});
};
const populateSourceFilter = () => {
ui.sourceFilter.innerHTML = '<option value="all">Alle Quellen</option>';
state.sources.forEach((source) => {
const option = document.createElement("option");
option.value = source.name;
option.textContent = source.label;
ui.sourceFilter.appendChild(option);
});
};
const updateUI = () => {
const filtered = filterGames();
renderSummary(filtered);
renderGames(filtered);
};
const loadSources = async () => {
const response = await fetch(sourcesConfigUrl);
if (!response.ok) {
throw new Error("Konnte sources.json nicht laden.");
}
const config = await response.json();
state.sources = config.sources;
const data = await Promise.all(
config.sources.map(async (source) => {
const sourceResponse = await fetch(source.file);
if (!sourceResponse.ok) {
throw new Error(`Konnte ${source.file} nicht laden.`);
}
const list = await sourceResponse.json();
return list.map((game) => ({
...game,
source: source.name,
platform: game.platform || source.platform,
}));
}),
);
state.allGames = data.flat();
state.mergedGames = mergeGames(state.allGames);
};
const attachEvents = () => {
ui.searchInput.addEventListener("input", (event) => {
state.search = event.target.value;
updateUI();
});
ui.sourceFilter.addEventListener("change", (event) => {
state.sourceFilter = event.target.value;
updateUI();
});
ui.sortSelect.addEventListener("change", (event) => {
state.sortBy = event.target.value;
updateUI();
});
ui.refreshButton.addEventListener("click", async () => {
ui.refreshButton.disabled = true;
ui.refreshButton.textContent = "Lade ...";
try {
await loadSources();
populateSourceFilter();
updateUI();
} finally {
ui.refreshButton.disabled = false;
ui.refreshButton.textContent = "Daten neu laden";
}
});
};
const init = async () => {
try {
await loadSources();
populateSourceFilter();
attachEvents();
updateUI();
} catch (error) {
ui.grid.innerHTML = `<div class="card">${error.message}</div>`;
}
};
init();

View File

@@ -1,23 +0,0 @@
{
"steam": {
"apiKey": "YOUR_STEAM_API_KEY",
"steamId": "YOUR_STEAM_ID"
},
"gog": {
"userId": "",
"accessToken": ""
},
"epic": {
"email": "",
"method": "manual"
},
"amazon": {
"email": "",
"method": "manual"
},
"blizzard": {
"clientId": "",
"clientSecret": "",
"region": "eu"
}
}

View File

@@ -1,138 +0,0 @@
# Blizzard Setup für WhatToPlay
## API OAuth Konfiguration
### 1. Battle.net Developer Portal öffnen
- Gehe zu https://develop.battle.net
- Melde dich mit deinem Battle.net Account an
### 2. Application registrieren
- Klicke auf "Create Application"
- Name: "WhatToPlay" (oder dein Projektname)
- Website: https://whattoplay.local (für Development)
- Beschreibung: "Game Library Manager"
- Akzeptiere die ToS
### 3. OAuth Credentials kopieren
Nach der Registrierung siehst du:
- **Client ID** - die öffentliche ID
- **Client Secret** - HALTEN Sie geheim! (Nur auf Server, nie im Browser!)
### 4. Redirect URI setzen
In deiner Application Settings:
```
Redirect URIs:
https://whattoplay-oauth.workers.dev/blizzard/callback (Production)
http://localhost:3000/auth/callback (Development)
```
---
## config.local.json Setup
```json
{
"blizzard": {
"clientId": "your_client_id_here",
"clientSecret": "your_client_secret_here",
"region": "eu"
}
}
```
### Region Codes:
- `us` - North America
- `eu` - Europe
- `kr` - Korea
- `tw` - Taiwan
---
## Blizzard Games, die unterstützt werden
1. **World of Warcraft** - Character-basiert
2. **Diablo III** - Hero-basiert
3. **Diablo IV** - Charakter-basiert
4. **Overwatch 2** - Account-basiert
5. **Starcraft II** - Campaign Progress
6. **Heroes of the Storm** - Character-basiert
7. **Hearthstone** - Deck-basiert
---
## Development vs Production
### Development (Lokal)
```bash
# Teste mit lokalem Token
npm run import
# Script verwendet config.local.json
```
### Production (Mit Cloudflare Worker)
```
Frontend → Cloudflare Worker → Blizzard OAuth
Token Exchange
(Client Secret sicher!)
```
Siehe: [CLOUDFLARE-WORKERS-SETUP.md](./CLOUDFLARE-WORKERS-SETUP.md)
---
## Troubleshooting
### "Client ID invalid"
- Überprüfe dass die Client ID korrekt kopiert wurde
- Stelle sicher dass du im Development Portal angemeldet bist
### "Redirect URI mismatch"
- Die Redirect URI muss exakt übereinstimmen
- Beachte Protocol (https vs http)
- Beachte Port-Nummern
### "No games found"
- Dein Account muss mindestens 1 Blizzard Game haben
- Bei Diablo III: Character muss erstellt sein
- Charaktere können bis zu 24h brauchen zum Erscheinen
### Token-Fehler in Production
- Client Secret ist abgelaufen → Neu generieren
- Überprüfe Cloudflare Worker Logs:
```bash
npx wrangler tail whattoplay-blizzard
```
---
## Sicherheit
🔒 **Wichtig:**
- **Client Secret** NIEMALS ins Frontend committen
- Nutze Cloudflare KV Store oder Environment Variables
- Token mit Ablaufdatum (expires_in) prüfen
- Token nicht in Browser LocalStorage speichern (nur Session)
---
## Links
- [Battle.net Developer Portal](https://develop.battle.net)
- [Blizzard OAuth Documentation](https://develop.battle.net/documentation/guides/using-oauth)
- [Game Data APIs](https://develop.battle.net/documentation/guides/game-data-apis)

View File

@@ -1,328 +0,0 @@
# WhatToPlay - Feature-Übersicht (Februar 2026)
## 🆕 Neue Features
### 1⃣ Settings-Tab mit Konfiguration
**Pfad**: `src/pages/Settings/SettingsPage.tsx`
```
Settings-Tab
├── 🎮 Steam Integration
│ ├── API Key Input (verborgen)
│ ├── Steam ID Input
│ └── Tutorial-Button (✨ Step-by-Step Anleitung)
├── 🌐 GOG Integration
│ ├── User ID Input
│ ├── Access Token Input (verborgen)
│ └── Tutorial für Token-Extraction
├── ⚙️ Epic Games
│ ├── E-Mail Input
│ ├── Import-Methode (Manual oder OAuth)
│ └── Info: Keine öffentliche API
├── 🔶 Amazon Games
│ ├── E-Mail Input
│ ├── Import-Methode (Manual oder OAuth)
│ └── Ähnlich wie Epic
├── ⚔️ Blizzard Entertainment
│ ├── Client ID Input (verborgen)
│ ├── Client Secret Input (verborgen)
│ ├── Region Selector (US/EU/KR/TW)
│ └── Tutorial-Button
└── 📦 Daten-Management
├── Config Exportieren (JSON Download)
├── Config Importieren (JSON Upload)
└── Alle Einstellungen löschen
```
### 2⃣ Integriertes Tutorial-System
**Pfad**: `src/components/TutorialModal.tsx`
Jeder Service hat sein eigenes Step-by-Step Tutorial:
```
Tutorial Modal
├── Steam
│ ├── API Key generieren
│ ├── Steam ID finden
│ └── 6 Schritte mit Screenshots-Links
├── GOG
│ ├── Browser DevTools öffnen
│ ├── Bearer Token kopieren
│ └── 5 Schritte mit Code-Beispiele
├── Epic Games
│ ├── Account-Setup
│ ├── JSON Export erklären
│ └── 4 Schritte, einfach
├── Amazon Games
│ ├── Prime Gaming aktivieren
│ ├── Luna erklärt
│ └── 4 Schritte
└── Blizzard
├── Developer Portal
├── OAuth Credentials
└── 6 Schritte detailliert
```
### 3⃣ ConfigService - Sichere Speicherung
**Pfad**: `src/services/ConfigService.ts`
```typescript
ConfigService
loadConfig() - Lade aus localStorage
saveConfig() - Speichere in localStorage
exportConfig() - Download als JSON
importConfig() - Upload aus JSON
backupToIndexedDB() - Redundante Speicherung
restoreFromIndexedDB() - Aus Backup zurück
validateConfig() - Prüfe auf Fehler
clearConfig() - Alles löschen
```
**Speicher-Strategie:**
- ✅ localStorage für schnellen Zugriff
- ✅ IndexedDB für Backup & Encryption-Ready
- ✅ Keine Tokens in localStorage ohne Verschlüsselung
- ✅ Export/Import für Cloud-Sync
### 4⃣ Blizzard API Integration
**Pfad**: `scripts/fetch-blizzard.mjs`
```
Supported Games:
• World of Warcraft
• Diablo III (Heroes)
• Diablo IV
• Overwatch 2
• StarCraft II
• Heroes of the Storm
• Hearthstone
Data:
• Character Name
• Level
• Class
• Hardcore Flag
• Elite Kills
• Experience
• Last Updated
```
### 5⃣ Cloudflare Workers Setup (Serverless)
**Pfad**: `docs/CLOUDFLARE-WORKERS-SETUP.md`
```
Zero Infrastructure Deployment:
Frontend (Vercel/Netlify)
Cloudflare Workers (Serverless)
OAuth Callbacks + Token Exchange
GOG Galaxy Library API
Blizzard Battle.net API
Epic Games (später)
Amazon Games (später)
✨ Benefits:
• Keine Server zu verwalten
• Kostenlos bis 100k req/Tag
• Client Secrets geschützt (Server-Side)
• CORS automatisch konfiguriert
• Weltweit verteilt
```
---
## 📁 Neue Dateien
| Datei | Beschreibung | Status |
| ------------------------------------- | --------------------------- | ------ |
| `src/pages/Settings/SettingsPage.tsx` | Settings-Tab mit Formularen | ✅ |
| `src/pages/Settings/SettingsPage.css` | Styling | ✅ |
| `src/components/TutorialModal.tsx` | Tutorial-System | ✅ |
| `src/services/ConfigService.ts` | Konfiguration speichern | ✅ |
| `scripts/fetch-blizzard.mjs` | Blizzard API Importer | ✅ |
| `docs/CLOUDFLARE-WORKERS-SETUP.md` | Worker Dokumentation | ✅ |
| `docs/BLIZZARD-SETUP.md` | Blizzard OAuth Guide | ✅ |
| `config.local.json.example` | Config Template | ✅ |
---
## 🔄 Workflow für Nutzer
### Erste Nutzung:
```
1. App öffnen → Settings-Tab
2. Auf "?" Button klicken → Tutorial Modal
3. Step-by-Step folgen
4. Credentials eingeben
5. "Speichern" klicken → localStorage
6. Daten werden automatisch synced
```
### Daten importieren:
```
1. Settings-Tab → "Config importieren"
2. Datei auswählen (whattoplay-config.json)
3. Credentials werden wiederhergestellt
4. Alle APIs neu abfragen
```
### Daten exportieren:
```
1. Settings-Tab → "Config exportieren"
2. JSON-Datei downloaded
3. Kann auf anderem Device importiert werden
4. Oder als Backup gespeichert
```
---
## 🚀 Nächste Schritte
### Phase 1: Production Ready (Jetzt)
- [x] Steam Integration
- [x] Settings-Tab
- [x] Blizzard OAuth
- [x] Cloudflare Worker Setup (dokumentiert)
### Phase 2: Backend Deployment (1-2 Wochen)
- [ ] Cloudflare Worker deployen
- [ ] GOG OAuth Callback
- [ ] Blizzard OAuth Callback
- [ ] Token Encryption in KV Store
### Phase 3: Import Features (2-4 Wochen)
- [ ] Epic Games JSON Import UI
- [ ] Amazon Games JSON Import UI
- [ ] Drag & Drop Upload
- [ ] Validierung
### Phase 4: Polish (4+ Wochen)
- [ ] Home-Page Widgets
- [ ] Playlists Feature
- [ ] Discover/Tinder UI
- [ ] PWA Setup
- [ ] iOS Testing
---
## 📊 Statistiken
| Metric | Wert |
| --------------------------- | -------------------------------------- |
| Unterstützte Plattformen | 5 (Steam, GOG, Epic, Amazon, Blizzard) |
| Settings-Formulare | 5 |
| Tutorial-Schritte | 30+ |
| Cloudflare Worker Functions | 3 (GOG, Blizzard, Validation) |
| API Endpoints | 15+ |
| LocalStorage Capacity | 5-10MB |
| IndexedDB Capacity | 50MB+ |
---
## 💡 Design Patterns
### Konfiguration speichern (Observable Pattern)
```typescript
// SettingsPage.tsx
const [config, setConfig] = useState<ServiceConfig>({});
const handleSaveConfig = (service: keyof ServiceConfig, data: any) => {
const updated = { ...config, [service]: { ...config[service], ...data } };
setConfig(updated);
ConfigService.saveConfig(updated); // → localStorage
// Optional: ConfigService.backupToIndexedDB(updated); // → Backup
};
```
### Tutorial System (Data-Driven)
```typescript
// TutorialModal.tsx - Alle Tutorials in TUTORIALS Objekt
const TUTORIALS: Record<string, Tutorial> = {
steam: { ... },
gog: { ... },
// Einfach zu erweitern!
};
```
### OAuth Flow mit Cloudflare Worker
```
Frontend initiiert:
Worker erhält Callback:
Token Exchange Server-Side:
Frontend erhält Token in URL:
ConfigService speichert Token:
Nächster API Call mit Token
```
---
## 🔐 Sicherheit
### ✅ Best Practices implementiert:
- Client Secrets in Backend nur (Cloudflare KV)
- Tokens mit Session-Speicher (nicht persistent)
- Export/Import mit Warnung
- Validation der Credentials
- CORS nur für eigene Domain
- State Parameter für CSRF
### ❌ Nicht implementiert (wäre Overkill):
- Token-Verschlüsselung in localStorage (würde Komplexität erhöhen)
- 2FA für Settings
- Audit Logs
- Rate Limiting (kommt auf Server-Side)
---
## 🎯 Gesamtziel
**Zero Infrastructure, Full-Featured:**
- Frontend: Statisch deployed (Vercel/Netlify)
- Backend: Serverless (Cloudflare Workers)
- Datenbank: Optional (Supabase/Firebase)
- Secrets: KV Store oder Environment Variables
- **Kosten**: ~$0/Monat für < 1000 User
Nutzer kann:
- ✅ Alle Credentials selbst eingeben
- ✅ Daten jederzeit exportieren/importieren
- ✅ Offline mit LocalStorage arbeiten
- ✅ Auf iOS/Web/Desktop gleiches UI
- ✅ Keine zusätzlichen Apps nötig

View File

@@ -1,144 +0,0 @@
# GOG Integration - Development Setup
## ⚠️ Wichtig: Temporäre Lösung für Development
Da wir eine **Web/iOS App** bauen, können wir keine CLI-Tools (wie `gogdl`) nutzen.
Für Production brauchen wir ein **Backend mit OAuth Flow**.
## Wie bekomme ich GOG Credentials?
### Option 1: Manuell aus Browser (Development)
1. **Öffne GOG.com (eingeloggt)**
```
https://www.gog.com
```
2. **Öffne Browser DevTools**
- Chrome/Edge: `F12` oder `Cmd+Option+I` (Mac)
- Firefox: `F12`
3. **Gehe zu Network Tab**
- Klicke auf "Network" / "Netzwerk"
- Aktiviere "Preserve log" / "Log beibehalten"
4. **Lade eine GOG Seite neu**
- Z.B. deine Library: `https://www.gog.com/account`
5. **Finde Request mit Bearer Token**
- Suche nach Requests zu `gog.com` oder `galaxy-library.gog.com`
- Klicke auf einen Request
- Gehe zu "Headers" Tab
- Kopiere den `Authorization: Bearer ...` Token
6. **Kopiere User ID**
- Suche nach Request zu `embed.gog.com/userData.json`
- Im Response findest du `"galaxyUserId": "123456789..."`
- Kopiere diese ID
7. **Trage in config.local.json ein**
```json
{
"steam": { ... },
"epic": {},
"gog": {
"userId": "DEINE_GALAXY_USER_ID",
"accessToken": "DEIN_BEARER_TOKEN"
}
}
```
### Option 2: Backend OAuth Flow (Production - TODO)
Für Production implementieren wir einen OAuth Flow:
```javascript
// Backend Endpoint (z.B. Vercel Function)
export async function POST(request) {
// 1. User zu GOG Auth redirecten
const authUrl = `https://auth.gog.com/auth?client_id=...&redirect_uri=...`;
// 2. Callback mit Code
// 3. Code gegen Access Token tauschen
const token = await fetch("https://auth.gog.com/token", {
method: "POST",
body: { code, client_secret: process.env.GOG_SECRET },
});
// 4. Token sicher speichern (z.B. encrypted in DB)
return { success: true };
}
```
## API Endpoints
### GOG Galaxy Library
```
GET https://galaxy-library.gog.com/users/{userId}/releases
Headers:
Authorization: Bearer {accessToken}
User-Agent: WhatToPlay/1.0
Response:
{
"items": [
{
"external_id": "1207658930",
"platform_id": "gog",
"date_created": 1234567890,
...
}
],
"total_count": 123,
"next_page_token": "..."
}
```
### GOG User Data
```
GET https://embed.gog.com/userData.json
Headers:
Authorization: Bearer {accessToken}
Response:
{
"userId": "...",
"galaxyUserId": "...",
"username": "...",
...
}
```
## Token Lebensdauer
- GOG Tokens laufen nach **ca. 1 Stunde** ab
- Für Development: Token regelmäßig neu kopieren
- Für Production: Refresh Token Flow implementieren
## Nächste Schritte
1. ✅ Development: Manueller Token aus Browser
2. 📝 Backend: Vercel Function für OAuth
3. 🔐 Backend: Token Refresh implementieren
4. 📱 iOS: Secure Storage für Tokens (Keychain)
## Troubleshooting
### `401 Unauthorized`
- Token abgelaufen → Neu aus Browser kopieren
- Falscher Token → Prüfe `Authorization: Bearer ...`
### `CORS Error`
- Normal im Browser (darum brauchen wir Backend)
- Development: Scripts laufen in Node.js (kein CORS)
- Production: Backend macht die Requests
### Leere Library
- Prüfe `userId` - muss `galaxyUserId` sein, nicht `userId`
- Prüfe API Endpoint - `/users/{userId}/releases`, nicht `/games`

View File

@@ -3,12 +3,14 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#3880ff" />
<meta name="theme-color" content="#0a84ff" />
<meta name="description" content="Verwalte deine Spielebibliothek und entdecke neue Spiele" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="WhatToPlay" />
<link rel="manifest" href="/manifest.json" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png" />
<title>WhatToPlay</title>
</head>
<body>

View File

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

View File

@@ -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]
</IfModule>
# No cache for manifest and index (PWA updates)
<FilesMatch "(manifest\.json|index\.html)$">
Header set Cache-Control "no-cache, must-revalidate"
</FilesMatch>

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

BIN
public/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

BIN
public/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

23
public/icon.svg Normal file
View File

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

After

Width:  |  Height:  |  Size: 907 B

View File

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

View File

@@ -1,104 +0,0 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
const loadConfig = async () => {
const configUrl = new URL("../config.local.json", import.meta.url);
try {
const raw = await readFile(configUrl, "utf-8");
return JSON.parse(raw);
} catch {
return {};
}
};
const toIsoDate = (unixSeconds) =>
unixSeconds ? new Date(unixSeconds * 1000).toISOString().slice(0, 10) : null;
const sanitizeFileName = (value) => {
const normalized = value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return normalized || "spiel";
};
const fetchOwnedGames = async ({ apiKey, steamId }) => {
const url = new URL(
"https://api.steampowered.com/IPlayerService/GetOwnedGames/v1/",
);
url.searchParams.set("key", apiKey);
url.searchParams.set("steamid", steamId);
url.searchParams.set("include_appinfo", "true");
url.searchParams.set("include_played_free_games", "true");
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Steam API Fehler: ${response.status}`);
}
const payload = await response.json();
return payload.response?.games ?? [];
};
const buildSteamEntry = (game) => ({
id: String(game.appid),
title: game.name,
platform: "PC",
lastPlayed: toIsoDate(game.rtime_last_played),
playtimeHours: Math.round((game.playtime_forever / 60) * 10) / 10,
tags: [],
url: `https://store.steampowered.com/app/${game.appid}`,
});
const buildTextFile = (entry) => {
const lines = [
`Titel: ${entry.title}`,
`Steam AppID: ${entry.id}`,
`Zuletzt gespielt: ${entry.lastPlayed ?? "-"}`,
`Spielzeit (h): ${entry.playtimeHours ?? 0}`,
`Store: ${entry.url}`,
"Quelle: steam",
];
return lines.join("\n") + "\n";
};
const writeOutputs = async (entries) => {
const dataDir = new URL("../public/data/", import.meta.url);
const textDir = new URL("../public/data/steam-text/", import.meta.url);
await mkdir(dataDir, { recursive: true });
await mkdir(textDir, { recursive: true });
const jsonPath = new URL("steam.json", dataDir);
await writeFile(jsonPath, JSON.stringify(entries, null, 2) + "\n", "utf-8");
await Promise.all(
entries.map(async (entry) => {
const fileName = `${sanitizeFileName(entry.title)}__${entry.id}.txt`;
const filePath = new URL(fileName, textDir);
await writeFile(filePath, buildTextFile(entry), "utf-8");
}),
);
};
const run = async () => {
const config = await loadConfig();
const apiKey = config.steam?.apiKey || process.env.STEAM_API_KEY;
const steamId = config.steam?.steamId || process.env.STEAM_ID;
if (!apiKey || !steamId) {
console.error(
"Bitte Steam-Zugangsdaten in config.local.json oder per Umgebungsvariablen setzen.",
);
process.exit(1);
}
const games = await fetchOwnedGames({ apiKey, steamId });
const entries = games.map(buildSteamEntry);
await writeOutputs(entries);
console.log(`Steam-Export fertig: ${entries.length} Spiele.`);
};
run().catch((error) => {
console.error(error);
process.exit(1);
});

View File

@@ -1,101 +0,0 @@
#!/usr/bin/env node
/**
* Steam CLI - Direktes Testen der Steam API
* Usage: node scripts/steam-cli.mjs [apiKey] [steamId]
*/
import { fetchSteamGames } from "../server/steam-backend.mjs";
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
async function loadConfig() {
try {
const configPath = join(__dirname, "..", "config.local.json");
const configData = await readFile(configPath, "utf-8");
return JSON.parse(configData);
} catch {
return null;
}
}
async function main() {
console.log("=".repeat(70));
console.log("Steam API CLI Test");
console.log("=".repeat(70));
// API Key und Steam ID holen (CLI-Args oder config.local.json)
let apiKey = process.argv[2];
let steamId = process.argv[3];
if (!apiKey || !steamId) {
console.log("\nKeine CLI-Args, versuche config.local.json zu laden...");
const config = await loadConfig();
if (config?.steam) {
apiKey = config.steam.apiKey;
steamId = config.steam.steamId;
console.log("✓ Credentials aus config.local.json geladen");
}
}
if (!apiKey || !steamId) {
console.error("\n❌ Fehler: API Key und Steam ID erforderlich!");
console.error("\nUsage:");
console.error(" node scripts/steam-cli.mjs <apiKey> <steamId>");
console.error(
" oder config.local.json mit steam.apiKey und steam.steamId",
);
process.exit(1);
}
console.log("\nParameter:");
console.log(" API Key:", apiKey.substring(0, 8) + "...");
console.log(" Steam ID:", steamId);
console.log("\nRufe Steam API auf...\n");
try {
const result = await fetchSteamGames(apiKey, steamId);
console.log("=".repeat(70));
console.log("✓ Erfolgreich!");
console.log("=".repeat(70));
console.log(`\nAnzahl Spiele: ${result.count}`);
if (result.count > 0) {
console.log("\nErste 5 Spiele:");
console.log("-".repeat(70));
result.games.slice(0, 5).forEach((game, idx) => {
console.log(`\n${idx + 1}. ${game.title}`);
console.log(` ID: ${game.id}`);
console.log(` Spielzeit: ${game.playtimeHours}h`);
console.log(` Zuletzt gespielt: ${game.lastPlayed || "nie"}`);
console.log(` URL: ${game.url}`);
});
console.log("\n" + "-".repeat(70));
console.log("\nKomplettes JSON (erste 3 Spiele):");
console.log(JSON.stringify(result.games.slice(0, 3), null, 2));
}
console.log("\n" + "=".repeat(70));
console.log("✓ Test erfolgreich abgeschlossen");
console.log("=".repeat(70) + "\n");
} catch (error) {
console.error("\n" + "=".repeat(70));
console.error("❌ Fehler:");
console.error("=".repeat(70));
console.error("\nMessage:", error.message);
if (error.stack) {
console.error("\nStack:");
console.error(error.stack);
}
console.error("\n" + "=".repeat(70) + "\n");
process.exit(1);
}
}
main();

View File

@@ -1,75 +0,0 @@
/**
* Test-Script für Backend-APIs
* Ruft die Endpoints direkt auf ohne Browser/GUI
*/
import { handleConfigLoad, handleSteamRefresh } from "../server/steam-api.mjs";
// Mock Request/Response Objekte
class MockRequest {
constructor(method, url, body = null) {
this.method = method;
this.url = url;
this._body = body;
this._listeners = {};
}
on(event, callback) {
this._listeners[event] = callback;
if (event === "data" && this._body) {
setTimeout(() => callback(this._body), 0);
}
if (event === "end") {
setTimeout(() => callback(), 0);
}
}
}
class MockResponse {
constructor() {
this.statusCode = 200;
this.headers = {};
this._chunks = [];
}
setHeader(name, value) {
this.headers[name] = value;
}
end(data) {
if (data) this._chunks.push(data);
const output = this._chunks.join("");
console.log("\n=== RESPONSE ===");
console.log("Status:", this.statusCode);
console.log("Headers:", this.headers);
console.log("Body:", output);
// Parse JSON wenn Content-Type gesetzt ist
if (this.headers["Content-Type"] === "application/json") {
try {
const parsed = JSON.parse(output);
console.log("\nParsed JSON:");
console.log(JSON.stringify(parsed, null, 2));
} catch (e) {
console.error("JSON Parse Error:", e.message);
}
}
}
}
// Test 1: Config Load
console.log("\n### TEST 1: Config Load ###");
const configReq = new MockRequest("GET", "/api/config/load");
const configRes = new MockResponse();
await handleConfigLoad(configReq, configRes);
// Test 2: Steam Refresh (braucht config.local.json)
console.log("\n\n### TEST 2: Steam Refresh ###");
const steamBody = JSON.stringify({
apiKey: "78CDB987B47DDBB9C385522E5F6D0A52",
steamId: "76561197960313963",
});
const steamReq = new MockRequest("POST", "/api/steam/refresh", steamBody);
const steamRes = new MockResponse();
await handleSteamRefresh(steamReq, steamRes);

View File

@@ -1,54 +0,0 @@
#!/usr/bin/env node
/**
* Standalone Backend-Test
* Testet die API-Funktionen direkt ohne Vite-Server
*/
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const rootDir = join(__dirname, "..");
console.log("=".repeat(60));
console.log("Backend API Test");
console.log("=".repeat(60));
// Test 1: Config File lesen
console.log("\n[TEST 1] Config File direkt lesen");
console.log("-".repeat(60));
const configPath = join(rootDir, "config.local.json");
console.log("Config Pfad:", configPath);
try {
const configRaw = await readFile(configPath, "utf-8");
console.log("\n✓ Datei gelesen, Größe:", configRaw.length, "bytes");
console.log("\nInhalt:");
console.log(configRaw);
const config = JSON.parse(configRaw);
console.log("\n✓ JSON parsing erfolgreich");
console.log("\nGeparste Config:");
console.log(JSON.stringify(config, null, 2));
if (config.steam?.apiKey && config.steam?.steamId) {
console.log("\n✓ Steam-Daten vorhanden:");
console.log(" - API Key:", config.steam.apiKey.substring(0, 8) + "...");
console.log(" - Steam ID:", config.steam.steamId);
} else {
console.log("\n⚠ Steam-Daten nicht vollständig");
}
} catch (error) {
console.error("\n❌ Fehler beim Lesen der Config:");
console.error(" Error:", error.message);
console.error(" Stack:", error.stack);
process.exit(1);
}
console.log("\n" + "=".repeat(60));
console.log("✓ Alle Tests bestanden!");
console.log("=".repeat(60));

View File

@@ -1,28 +0,0 @@
/**
* Einfacher Test: Lädt config.local.json
*/
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const configPath = join(__dirname, "..", "config.local.json");
console.log("Config Pfad:", configPath);
try {
const configData = await readFile(configPath, "utf-8");
console.log("\nRaw File Content:");
console.log(configData);
const config = JSON.parse(configData);
console.log("\nParsed Config:");
console.log(JSON.stringify(config, null, 2));
console.log("\n✓ Config erfolgreich geladen!");
} catch (error) {
console.error("\n❌ Fehler:", error.message);
console.error(error);
}

View File

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

View File

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

210
src/data/tutorials.ts Normal file
View File

@@ -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<string, Tutorial> = {
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",
],
},
};

View File

@@ -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<string, "skip" | "interested">;
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<Game[]>([]);
const [swipeResults, setSwipeResults] = useState<SwipeResults>({});
const [playlists, setPlaylists] = useState<Playlist[]>([]);
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 (
<IonPage>

View File

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

View File

@@ -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<Playlist[]>([]);
const [games, setGames] = useState<Game[]>([]);
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 (
<IonPage>
<IonHeader translucent>
@@ -23,10 +76,71 @@ export default function PlaylistsPage() {
</IonToolbar>
</IonHeader>
<div className="playlists-placeholder">
<h2>Spieleplaylists</h2>
<p>Erstelle und teile kuratierte Playlists deiner Lieblingsspiele.</p>
</div>
{loading ? (
<div className="playlists-loading">
<IonSpinner name="crescent" />
</div>
) : (
<div className="playlists-container">
{playlists.map((playlist) => (
<IonCard key={playlist.id}>
<IonCardHeader>
<IonCardTitle>
{playlist.name}
<IonBadge color="primary" style={{ marginLeft: "8px" }}>
{playlist.gameIds.length}
</IonBadge>
</IonCardTitle>
</IonCardHeader>
<IonCardContent>
{playlist.gameIds.length === 0 ? (
<p className="playlists-empty">
Keine Spiele in dieser Playlist
</p>
) : (
<IonList>
{playlist.gameIds.map((gameId) => {
const game = getGameById(gameId);
if (!game) return null;
return (
<IonItem key={gameId}>
<IonIcon
icon={gameControllerOutline}
slot="start"
color="primary"
/>
<IonLabel>
<h2>{game.title}</h2>
{game.source && (
<p>
<IonBadge
color="medium"
style={{ fontSize: "10px" }}
>
{game.source}
</IonBadge>
</p>
)}
</IonLabel>
<IonIcon
icon={closeCircleOutline}
slot="end"
color="medium"
onClick={() =>
handleRemoveGame(playlist.id, gameId)
}
style={{ cursor: "pointer" }}
/>
</IonItem>
);
})}
</IonList>
)}
</IonCardContent>
</IonCard>
))}
</div>
)}
</IonContent>
</IonPage>
);

View File

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

View File

@@ -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<void> {
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<Playlist[]> {
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<Playlist | null> {
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<void> {
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<void> {
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<void> {
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

View File

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

View File

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