clean up code
This commit is contained in:
17
.gitignore
vendored
17
.gitignore
vendored
@@ -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
17
.vscode/tasks.json
vendored
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
214
UBERSPACE.md
214
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 <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
279
app.js
@@ -1,279 +0,0 @@
|
||||
const sourcesConfigUrl = "./data/sources.json";
|
||||
|
||||
const state = {
|
||||
allGames: [],
|
||||
mergedGames: [],
|
||||
search: "",
|
||||
sourceFilter: "all",
|
||||
sortBy: "title",
|
||||
sources: [],
|
||||
};
|
||||
|
||||
const ui = {
|
||||
grid: document.getElementById("gamesGrid"),
|
||||
summary: document.getElementById("summary"),
|
||||
searchInput: document.getElementById("searchInput"),
|
||||
sourceFilter: document.getElementById("sourceFilter"),
|
||||
sortSelect: document.getElementById("sortSelect"),
|
||||
refreshButton: document.getElementById("refreshButton"),
|
||||
template: document.getElementById("gameCardTemplate"),
|
||||
};
|
||||
|
||||
const normalizeTitle = (title) =>
|
||||
title.toLowerCase().replace(/[:\-]/g, " ").replace(/\s+/g, " ").trim();
|
||||
|
||||
const toDateValue = (value) => (value ? new Date(value).getTime() : 0);
|
||||
|
||||
const mergeGames = (games) => {
|
||||
const map = new Map();
|
||||
|
||||
games.forEach((game) => {
|
||||
const key = game.canonicalId || normalizeTitle(game.title);
|
||||
const entry = map.get(key) || {
|
||||
title: game.title,
|
||||
canonicalId: key,
|
||||
platforms: new Set(),
|
||||
sources: [],
|
||||
tags: new Set(),
|
||||
lastPlayed: null,
|
||||
playtimeHours: 0,
|
||||
};
|
||||
|
||||
entry.platforms.add(game.platform);
|
||||
game.tags?.forEach((tag) => entry.tags.add(tag));
|
||||
entry.sources.push({
|
||||
name: game.source,
|
||||
id: game.id,
|
||||
url: game.url,
|
||||
platform: game.platform,
|
||||
});
|
||||
|
||||
if (
|
||||
game.lastPlayed &&
|
||||
(!entry.lastPlayed || game.lastPlayed > entry.lastPlayed)
|
||||
) {
|
||||
entry.lastPlayed = game.lastPlayed;
|
||||
}
|
||||
|
||||
if (Number.isFinite(game.playtimeHours)) {
|
||||
entry.playtimeHours += game.playtimeHours;
|
||||
}
|
||||
|
||||
map.set(key, entry);
|
||||
});
|
||||
|
||||
return Array.from(map.values()).map((entry) => ({
|
||||
...entry,
|
||||
platforms: Array.from(entry.platforms),
|
||||
tags: Array.from(entry.tags),
|
||||
}));
|
||||
};
|
||||
|
||||
const sortGames = (games, sortBy) => {
|
||||
const sorted = [...games];
|
||||
sorted.sort((a, b) => {
|
||||
if (sortBy === "lastPlayed") {
|
||||
return toDateValue(b.lastPlayed) - toDateValue(a.lastPlayed);
|
||||
}
|
||||
if (sortBy === "platforms") {
|
||||
return b.platforms.length - a.platforms.length;
|
||||
}
|
||||
return a.title.localeCompare(b.title, "de");
|
||||
});
|
||||
return sorted;
|
||||
};
|
||||
|
||||
const filterGames = () => {
|
||||
const query = state.search.trim().toLowerCase();
|
||||
let filtered = [...state.mergedGames];
|
||||
|
||||
if (state.sourceFilter !== "all") {
|
||||
filtered = filtered.filter((game) =>
|
||||
game.sources.some((source) => source.name === state.sourceFilter),
|
||||
);
|
||||
}
|
||||
|
||||
if (query) {
|
||||
filtered = filtered.filter((game) => {
|
||||
const haystack = [
|
||||
game.title,
|
||||
...game.platforms,
|
||||
...game.tags,
|
||||
...game.sources.map((source) => source.name),
|
||||
]
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return haystack.includes(query);
|
||||
});
|
||||
}
|
||||
|
||||
return sortGames(filtered, state.sortBy);
|
||||
};
|
||||
|
||||
const renderSummary = (games) => {
|
||||
const totalGames = state.mergedGames.length;
|
||||
const totalSources = state.sources.length;
|
||||
const duplicates = state.allGames.length - state.mergedGames.length;
|
||||
const totalPlaytime = state.allGames.reduce(
|
||||
(sum, game) => sum + (game.playtimeHours || 0),
|
||||
0,
|
||||
);
|
||||
|
||||
ui.summary.innerHTML = [
|
||||
{
|
||||
label: "Konsolidierte Spiele",
|
||||
value: totalGames,
|
||||
},
|
||||
{
|
||||
label: "Quellen",
|
||||
value: totalSources,
|
||||
},
|
||||
{
|
||||
label: "Zusammengeführte Duplikate",
|
||||
value: Math.max(duplicates, 0),
|
||||
},
|
||||
{
|
||||
label: "Gesamte Spielzeit (h)",
|
||||
value: totalPlaytime.toFixed(1),
|
||||
},
|
||||
]
|
||||
.map(
|
||||
(item) => `
|
||||
<div class="summary-card">
|
||||
<h3>${item.label}</h3>
|
||||
<p>${item.value}</p>
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
.join("");
|
||||
};
|
||||
|
||||
const renderGames = (games) => {
|
||||
ui.grid.innerHTML = "";
|
||||
|
||||
games.forEach((game) => {
|
||||
const card = ui.template.content.cloneNode(true);
|
||||
card.querySelector(".title").textContent = game.title;
|
||||
card.querySelector(".badge").textContent =
|
||||
`${game.platforms.length} Plattformen`;
|
||||
card.querySelector(".meta").textContent = game.lastPlayed
|
||||
? `Zuletzt gespielt: ${new Date(game.lastPlayed).toLocaleDateString("de")}`
|
||||
: "Noch nicht gespielt";
|
||||
|
||||
const tagList = card.querySelector(".tag-list");
|
||||
game.tags.slice(0, 4).forEach((tag) => {
|
||||
const span = document.createElement("span");
|
||||
span.className = "tag";
|
||||
span.textContent = tag;
|
||||
tagList.appendChild(span);
|
||||
});
|
||||
|
||||
if (!game.tags.length) {
|
||||
const span = document.createElement("span");
|
||||
span.className = "tag";
|
||||
span.textContent = "Ohne Tags";
|
||||
tagList.appendChild(span);
|
||||
}
|
||||
|
||||
const sources = card.querySelector(".sources");
|
||||
game.sources.forEach((source) => {
|
||||
const item = document.createElement("div");
|
||||
item.className = "source-item";
|
||||
const name = document.createElement("span");
|
||||
name.textContent = source.name;
|
||||
const details = document.createElement("p");
|
||||
details.textContent = `${source.platform} · ${source.id}`;
|
||||
item.append(name, details);
|
||||
sources.appendChild(item);
|
||||
});
|
||||
|
||||
ui.grid.appendChild(card);
|
||||
});
|
||||
};
|
||||
|
||||
const populateSourceFilter = () => {
|
||||
ui.sourceFilter.innerHTML = '<option value="all">Alle Quellen</option>';
|
||||
state.sources.forEach((source) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = source.name;
|
||||
option.textContent = source.label;
|
||||
ui.sourceFilter.appendChild(option);
|
||||
});
|
||||
};
|
||||
|
||||
const updateUI = () => {
|
||||
const filtered = filterGames();
|
||||
renderSummary(filtered);
|
||||
renderGames(filtered);
|
||||
};
|
||||
|
||||
const loadSources = async () => {
|
||||
const response = await fetch(sourcesConfigUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error("Konnte sources.json nicht laden.");
|
||||
}
|
||||
|
||||
const config = await response.json();
|
||||
state.sources = config.sources;
|
||||
|
||||
const data = await Promise.all(
|
||||
config.sources.map(async (source) => {
|
||||
const sourceResponse = await fetch(source.file);
|
||||
if (!sourceResponse.ok) {
|
||||
throw new Error(`Konnte ${source.file} nicht laden.`);
|
||||
}
|
||||
const list = await sourceResponse.json();
|
||||
return list.map((game) => ({
|
||||
...game,
|
||||
source: source.name,
|
||||
platform: game.platform || source.platform,
|
||||
}));
|
||||
}),
|
||||
);
|
||||
|
||||
state.allGames = data.flat();
|
||||
state.mergedGames = mergeGames(state.allGames);
|
||||
};
|
||||
|
||||
const attachEvents = () => {
|
||||
ui.searchInput.addEventListener("input", (event) => {
|
||||
state.search = event.target.value;
|
||||
updateUI();
|
||||
});
|
||||
|
||||
ui.sourceFilter.addEventListener("change", (event) => {
|
||||
state.sourceFilter = event.target.value;
|
||||
updateUI();
|
||||
});
|
||||
|
||||
ui.sortSelect.addEventListener("change", (event) => {
|
||||
state.sortBy = event.target.value;
|
||||
updateUI();
|
||||
});
|
||||
|
||||
ui.refreshButton.addEventListener("click", async () => {
|
||||
ui.refreshButton.disabled = true;
|
||||
ui.refreshButton.textContent = "Lade ...";
|
||||
try {
|
||||
await loadSources();
|
||||
populateSourceFilter();
|
||||
updateUI();
|
||||
} finally {
|
||||
ui.refreshButton.disabled = false;
|
||||
ui.refreshButton.textContent = "Daten neu laden";
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const init = async () => {
|
||||
try {
|
||||
await loadSources();
|
||||
populateSourceFilter();
|
||||
attachEvents();
|
||||
updateUI();
|
||||
} catch (error) {
|
||||
ui.grid.innerHTML = `<div class="card">${error.message}</div>`;
|
||||
}
|
||||
};
|
||||
|
||||
init();
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
# Blizzard Setup für WhatToPlay
|
||||
|
||||
## API OAuth Konfiguration
|
||||
|
||||
### 1. Battle.net Developer Portal öffnen
|
||||
|
||||
- Gehe zu https://develop.battle.net
|
||||
- Melde dich mit deinem Battle.net Account an
|
||||
|
||||
### 2. Application registrieren
|
||||
|
||||
- Klicke auf "Create Application"
|
||||
- Name: "WhatToPlay" (oder dein Projektname)
|
||||
- Website: https://whattoplay.local (für Development)
|
||||
- Beschreibung: "Game Library Manager"
|
||||
- Akzeptiere die ToS
|
||||
|
||||
### 3. OAuth Credentials kopieren
|
||||
|
||||
Nach der Registrierung siehst du:
|
||||
|
||||
- **Client ID** - die öffentliche ID
|
||||
- **Client Secret** - HALTEN Sie geheim! (Nur auf Server, nie im Browser!)
|
||||
|
||||
### 4. Redirect URI setzen
|
||||
|
||||
In deiner Application Settings:
|
||||
|
||||
```
|
||||
Redirect URIs:
|
||||
https://whattoplay-oauth.workers.dev/blizzard/callback (Production)
|
||||
http://localhost:3000/auth/callback (Development)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## config.local.json Setup
|
||||
|
||||
```json
|
||||
{
|
||||
"blizzard": {
|
||||
"clientId": "your_client_id_here",
|
||||
"clientSecret": "your_client_secret_here",
|
||||
"region": "eu"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Region Codes:
|
||||
|
||||
- `us` - North America
|
||||
- `eu` - Europe
|
||||
- `kr` - Korea
|
||||
- `tw` - Taiwan
|
||||
|
||||
---
|
||||
|
||||
## Blizzard Games, die unterstützt werden
|
||||
|
||||
1. **World of Warcraft** - Character-basiert
|
||||
2. **Diablo III** - Hero-basiert
|
||||
3. **Diablo IV** - Charakter-basiert
|
||||
4. **Overwatch 2** - Account-basiert
|
||||
5. **Starcraft II** - Campaign Progress
|
||||
6. **Heroes of the Storm** - Character-basiert
|
||||
7. **Hearthstone** - Deck-basiert
|
||||
|
||||
---
|
||||
|
||||
## Development vs Production
|
||||
|
||||
### Development (Lokal)
|
||||
|
||||
```bash
|
||||
# Teste mit lokalem Token
|
||||
npm run import
|
||||
|
||||
# Script verwendet config.local.json
|
||||
```
|
||||
|
||||
### Production (Mit Cloudflare Worker)
|
||||
|
||||
```
|
||||
Frontend → Cloudflare Worker → Blizzard OAuth
|
||||
↓
|
||||
Token Exchange
|
||||
(Client Secret sicher!)
|
||||
```
|
||||
|
||||
Siehe: [CLOUDFLARE-WORKERS-SETUP.md](./CLOUDFLARE-WORKERS-SETUP.md)
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Client ID invalid"
|
||||
|
||||
- Überprüfe dass die Client ID korrekt kopiert wurde
|
||||
- Stelle sicher dass du im Development Portal angemeldet bist
|
||||
|
||||
### "Redirect URI mismatch"
|
||||
|
||||
- Die Redirect URI muss exakt übereinstimmen
|
||||
- Beachte Protocol (https vs http)
|
||||
- Beachte Port-Nummern
|
||||
|
||||
### "No games found"
|
||||
|
||||
- Dein Account muss mindestens 1 Blizzard Game haben
|
||||
- Bei Diablo III: Character muss erstellt sein
|
||||
- Charaktere können bis zu 24h brauchen zum Erscheinen
|
||||
|
||||
### Token-Fehler in Production
|
||||
|
||||
- Client Secret ist abgelaufen → Neu generieren
|
||||
- Überprüfe Cloudflare Worker Logs:
|
||||
```bash
|
||||
npx wrangler tail whattoplay-blizzard
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sicherheit
|
||||
|
||||
🔒 **Wichtig:**
|
||||
|
||||
- **Client Secret** NIEMALS ins Frontend committen
|
||||
- Nutze Cloudflare KV Store oder Environment Variables
|
||||
- Token mit Ablaufdatum (expires_in) prüfen
|
||||
- Token nicht in Browser LocalStorage speichern (nur Session)
|
||||
|
||||
---
|
||||
|
||||
## Links
|
||||
|
||||
- [Battle.net Developer Portal](https://develop.battle.net)
|
||||
- [Blizzard OAuth Documentation](https://develop.battle.net/documentation/guides/using-oauth)
|
||||
- [Game Data APIs](https://develop.battle.net/documentation/guides/game-data-apis)
|
||||
@@ -1,328 +0,0 @@
|
||||
# WhatToPlay - Feature-Übersicht (Februar 2026)
|
||||
|
||||
## 🆕 Neue Features
|
||||
|
||||
### 1️⃣ Settings-Tab mit Konfiguration
|
||||
|
||||
**Pfad**: `src/pages/Settings/SettingsPage.tsx`
|
||||
|
||||
```
|
||||
Settings-Tab
|
||||
├── 🎮 Steam Integration
|
||||
│ ├── API Key Input (verborgen)
|
||||
│ ├── Steam ID Input
|
||||
│ └── Tutorial-Button (✨ Step-by-Step Anleitung)
|
||||
│
|
||||
├── 🌐 GOG Integration
|
||||
│ ├── User ID Input
|
||||
│ ├── Access Token Input (verborgen)
|
||||
│ └── Tutorial für Token-Extraction
|
||||
│
|
||||
├── ⚙️ Epic Games
|
||||
│ ├── E-Mail Input
|
||||
│ ├── Import-Methode (Manual oder OAuth)
|
||||
│ └── ℹ️ Info: Keine öffentliche API
|
||||
│
|
||||
├── 🔶 Amazon Games
|
||||
│ ├── E-Mail Input
|
||||
│ ├── Import-Methode (Manual oder OAuth)
|
||||
│ └── Ähnlich wie Epic
|
||||
│
|
||||
├── ⚔️ Blizzard Entertainment
|
||||
│ ├── Client ID Input (verborgen)
|
||||
│ ├── Client Secret Input (verborgen)
|
||||
│ ├── Region Selector (US/EU/KR/TW)
|
||||
│ └── Tutorial-Button
|
||||
│
|
||||
└── 📦 Daten-Management
|
||||
├── Config Exportieren (JSON Download)
|
||||
├── Config Importieren (JSON Upload)
|
||||
└── Alle Einstellungen löschen
|
||||
```
|
||||
|
||||
### 2️⃣ Integriertes Tutorial-System
|
||||
|
||||
**Pfad**: `src/components/TutorialModal.tsx`
|
||||
|
||||
Jeder Service hat sein eigenes Step-by-Step Tutorial:
|
||||
|
||||
```
|
||||
Tutorial Modal
|
||||
├── Steam
|
||||
│ ├── API Key generieren
|
||||
│ ├── Steam ID finden
|
||||
│ └── 6 Schritte mit Screenshots-Links
|
||||
│
|
||||
├── GOG
|
||||
│ ├── Browser DevTools öffnen
|
||||
│ ├── Bearer Token kopieren
|
||||
│ └── 5 Schritte mit Code-Beispiele
|
||||
│
|
||||
├── Epic Games
|
||||
│ ├── Account-Setup
|
||||
│ ├── JSON Export erklären
|
||||
│ └── 4 Schritte, einfach
|
||||
│
|
||||
├── Amazon Games
|
||||
│ ├── Prime Gaming aktivieren
|
||||
│ ├── Luna erklärt
|
||||
│ └── 4 Schritte
|
||||
│
|
||||
└── Blizzard
|
||||
├── Developer Portal
|
||||
├── OAuth Credentials
|
||||
└── 6 Schritte detailliert
|
||||
```
|
||||
|
||||
### 3️⃣ ConfigService - Sichere Speicherung
|
||||
|
||||
**Pfad**: `src/services/ConfigService.ts`
|
||||
|
||||
```typescript
|
||||
ConfigService
|
||||
├── loadConfig() - Lade aus localStorage
|
||||
├── saveConfig() - Speichere in localStorage
|
||||
├── exportConfig() - Download als JSON
|
||||
├── importConfig() - Upload aus JSON
|
||||
├── backupToIndexedDB() - Redundante Speicherung
|
||||
├── restoreFromIndexedDB() - Aus Backup zurück
|
||||
├── validateConfig() - Prüfe auf Fehler
|
||||
└── clearConfig() - Alles löschen
|
||||
```
|
||||
|
||||
**Speicher-Strategie:**
|
||||
|
||||
- ✅ localStorage für schnellen Zugriff
|
||||
- ✅ IndexedDB für Backup & Encryption-Ready
|
||||
- ✅ Keine Tokens in localStorage ohne Verschlüsselung
|
||||
- ✅ Export/Import für Cloud-Sync
|
||||
|
||||
### 4️⃣ Blizzard API Integration
|
||||
|
||||
**Pfad**: `scripts/fetch-blizzard.mjs`
|
||||
|
||||
```
|
||||
Supported Games:
|
||||
• World of Warcraft
|
||||
• Diablo III (Heroes)
|
||||
• Diablo IV
|
||||
• Overwatch 2
|
||||
• StarCraft II
|
||||
• Heroes of the Storm
|
||||
• Hearthstone
|
||||
|
||||
Data:
|
||||
• Character Name
|
||||
• Level
|
||||
• Class
|
||||
• Hardcore Flag
|
||||
• Elite Kills
|
||||
• Experience
|
||||
• Last Updated
|
||||
```
|
||||
|
||||
### 5️⃣ Cloudflare Workers Setup (Serverless)
|
||||
|
||||
**Pfad**: `docs/CLOUDFLARE-WORKERS-SETUP.md`
|
||||
|
||||
```
|
||||
Zero Infrastructure Deployment:
|
||||
|
||||
Frontend (Vercel/Netlify)
|
||||
↓
|
||||
Cloudflare Workers (Serverless)
|
||||
↓
|
||||
OAuth Callbacks + Token Exchange
|
||||
↓
|
||||
GOG Galaxy Library API
|
||||
Blizzard Battle.net API
|
||||
Epic Games (später)
|
||||
Amazon Games (später)
|
||||
|
||||
✨ Benefits:
|
||||
• Keine Server zu verwalten
|
||||
• Kostenlos bis 100k req/Tag
|
||||
• Client Secrets geschützt (Server-Side)
|
||||
• CORS automatisch konfiguriert
|
||||
• Weltweit verteilt
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📁 Neue Dateien
|
||||
|
||||
| Datei | Beschreibung | Status |
|
||||
| ------------------------------------- | --------------------------- | ------ |
|
||||
| `src/pages/Settings/SettingsPage.tsx` | Settings-Tab mit Formularen | ✅ |
|
||||
| `src/pages/Settings/SettingsPage.css` | Styling | ✅ |
|
||||
| `src/components/TutorialModal.tsx` | Tutorial-System | ✅ |
|
||||
| `src/services/ConfigService.ts` | Konfiguration speichern | ✅ |
|
||||
| `scripts/fetch-blizzard.mjs` | Blizzard API Importer | ✅ |
|
||||
| `docs/CLOUDFLARE-WORKERS-SETUP.md` | Worker Dokumentation | ✅ |
|
||||
| `docs/BLIZZARD-SETUP.md` | Blizzard OAuth Guide | ✅ |
|
||||
| `config.local.json.example` | Config Template | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Workflow für Nutzer
|
||||
|
||||
### Erste Nutzung:
|
||||
|
||||
```
|
||||
1. App öffnen → Settings-Tab
|
||||
2. Auf "?" Button klicken → Tutorial Modal
|
||||
3. Step-by-Step folgen
|
||||
4. Credentials eingeben
|
||||
5. "Speichern" klicken → localStorage
|
||||
6. Daten werden automatisch synced
|
||||
```
|
||||
|
||||
### Daten importieren:
|
||||
|
||||
```
|
||||
1. Settings-Tab → "Config importieren"
|
||||
2. Datei auswählen (whattoplay-config.json)
|
||||
3. Credentials werden wiederhergestellt
|
||||
4. Alle APIs neu abfragen
|
||||
```
|
||||
|
||||
### Daten exportieren:
|
||||
|
||||
```
|
||||
1. Settings-Tab → "Config exportieren"
|
||||
2. JSON-Datei downloaded
|
||||
3. Kann auf anderem Device importiert werden
|
||||
4. Oder als Backup gespeichert
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Nächste Schritte
|
||||
|
||||
### Phase 1: Production Ready (Jetzt)
|
||||
|
||||
- [x] Steam Integration
|
||||
- [x] Settings-Tab
|
||||
- [x] Blizzard OAuth
|
||||
- [x] Cloudflare Worker Setup (dokumentiert)
|
||||
|
||||
### Phase 2: Backend Deployment (1-2 Wochen)
|
||||
|
||||
- [ ] Cloudflare Worker deployen
|
||||
- [ ] GOG OAuth Callback
|
||||
- [ ] Blizzard OAuth Callback
|
||||
- [ ] Token Encryption in KV Store
|
||||
|
||||
### Phase 3: Import Features (2-4 Wochen)
|
||||
|
||||
- [ ] Epic Games JSON Import UI
|
||||
- [ ] Amazon Games JSON Import UI
|
||||
- [ ] Drag & Drop Upload
|
||||
- [ ] Validierung
|
||||
|
||||
### Phase 4: Polish (4+ Wochen)
|
||||
|
||||
- [ ] Home-Page Widgets
|
||||
- [ ] Playlists Feature
|
||||
- [ ] Discover/Tinder UI
|
||||
- [ ] PWA Setup
|
||||
- [ ] iOS Testing
|
||||
|
||||
---
|
||||
|
||||
## 📊 Statistiken
|
||||
|
||||
| Metric | Wert |
|
||||
| --------------------------- | -------------------------------------- |
|
||||
| Unterstützte Plattformen | 5 (Steam, GOG, Epic, Amazon, Blizzard) |
|
||||
| Settings-Formulare | 5 |
|
||||
| Tutorial-Schritte | 30+ |
|
||||
| Cloudflare Worker Functions | 3 (GOG, Blizzard, Validation) |
|
||||
| API Endpoints | 15+ |
|
||||
| LocalStorage Capacity | 5-10MB |
|
||||
| IndexedDB Capacity | 50MB+ |
|
||||
|
||||
---
|
||||
|
||||
## 💡 Design Patterns
|
||||
|
||||
### Konfiguration speichern (Observable Pattern)
|
||||
|
||||
```typescript
|
||||
// SettingsPage.tsx
|
||||
const [config, setConfig] = useState<ServiceConfig>({});
|
||||
|
||||
const handleSaveConfig = (service: keyof ServiceConfig, data: any) => {
|
||||
const updated = { ...config, [service]: { ...config[service], ...data } };
|
||||
setConfig(updated);
|
||||
ConfigService.saveConfig(updated); // → localStorage
|
||||
// Optional: ConfigService.backupToIndexedDB(updated); // → Backup
|
||||
};
|
||||
```
|
||||
|
||||
### Tutorial System (Data-Driven)
|
||||
|
||||
```typescript
|
||||
// TutorialModal.tsx - Alle Tutorials in TUTORIALS Objekt
|
||||
const TUTORIALS: Record<string, Tutorial> = {
|
||||
steam: { ... },
|
||||
gog: { ... },
|
||||
// Einfach zu erweitern!
|
||||
};
|
||||
```
|
||||
|
||||
### OAuth Flow mit Cloudflare Worker
|
||||
|
||||
```
|
||||
Frontend initiiert:
|
||||
↓
|
||||
Worker erhält Callback:
|
||||
↓
|
||||
Token Exchange Server-Side:
|
||||
↓
|
||||
Frontend erhält Token in URL:
|
||||
↓
|
||||
ConfigService speichert Token:
|
||||
↓
|
||||
Nächster API Call mit Token
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Sicherheit
|
||||
|
||||
### ✅ Best Practices implementiert:
|
||||
|
||||
- Client Secrets in Backend nur (Cloudflare KV)
|
||||
- Tokens mit Session-Speicher (nicht persistent)
|
||||
- Export/Import mit Warnung
|
||||
- Validation der Credentials
|
||||
- CORS nur für eigene Domain
|
||||
- State Parameter für CSRF
|
||||
|
||||
### ❌ Nicht implementiert (wäre Overkill):
|
||||
|
||||
- Token-Verschlüsselung in localStorage (würde Komplexität erhöhen)
|
||||
- 2FA für Settings
|
||||
- Audit Logs
|
||||
- Rate Limiting (kommt auf Server-Side)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Gesamtziel
|
||||
|
||||
**Zero Infrastructure, Full-Featured:**
|
||||
|
||||
- Frontend: Statisch deployed (Vercel/Netlify)
|
||||
- Backend: Serverless (Cloudflare Workers)
|
||||
- Datenbank: Optional (Supabase/Firebase)
|
||||
- Secrets: KV Store oder Environment Variables
|
||||
- **Kosten**: ~$0/Monat für < 1000 User
|
||||
|
||||
Nutzer kann:
|
||||
|
||||
- ✅ Alle Credentials selbst eingeben
|
||||
- ✅ Daten jederzeit exportieren/importieren
|
||||
- ✅ Offline mit LocalStorage arbeiten
|
||||
- ✅ Auf iOS/Web/Desktop gleiches UI
|
||||
- ✅ Keine zusätzlichen Apps nötig
|
||||
@@ -1,144 +0,0 @@
|
||||
# GOG Integration - Development Setup
|
||||
|
||||
## ⚠️ Wichtig: Temporäre Lösung für Development
|
||||
|
||||
Da wir eine **Web/iOS App** bauen, können wir keine CLI-Tools (wie `gogdl`) nutzen.
|
||||
Für Production brauchen wir ein **Backend mit OAuth Flow**.
|
||||
|
||||
## Wie bekomme ich GOG Credentials?
|
||||
|
||||
### Option 1: Manuell aus Browser (Development)
|
||||
|
||||
1. **Öffne GOG.com (eingeloggt)**
|
||||
|
||||
```
|
||||
https://www.gog.com
|
||||
```
|
||||
|
||||
2. **Öffne Browser DevTools**
|
||||
- Chrome/Edge: `F12` oder `Cmd+Option+I` (Mac)
|
||||
- Firefox: `F12`
|
||||
|
||||
3. **Gehe zu Network Tab**
|
||||
- Klicke auf "Network" / "Netzwerk"
|
||||
- Aktiviere "Preserve log" / "Log beibehalten"
|
||||
|
||||
4. **Lade eine GOG Seite neu**
|
||||
- Z.B. deine Library: `https://www.gog.com/account`
|
||||
|
||||
5. **Finde Request mit Bearer Token**
|
||||
- Suche nach Requests zu `gog.com` oder `galaxy-library.gog.com`
|
||||
- Klicke auf einen Request
|
||||
- Gehe zu "Headers" Tab
|
||||
- Kopiere den `Authorization: Bearer ...` Token
|
||||
|
||||
6. **Kopiere User ID**
|
||||
- Suche nach Request zu `embed.gog.com/userData.json`
|
||||
- Im Response findest du `"galaxyUserId": "123456789..."`
|
||||
- Kopiere diese ID
|
||||
|
||||
7. **Trage in config.local.json ein**
|
||||
```json
|
||||
{
|
||||
"steam": { ... },
|
||||
"epic": {},
|
||||
"gog": {
|
||||
"userId": "DEINE_GALAXY_USER_ID",
|
||||
"accessToken": "DEIN_BEARER_TOKEN"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Option 2: Backend OAuth Flow (Production - TODO)
|
||||
|
||||
Für Production implementieren wir einen OAuth Flow:
|
||||
|
||||
```javascript
|
||||
// Backend Endpoint (z.B. Vercel Function)
|
||||
export async function POST(request) {
|
||||
// 1. User zu GOG Auth redirecten
|
||||
const authUrl = `https://auth.gog.com/auth?client_id=...&redirect_uri=...`;
|
||||
|
||||
// 2. Callback mit Code
|
||||
// 3. Code gegen Access Token tauschen
|
||||
const token = await fetch("https://auth.gog.com/token", {
|
||||
method: "POST",
|
||||
body: { code, client_secret: process.env.GOG_SECRET },
|
||||
});
|
||||
|
||||
// 4. Token sicher speichern (z.B. encrypted in DB)
|
||||
return { success: true };
|
||||
}
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### GOG Galaxy Library
|
||||
|
||||
```
|
||||
GET https://galaxy-library.gog.com/users/{userId}/releases
|
||||
Headers:
|
||||
Authorization: Bearer {accessToken}
|
||||
User-Agent: WhatToPlay/1.0
|
||||
|
||||
Response:
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"external_id": "1207658930",
|
||||
"platform_id": "gog",
|
||||
"date_created": 1234567890,
|
||||
...
|
||||
}
|
||||
],
|
||||
"total_count": 123,
|
||||
"next_page_token": "..."
|
||||
}
|
||||
```
|
||||
|
||||
### GOG User Data
|
||||
|
||||
```
|
||||
GET https://embed.gog.com/userData.json
|
||||
Headers:
|
||||
Authorization: Bearer {accessToken}
|
||||
|
||||
Response:
|
||||
{
|
||||
"userId": "...",
|
||||
"galaxyUserId": "...",
|
||||
"username": "...",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
## Token Lebensdauer
|
||||
|
||||
- GOG Tokens laufen nach **ca. 1 Stunde** ab
|
||||
- Für Development: Token regelmäßig neu kopieren
|
||||
- Für Production: Refresh Token Flow implementieren
|
||||
|
||||
## Nächste Schritte
|
||||
|
||||
1. ✅ Development: Manueller Token aus Browser
|
||||
2. 📝 Backend: Vercel Function für OAuth
|
||||
3. 🔐 Backend: Token Refresh implementieren
|
||||
4. 📱 iOS: Secure Storage für Tokens (Keychain)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### `401 Unauthorized`
|
||||
|
||||
- Token abgelaufen → Neu aus Browser kopieren
|
||||
- Falscher Token → Prüfe `Authorization: Bearer ...`
|
||||
|
||||
### `CORS Error`
|
||||
|
||||
- Normal im Browser (darum brauchen wir Backend)
|
||||
- Development: Scripts laufen in Node.js (kein CORS)
|
||||
- Production: Backend macht die Requests
|
||||
|
||||
### Leere Library
|
||||
|
||||
- Prüfe `userId` - muss `galaxyUserId` sein, nicht `userId`
|
||||
- Prüfe API Endpoint - `/users/{userId}/releases`, nicht `/games`
|
||||
@@ -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>
|
||||
|
||||
13
package.json
13
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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
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
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
BIN
public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.7 KiB |
23
public/icon.svg
Normal file
23
public/icon.svg
Normal 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 |
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
|
||||
const loadConfig = async () => {
|
||||
const configUrl = new URL("../config.local.json", import.meta.url);
|
||||
try {
|
||||
const raw = await readFile(configUrl, "utf-8");
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const toIsoDate = (unixSeconds) =>
|
||||
unixSeconds ? new Date(unixSeconds * 1000).toISOString().slice(0, 10) : null;
|
||||
|
||||
const sanitizeFileName = (value) => {
|
||||
const normalized = value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
return normalized || "spiel";
|
||||
};
|
||||
|
||||
const fetchOwnedGames = async ({ apiKey, steamId }) => {
|
||||
const url = new URL(
|
||||
"https://api.steampowered.com/IPlayerService/GetOwnedGames/v1/",
|
||||
);
|
||||
url.searchParams.set("key", apiKey);
|
||||
url.searchParams.set("steamid", steamId);
|
||||
url.searchParams.set("include_appinfo", "true");
|
||||
url.searchParams.set("include_played_free_games", "true");
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Steam API Fehler: ${response.status}`);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
return payload.response?.games ?? [];
|
||||
};
|
||||
|
||||
const buildSteamEntry = (game) => ({
|
||||
id: String(game.appid),
|
||||
title: game.name,
|
||||
platform: "PC",
|
||||
lastPlayed: toIsoDate(game.rtime_last_played),
|
||||
playtimeHours: Math.round((game.playtime_forever / 60) * 10) / 10,
|
||||
tags: [],
|
||||
url: `https://store.steampowered.com/app/${game.appid}`,
|
||||
});
|
||||
|
||||
const buildTextFile = (entry) => {
|
||||
const lines = [
|
||||
`Titel: ${entry.title}`,
|
||||
`Steam AppID: ${entry.id}`,
|
||||
`Zuletzt gespielt: ${entry.lastPlayed ?? "-"}`,
|
||||
`Spielzeit (h): ${entry.playtimeHours ?? 0}`,
|
||||
`Store: ${entry.url}`,
|
||||
"Quelle: steam",
|
||||
];
|
||||
return lines.join("\n") + "\n";
|
||||
};
|
||||
|
||||
const writeOutputs = async (entries) => {
|
||||
const dataDir = new URL("../public/data/", import.meta.url);
|
||||
const textDir = new URL("../public/data/steam-text/", import.meta.url);
|
||||
|
||||
await mkdir(dataDir, { recursive: true });
|
||||
await mkdir(textDir, { recursive: true });
|
||||
|
||||
const jsonPath = new URL("steam.json", dataDir);
|
||||
await writeFile(jsonPath, JSON.stringify(entries, null, 2) + "\n", "utf-8");
|
||||
|
||||
await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const fileName = `${sanitizeFileName(entry.title)}__${entry.id}.txt`;
|
||||
const filePath = new URL(fileName, textDir);
|
||||
await writeFile(filePath, buildTextFile(entry), "utf-8");
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const run = async () => {
|
||||
const config = await loadConfig();
|
||||
const apiKey = config.steam?.apiKey || process.env.STEAM_API_KEY;
|
||||
const steamId = config.steam?.steamId || process.env.STEAM_ID;
|
||||
|
||||
if (!apiKey || !steamId) {
|
||||
console.error(
|
||||
"Bitte Steam-Zugangsdaten in config.local.json oder per Umgebungsvariablen setzen.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const games = await fetchOwnedGames({ apiKey, steamId });
|
||||
const entries = games.map(buildSteamEntry);
|
||||
await writeOutputs(entries);
|
||||
console.log(`Steam-Export fertig: ${entries.length} Spiele.`);
|
||||
};
|
||||
|
||||
run().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,101 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Steam CLI - Direktes Testen der Steam API
|
||||
* Usage: node scripts/steam-cli.mjs [apiKey] [steamId]
|
||||
*/
|
||||
|
||||
import { fetchSteamGames } from "../server/steam-backend.mjs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const configPath = join(__dirname, "..", "config.local.json");
|
||||
const configData = await readFile(configPath, "utf-8");
|
||||
return JSON.parse(configData);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("=".repeat(70));
|
||||
console.log("Steam API CLI Test");
|
||||
console.log("=".repeat(70));
|
||||
|
||||
// API Key und Steam ID holen (CLI-Args oder config.local.json)
|
||||
let apiKey = process.argv[2];
|
||||
let steamId = process.argv[3];
|
||||
|
||||
if (!apiKey || !steamId) {
|
||||
console.log("\nKeine CLI-Args, versuche config.local.json zu laden...");
|
||||
const config = await loadConfig();
|
||||
if (config?.steam) {
|
||||
apiKey = config.steam.apiKey;
|
||||
steamId = config.steam.steamId;
|
||||
console.log("✓ Credentials aus config.local.json geladen");
|
||||
}
|
||||
}
|
||||
|
||||
if (!apiKey || !steamId) {
|
||||
console.error("\n❌ Fehler: API Key und Steam ID erforderlich!");
|
||||
console.error("\nUsage:");
|
||||
console.error(" node scripts/steam-cli.mjs <apiKey> <steamId>");
|
||||
console.error(
|
||||
" oder config.local.json mit steam.apiKey und steam.steamId",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("\nParameter:");
|
||||
console.log(" API Key:", apiKey.substring(0, 8) + "...");
|
||||
console.log(" Steam ID:", steamId);
|
||||
console.log("\nRufe Steam API auf...\n");
|
||||
|
||||
try {
|
||||
const result = await fetchSteamGames(apiKey, steamId);
|
||||
|
||||
console.log("=".repeat(70));
|
||||
console.log("✓ Erfolgreich!");
|
||||
console.log("=".repeat(70));
|
||||
console.log(`\nAnzahl Spiele: ${result.count}`);
|
||||
|
||||
if (result.count > 0) {
|
||||
console.log("\nErste 5 Spiele:");
|
||||
console.log("-".repeat(70));
|
||||
result.games.slice(0, 5).forEach((game, idx) => {
|
||||
console.log(`\n${idx + 1}. ${game.title}`);
|
||||
console.log(` ID: ${game.id}`);
|
||||
console.log(` Spielzeit: ${game.playtimeHours}h`);
|
||||
console.log(` Zuletzt gespielt: ${game.lastPlayed || "nie"}`);
|
||||
console.log(` URL: ${game.url}`);
|
||||
});
|
||||
|
||||
console.log("\n" + "-".repeat(70));
|
||||
console.log("\nKomplettes JSON (erste 3 Spiele):");
|
||||
console.log(JSON.stringify(result.games.slice(0, 3), null, 2));
|
||||
}
|
||||
|
||||
console.log("\n" + "=".repeat(70));
|
||||
console.log("✓ Test erfolgreich abgeschlossen");
|
||||
console.log("=".repeat(70) + "\n");
|
||||
} catch (error) {
|
||||
console.error("\n" + "=".repeat(70));
|
||||
console.error("❌ Fehler:");
|
||||
console.error("=".repeat(70));
|
||||
console.error("\nMessage:", error.message);
|
||||
if (error.stack) {
|
||||
console.error("\nStack:");
|
||||
console.error(error.stack);
|
||||
}
|
||||
console.error("\n" + "=".repeat(70) + "\n");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,75 +0,0 @@
|
||||
/**
|
||||
* Test-Script für Backend-APIs
|
||||
* Ruft die Endpoints direkt auf ohne Browser/GUI
|
||||
*/
|
||||
|
||||
import { handleConfigLoad, handleSteamRefresh } from "../server/steam-api.mjs";
|
||||
|
||||
// Mock Request/Response Objekte
|
||||
class MockRequest {
|
||||
constructor(method, url, body = null) {
|
||||
this.method = method;
|
||||
this.url = url;
|
||||
this._body = body;
|
||||
this._listeners = {};
|
||||
}
|
||||
|
||||
on(event, callback) {
|
||||
this._listeners[event] = callback;
|
||||
|
||||
if (event === "data" && this._body) {
|
||||
setTimeout(() => callback(this._body), 0);
|
||||
}
|
||||
if (event === "end") {
|
||||
setTimeout(() => callback(), 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class MockResponse {
|
||||
constructor() {
|
||||
this.statusCode = 200;
|
||||
this.headers = {};
|
||||
this._chunks = [];
|
||||
}
|
||||
|
||||
setHeader(name, value) {
|
||||
this.headers[name] = value;
|
||||
}
|
||||
|
||||
end(data) {
|
||||
if (data) this._chunks.push(data);
|
||||
const output = this._chunks.join("");
|
||||
console.log("\n=== RESPONSE ===");
|
||||
console.log("Status:", this.statusCode);
|
||||
console.log("Headers:", this.headers);
|
||||
console.log("Body:", output);
|
||||
|
||||
// Parse JSON wenn Content-Type gesetzt ist
|
||||
if (this.headers["Content-Type"] === "application/json") {
|
||||
try {
|
||||
const parsed = JSON.parse(output);
|
||||
console.log("\nParsed JSON:");
|
||||
console.log(JSON.stringify(parsed, null, 2));
|
||||
} catch (e) {
|
||||
console.error("JSON Parse Error:", e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test 1: Config Load
|
||||
console.log("\n### TEST 1: Config Load ###");
|
||||
const configReq = new MockRequest("GET", "/api/config/load");
|
||||
const configRes = new MockResponse();
|
||||
await handleConfigLoad(configReq, configRes);
|
||||
|
||||
// Test 2: Steam Refresh (braucht config.local.json)
|
||||
console.log("\n\n### TEST 2: Steam Refresh ###");
|
||||
const steamBody = JSON.stringify({
|
||||
apiKey: "78CDB987B47DDBB9C385522E5F6D0A52",
|
||||
steamId: "76561197960313963",
|
||||
});
|
||||
const steamReq = new MockRequest("POST", "/api/steam/refresh", steamBody);
|
||||
const steamRes = new MockResponse();
|
||||
await handleSteamRefresh(steamReq, steamRes);
|
||||
@@ -1,54 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Standalone Backend-Test
|
||||
* Testet die API-Funktionen direkt ohne Vite-Server
|
||||
*/
|
||||
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const rootDir = join(__dirname, "..");
|
||||
|
||||
console.log("=".repeat(60));
|
||||
console.log("Backend API Test");
|
||||
console.log("=".repeat(60));
|
||||
|
||||
// Test 1: Config File lesen
|
||||
console.log("\n[TEST 1] Config File direkt lesen");
|
||||
console.log("-".repeat(60));
|
||||
|
||||
const configPath = join(rootDir, "config.local.json");
|
||||
console.log("Config Pfad:", configPath);
|
||||
|
||||
try {
|
||||
const configRaw = await readFile(configPath, "utf-8");
|
||||
console.log("\n✓ Datei gelesen, Größe:", configRaw.length, "bytes");
|
||||
console.log("\nInhalt:");
|
||||
console.log(configRaw);
|
||||
|
||||
const config = JSON.parse(configRaw);
|
||||
console.log("\n✓ JSON parsing erfolgreich");
|
||||
console.log("\nGeparste Config:");
|
||||
console.log(JSON.stringify(config, null, 2));
|
||||
|
||||
if (config.steam?.apiKey && config.steam?.steamId) {
|
||||
console.log("\n✓ Steam-Daten vorhanden:");
|
||||
console.log(" - API Key:", config.steam.apiKey.substring(0, 8) + "...");
|
||||
console.log(" - Steam ID:", config.steam.steamId);
|
||||
} else {
|
||||
console.log("\n⚠️ Steam-Daten nicht vollständig");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("\n❌ Fehler beim Lesen der Config:");
|
||||
console.error(" Error:", error.message);
|
||||
console.error(" Stack:", error.stack);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("\n" + "=".repeat(60));
|
||||
console.log("✓ Alle Tests bestanden!");
|
||||
console.log("=".repeat(60));
|
||||
@@ -1,28 +0,0 @@
|
||||
/**
|
||||
* Einfacher Test: Lädt config.local.json
|
||||
*/
|
||||
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const configPath = join(__dirname, "..", "config.local.json");
|
||||
|
||||
console.log("Config Pfad:", configPath);
|
||||
|
||||
try {
|
||||
const configData = await readFile(configPath, "utf-8");
|
||||
console.log("\nRaw File Content:");
|
||||
console.log(configData);
|
||||
|
||||
const config = JSON.parse(configData);
|
||||
console.log("\nParsed Config:");
|
||||
console.log(JSON.stringify(config, null, 2));
|
||||
|
||||
console.log("\n✓ Config erfolgreich geladen!");
|
||||
} catch (error) {
|
||||
console.error("\n❌ Fehler:", error.message);
|
||||
console.error(error);
|
||||
}
|
||||
@@ -1,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);
|
||||
}
|
||||
}
|
||||
@@ -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
210
src/data/tutorials.ts
Normal 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",
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
231
styles.css
231
styles.css
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user