add working settings for steam

This commit is contained in:
2026-02-05 12:06:25 +01:00
parent 380f010a7e
commit 83ffd6212e
16 changed files with 1047 additions and 139 deletions

View File

@@ -6,7 +6,9 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "node --test server/**/*.test.mjs",
"test:steam": "node scripts/steam-cli.mjs"
},
"dependencies": {
"@ionic/react": "^8.0.0",

20
public/clear-storage.html Normal file
View File

@@ -0,0 +1,20 @@
<!doctype html>
<html>
<head>
<title>Clear Storage</title>
</head>
<body>
<h2>Clearing Storage...</h2>
<script>
// Clear localStorage
localStorage.clear();
// Clear IndexedDB
indexedDB.deleteDatabase("whattoplay");
document.write("<p>✓ localStorage cleared</p>");
document.write("<p>✓ IndexedDB deleted</p>");
document.write("<br><p>Close this tab and reload the app.</p>");
</script>
</body>
</html>

101
scripts/steam-cli.mjs Normal file
View File

@@ -0,0 +1,101 @@
#!/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();

75
scripts/test-api.mjs Normal file
View File

@@ -0,0 +1,75 @@
/**
* 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);

54
scripts/test-backend.mjs Normal file
View File

@@ -0,0 +1,54 @@
#!/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

@@ -0,0 +1,28 @@
/**
* 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);
}

92
server/steam-api.mjs Normal file
View File

@@ -0,0 +1,92 @@
/**
* Steam API Handler für Vite Dev Server
* Fungiert als Proxy um CORS-Probleme zu vermeiden
*/
import { fetchSteamGames } from "./steam-backend.mjs";
export async function handleSteamRefresh(req, res) {
if (req.method !== "POST") {
res.statusCode = 405;
res.end("Method Not Allowed");
return;
}
let body = "";
req.on("data", (chunk) => {
body += chunk.toString();
});
req.on("end", async () => {
try {
let payload;
try {
payload = JSON.parse(body || "{}");
} catch (error) {
res.statusCode = 400;
res.end(
JSON.stringify({
error: "Ungültiges JSON im Request-Body",
}),
);
return;
}
const { apiKey, steamId } = payload;
if (!apiKey || !steamId) {
res.statusCode = 400;
res.end(JSON.stringify({ error: "apiKey und steamId erforderlich" }));
return;
}
const { games, count } = await fetchSteamGames(apiKey, steamId);
res.statusCode = 200;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ games, count }));
} catch (error) {
res.statusCode = 500;
res.end(
JSON.stringify({
error: error instanceof Error ? error.message : String(error),
}),
);
}
});
}
/**
* 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",
}),
);
}
}

54
server/steam-backend.mjs Normal file
View File

@@ -0,0 +1,54 @@
/**
* Steam Backend - Isoliertes Modul für Steam API Calls
* Keine Dependencies zu Vite oder Express
*/
/**
* Ruft Steam API auf und gibt formatierte Spiele zurück
* @param {string} apiKey - Steam Web API Key
* @param {string} steamId - Steam User ID
* @returns {Promise<{games: Array, count: number}>}
*/
export async function fetchSteamGames(apiKey, steamId) {
if (!apiKey || !steamId) {
throw new Error("apiKey und steamId sind erforderlich");
}
// Steam API aufrufen
const url = new URL(
"https://api.steampowered.com/IPlayerService/GetOwnedGames/v1/",
);
url.searchParams.set("key", apiKey);
url.searchParams.set("steamid", steamId);
url.searchParams.set("include_appinfo", "true");
url.searchParams.set("include_played_free_games", "true");
const response = await fetch(url);
if (!response.ok) {
throw new Error(
`Steam API Error: ${response.status} ${response.statusText}`,
);
}
const data = await response.json();
const rawGames = data.response?.games ?? [];
// Spiele formatieren
const games = rawGames.map((game) => ({
id: `steam-${game.appid}`,
title: game.name,
platform: "PC",
lastPlayed: game.rtime_last_played
? new Date(game.rtime_last_played * 1000).toISOString().slice(0, 10)
: null,
playtimeHours: Math.round((game.playtime_forever / 60) * 10) / 10,
url: `https://store.steampowered.com/app/${game.appid}`,
source: "steam",
}));
return {
games,
count: games.length,
};
}

View File

@@ -0,0 +1,104 @@
/**
* Tests für Steam Backend
* Verwendung: node --test server/steam-backend.test.mjs
*/
import { describe, it } from "node:test";
import assert from "node:assert";
import { fetchSteamGames } from "./steam-backend.mjs";
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Lade Test-Credentials aus config.local.json
async function loadTestConfig() {
try {
const configPath = join(__dirname, "..", "config.local.json");
const configData = await readFile(configPath, "utf-8");
const config = JSON.parse(configData);
return config.steam;
} catch {
return null;
}
}
describe("Steam Backend", () => {
describe("fetchSteamGames()", () => {
it("sollte Fehler werfen wenn apiKey fehlt", async () => {
await assert.rejects(
async () => await fetchSteamGames(null, "12345"),
/apiKey und steamId sind erforderlich/,
);
});
it("sollte Fehler werfen wenn steamId fehlt", async () => {
await assert.rejects(
async () => await fetchSteamGames("test-key", null),
/apiKey und steamId sind erforderlich/,
);
});
it("sollte Spiele von echter Steam API laden", async () => {
const testConfig = await loadTestConfig();
if (!testConfig?.apiKey || !testConfig?.steamId) {
console.log("⚠️ Überspringe Test - config.local.json nicht vorhanden");
return;
}
const result = await fetchSteamGames(
testConfig.apiKey,
testConfig.steamId,
);
// Validiere Struktur
assert.ok(result, "Result sollte existieren");
assert.ok(
typeof result.count === "number",
"count sollte eine Zahl sein",
);
assert.ok(Array.isArray(result.games), "games sollte ein Array sein");
assert.strictEqual(
result.count,
result.games.length,
"count sollte games.length entsprechen",
);
// Validiere erstes Spiel (wenn vorhanden)
if (result.games.length > 0) {
const firstGame = result.games[0];
assert.ok(firstGame.id, "Spiel sollte ID haben");
assert.ok(firstGame.title, "Spiel sollte Titel haben");
assert.strictEqual(firstGame.platform, "PC", "Platform sollte PC sein");
assert.strictEqual(
firstGame.source,
"steam",
"Source sollte steam sein",
);
assert.ok(
typeof firstGame.playtimeHours === "number",
"playtimeHours sollte eine Zahl sein",
);
assert.ok(
firstGame.url?.includes("steampowered.com"),
"URL sollte steampowered.com enthalten",
);
console.log(`\n${result.count} Spiele erfolgreich geladen`);
console.log(
` Beispiel: "${firstGame.title}" (${firstGame.playtimeHours}h)`,
);
}
});
it("sollte Fehler bei ungültigen Credentials werfen", async () => {
await assert.rejects(
async () => await fetchSteamGames("invalid-key", "invalid-id"),
/Steam API Error/,
);
});
});
});

View File

@@ -17,6 +17,7 @@ import {
} from "@ionic/react";
import { swapVerticalOutline } from "ionicons/icons";
import { useEffect, useMemo, useState } from "react";
import { db } from "../../services/Database";
import "./LibraryPage.css";
@@ -76,7 +77,7 @@ export default function LibraryPage() {
const [error, setError] = useState<string | null>(null);
const [searchText, setSearchText] = useState("");
const [sortBy, setSortBy] = useState<"title" | "playtime" | "lastPlayed">(
"title",
"playtime",
);
const [showSortSheet, setShowSortSheet] = useState(false);
@@ -87,34 +88,11 @@ export default function LibraryPage() {
try {
setLoading(true);
// Lade sources.json
const sourcesResponse = await fetch("/data/sources.json");
if (!sourcesResponse.ok) {
throw new Error("sources.json konnte nicht geladen werden.");
}
const sourcesConfig = (await sourcesResponse.json()) as {
sources: SourceConfig[];
};
// Lade alle Spiele von allen Quellen
const allGamesArrays = await Promise.all(
sourcesConfig.sources.map(async (source) => {
try {
const response = await fetch(source.file);
if (!response.ok) return [];
const games = (await response.json()) as SteamGame[];
return games.map((game) => ({ ...game, source: source.name }));
} catch {
return [];
}
}),
);
const allGames = allGamesArrays.flat();
const merged = mergeGames(allGames);
// Lade Spiele aus IndexedDB
const dbGames = await db.getGames();
if (active) {
setGames(merged);
setGames(dbGames);
setError(null);
}
} catch (err) {

View File

@@ -47,3 +47,23 @@
padding: 0 16px 8px;
font-size: 0.85rem;
}
.settings-detail-api-output {
margin: 12px 16px;
padding: 12px;
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 8px;
font-size: 0.85rem;
}
.settings-detail-api-output pre {
margin: 8px 0 0 0;
padding: 0;
white-space: pre-wrap;
word-break: break-word;
font-family: "Monaco", "Courier New", monospace;
font-size: 0.8rem;
color: #374151;
overflow-x: auto;
}

View File

@@ -19,12 +19,11 @@ import {
IonToolbar,
} from "@ionic/react";
import {
cloudUploadOutline,
downloadOutline,
informationCircleOutline,
refreshOutline,
saveOutline,
settingsOutline,
shareOutline,
timeOutline,
trashOutline,
} from "ionicons/icons";
@@ -34,6 +33,7 @@ import {
ConfigService,
type ServiceConfig,
} from "../../services/ConfigService";
import { db } from "../../services/Database";
import "./SettingsDetailPage.css";
@@ -82,13 +82,68 @@ export default function SettingsDetailPage() {
const [config, setConfig] = useState<ServiceConfig>({});
const [showAlert, setShowAlert] = useState(false);
const [alertMessage, setAlertMessage] = useState("");
const [apiOutput, setApiOutput] = useState<string>("");
const meta = useMemo(() => SERVICE_META[serviceId as ServiceId], [serviceId]);
useEffect(() => {
const loadedConfig = ConfigService.loadConfig();
setConfig(loadedConfig);
}, []);
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");
const responseText = await response.text();
console.log("API Response Status:", response.status);
console.log("API Response Text:", responseText);
if (response.ok) {
const testConfig = JSON.parse(responseText);
loadedConfig = { ...loadedConfig, ...testConfig };
console.log("Test-Modus: Geladene Config:", loadedConfig);
setAlertMessage("🛠️ Test-Modus: config.local.json geladen");
setShowAlert(true);
} else {
console.error(
"API /api/config/load fehlgeschlagen:",
response.status,
responseText,
);
}
} catch (error) {
console.error(
"Test-Modus aktiv, aber config.local.json nicht ladbar",
error,
);
}
}
// Query-Parameter für Steam-Seite übernehmen
if (serviceId === "steam") {
const query = new URLSearchParams(window.location.search);
const steamIdParam = query.get("steamid") ?? query.get("steamId") ?? "";
const apiKeyParam = query.get("apikey") ?? query.get("apiKey") ?? "";
if (steamIdParam || apiKeyParam) {
loadedConfig = {
...loadedConfig,
steam: {
...loadedConfig.steam,
...(steamIdParam ? { steamId: steamIdParam } : {}),
...(apiKeyParam ? { apiKey: apiKeyParam } : {}),
},
};
}
}
setConfig(loadedConfig);
};
loadConfig();
}, [serviceId]);
const handleDraftChange = (service: keyof ServiceConfig, data: any) => {
setConfig((prev) => ({
@@ -97,24 +152,77 @@ export default function SettingsDetailPage() {
}));
};
const handleSaveService = (service: keyof ServiceConfig) => {
ConfigService.saveConfig(config);
const handleSaveService = async (service: keyof ServiceConfig) => {
await ConfigService.saveConfig(config);
setAlertMessage(`${service.toUpperCase()} Einstellungen gespeichert`);
setShowAlert(true);
// Automatisch Daten abrufen nach dem Speichern
if (service === "steam") {
await handleManualRefresh(service);
}
};
const handleManualRefresh = (service: keyof ServiceConfig) => {
const updatedConfig = {
...config,
[service]: {
...config[service],
lastRefresh: new Date().toISOString(),
},
};
setConfig(updatedConfig);
ConfigService.saveConfig(updatedConfig);
setAlertMessage(`${service.toUpperCase()} aktualisiert`);
setShowAlert(true);
const handleManualRefresh = async (service: keyof ServiceConfig) => {
setApiOutput("Rufe API auf...");
try {
if (service === "steam") {
const steamConfig = config.steam;
if (!steamConfig?.apiKey || !steamConfig?.steamId) {
setApiOutput("❌ Fehler: Steam API Key und Steam ID erforderlich");
setAlertMessage("Bitte zuerst Steam-Zugangsdaten eingeben");
setShowAlert(true);
return;
}
// Rufe Backend-Endpoint auf (statt direkt Steam API wegen CORS)
const response = await fetch("/api/steam/refresh", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
apiKey: steamConfig.apiKey,
steamId: steamConfig.steamId,
}),
});
if (!response.ok) {
const errorText = await response.text();
setApiOutput(`❌ API Fehler: ${response.status}\n${errorText}`);
setAlertMessage("Steam Refresh fehlgeschlagen");
setShowAlert(true);
return;
}
const result = await response.json();
// Spiele in Database speichern
await db.saveGames(result.games);
setApiOutput(
`${result.games.length} Spiele geladen und gespeichert\n\nBeispiel:\n${JSON.stringify(result.games.slice(0, 2), null, 2)}`,
);
}
const updatedConfig = {
...config,
[service]: {
...config[service],
lastRefresh: new Date().toISOString(),
},
};
setConfig(updatedConfig);
await ConfigService.saveConfig(updatedConfig);
setAlertMessage(`${service.toUpperCase()} aktualisiert`);
setShowAlert(true);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
setApiOutput(
`❌ Fehler: ${errorMsg}\n\n💡 Tipp: Führe stattdessen im Terminal aus:\nnpm run fetch:steam`,
);
setAlertMessage("Aktualisierung fehlgeschlagen");
setShowAlert(true);
}
};
const formatLastRefresh = (value?: string) => {
@@ -157,10 +265,12 @@ export default function SettingsDetailPage() {
setShowAlert(true);
};
const handleClearConfig = () => {
ConfigService.clearConfig();
const handleClearConfig = async () => {
await ConfigService.clearConfig();
await db.clear();
setConfig({});
setAlertMessage("✓ Alle Einstellungen gelöscht");
setApiOutput("");
setAlertMessage("✓ Alle Einstellungen und Spiele gelöscht");
setShowAlert(true);
};
@@ -227,8 +337,8 @@ export default function SettingsDetailPage() {
<IonItem>
<IonLabel position="stacked">Steam API Key</IonLabel>
<IonInput
type="password"
placeholder="XXXXXXXXXXXXXXXXXX"
type="text"
placeholder="Dein Steam Web API Key"
value={config.steam?.apiKey || ""}
onIonChange={(e) =>
handleDraftChange("steam", {
@@ -238,15 +348,19 @@ export default function SettingsDetailPage() {
/>
</IonItem>
<IonItem>
<IonLabel position="stacked">Steam ID</IonLabel>
<IonLabel position="stacked">Steam Profil URL oder ID</IonLabel>
<IonInput
placeholder="76561197960434622"
placeholder="steamcommunity.com/id/deinname oder Steam ID"
value={config.steam?.steamId || ""}
onIonChange={(e) =>
onIonChange={(e) => {
const input = e.detail.value || "";
// Extract Steam ID from URL if provided
const idMatch = input.match(/\/(id|profiles)\/(\w+)/);
const extractedId = idMatch ? idMatch[2] : input;
handleDraftChange("steam", {
steamId: e.detail.value || "",
})
}
steamId: extractedId,
});
}}
/>
</IonItem>
</IonList>
@@ -278,7 +392,7 @@ export default function SettingsDetailPage() {
<IonItem>
<IonLabel position="stacked">GOG User ID</IonLabel>
<IonInput
type="password"
type="text"
placeholder="galaxyUserId"
value={config.gog?.userId || ""}
onIonChange={(e) =>
@@ -291,7 +405,7 @@ export default function SettingsDetailPage() {
<IonItem>
<IonLabel position="stacked">Access Token</IonLabel>
<IonInput
type="password"
type="text"
placeholder="Bearer token"
value={config.gog?.accessToken || ""}
onIonChange={(e) =>
@@ -451,7 +565,7 @@ export default function SettingsDetailPage() {
<IonItem>
<IonLabel position="stacked">Client ID</IonLabel>
<IonInput
type="password"
type="text"
placeholder="your_client_id"
value={config.blizzard?.clientId || ""}
onIonChange={(e) =>
@@ -464,7 +578,7 @@ export default function SettingsDetailPage() {
<IonItem>
<IonLabel position="stacked">Client Secret</IonLabel>
<IonInput
type="password"
type="text"
placeholder="your_client_secret"
value={config.blizzard?.clientSecret || ""}
onIonChange={(e) =>
@@ -518,11 +632,10 @@ export default function SettingsDetailPage() {
<IonList inset>
<IonItem button onClick={handleExportConfig}>
<IonLabel>Config exportieren</IonLabel>
<IonIcon slot="end" icon={downloadOutline} />
<IonIcon slot="end" icon={shareOutline} />
</IonItem>
<IonItem className="settings-detail-file-item">
<IonLabel>Config importieren</IonLabel>
<IonIcon slot="end" icon={cloudUploadOutline} />
<input
type="file"
accept=".json"
@@ -535,22 +648,32 @@ export default function SettingsDetailPage() {
<IonButton
expand="block"
color="danger"
onClick={handleClearConfig}
onClick={() => handleClearConfig()}
>
<IonIcon icon={trashOutline} />
<IonLabel>Alle Einstellungen löschen</IonLabel>
<IonIcon slot="start" icon={trashOutline} />
Alle Einstellungen löschen
</IonButton>
</div>
</>
)}
{isProvider && (
<div className="settings-detail-last-refresh">
<IonText color="medium">
<IonIcon icon={timeOutline} /> Letzter Abruf:{" "}
{formatLastRefresh(lastRefresh)}
</IonText>
</div>
<>
<div className="settings-detail-last-refresh">
<IonText color="medium">
<IonIcon icon={timeOutline} /> Letzter Abruf:{" "}
{formatLastRefresh(lastRefresh)}
</IonText>
</div>
{apiOutput && (
<div className="settings-detail-api-output">
<IonText color="medium">
<strong>API Response:</strong>
</IonText>
<pre>{apiOutput}</pre>
</div>
)}
</>
)}
<div style={{ paddingBottom: "24px" }} />

View File

@@ -2,21 +2,54 @@
padding: 8px 16px 16px;
}
.settings-tutorial-step {
margin-bottom: 20px;
padding: 16px;
background: #ffffff;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.settings-tutorial-step h3 {
margin: 0 0 12px 0;
font-size: 1.1rem;
font-weight: 600;
color: #1f2937;
}
.settings-tutorial-step p {
margin: 0 0 12px 0;
line-height: 1.6;
color: #4b5563;
}
.settings-tutorial-code {
background: #f3f4f6;
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 12px;
margin: 12px 0;
white-space: pre-wrap;
font-family: "Monaco", "Courier New", monospace;
font-size: 0.85rem;
color: #374151;
overflow-x: auto;
}
.settings-tutorial-hint {
margin-top: 8px;
padding: 10px 12px;
background: rgba(0, 0, 0, 0.05);
border-radius: 8px;
font-size: 0.85rem;
color: #6b7280;
margin-top: 12px;
padding: 12px;
background: #fef3c7;
border-left: 4px solid #f59e0b;
border-radius: 6px;
font-size: 0.9rem;
color: #92400e;
line-height: 1.5;
}
.settings-tutorial-hint::before {
content: "💡 ";
font-size: 1rem;
}
.settings-tutorial-empty {

View File

@@ -1,7 +1,8 @@
/**
* ConfigService - Sichere Konfigurationsverwaltung
* Speichert Credentials lokal mit Best Practices
* Nutzt IndexedDB (Primary) mit localStorage Fallback (wie Voyager)
*/
import { db } from "./Database";
export interface ServiceConfig {
steam?: {
@@ -33,32 +34,60 @@ export interface ServiceConfig {
}
const STORAGE_KEY = "whattoplay_config";
const ENCRYPTED_STORAGE_KEY = "whattoplay_secure";
export class ConfigService {
/**
* Lade Konfiguration aus localStorage
* Lade Konfiguration aus IndexedDB (Primary) oder localStorage (Fallback)
*/
static loadConfig(): ServiceConfig {
static async loadConfig(): Promise<ServiceConfig> {
try {
// Versuche IndexedDB
const dbConfig = await db.getConfig();
if (dbConfig) {
return dbConfig;
}
// Fallback: localStorage
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : {};
const config = stored ? JSON.parse(stored) : {};
// Migriere zu IndexedDB
if (stored) {
await db.saveConfig(config);
}
return config;
} catch (error) {
console.warn("Config konnte nicht geladen werden", error);
return {};
// Letzter Fallback: localStorage
try {
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : {};
} catch {
return {};
}
}
}
/**
* Speichere Konfiguration in localStorage
* Speichere Konfiguration in IndexedDB + localStorage
*/
static saveConfig(config: ServiceConfig) {
static async saveConfig(config: ServiceConfig) {
try {
// Speichere in IndexedDB
await db.saveConfig(config);
// Redundanz: auch in localStorage
localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
return true;
} catch (error) {
console.error("Config konnte nicht gespeichert werden", error);
return false;
// Fallback: zumindest localStorage
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
return true;
} catch {
return false;
}
}
}
@@ -85,7 +114,7 @@ export class ConfigService {
try {
const text = await file.text();
const config = JSON.parse(text);
this.saveConfig(config);
await this.saveConfig(config);
return config;
} catch (error) {
console.error("Config-Import fehlgeschlagen", error);
@@ -94,55 +123,14 @@ export class ConfigService {
}
/**
* Backup zu IndexedDB für redundante Speicherung
* Lösche sensitive Daten aus IndexedDB + localStorage
*/
static async backupToIndexedDB(config: ServiceConfig) {
return new Promise((resolve, reject) => {
const request = indexedDB.open("whattoplay", 1);
request.onerror = () => reject(request.error);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains("config")) {
db.createObjectStore("config");
}
};
request.onsuccess = () => {
const db = request.result;
const tx = db.transaction("config", "readwrite");
const store = tx.objectStore("config");
store.put(config, ENCRYPTED_STORAGE_KEY);
resolve(true);
};
});
}
/**
* Wiederherstelle aus IndexedDB Backup
*/
static async restoreFromIndexedDB(): Promise<ServiceConfig | null> {
return new Promise((resolve) => {
const request = indexedDB.open("whattoplay", 1);
request.onerror = () => resolve(null);
request.onsuccess = () => {
const db = request.result;
const tx = db.transaction("config", "readonly");
const store = tx.objectStore("config");
const getRequest = store.get(ENCRYPTED_STORAGE_KEY);
getRequest.onsuccess = () => {
resolve(getRequest.result || null);
};
};
});
}
/**
* Lösche sensitive Daten
*/
static clearConfig() {
static async clearConfig() {
try {
await db.clear();
} catch (error) {
console.warn("IndexedDB konnte nicht gelöscht werden", error);
}
localStorage.removeItem(STORAGE_KEY);
console.log("✓ Config gelöscht");
}

210
src/services/Database.ts Normal file
View File

@@ -0,0 +1,210 @@
/**
* Database Service - IndexedDB für PWA-Persistenz
* Strategie wie Voyager: IndexedDB als Primary Storage
*/
export interface DbConfig {
steam?: {
apiKey: string;
steamId: string;
lastRefresh?: string;
};
gog?: {
userId: string;
accessToken: string;
lastRefresh?: string;
};
epic?: {
email?: string;
method?: "oauth" | "manual";
lastRefresh?: string;
};
amazon?: {
email?: string;
method?: "oauth" | "manual";
lastRefresh?: string;
};
blizzard?: {
clientId: string;
clientSecret: string;
region: "us" | "eu" | "kr" | "tw";
lastRefresh?: string;
};
}
export interface Game {
id: string;
title: string;
platform?: string;
lastPlayed?: string | null;
playtimeHours?: number;
url?: string;
source?: string;
}
const DB_NAME = "whattoplay";
const DB_VERSION = 1;
class Database {
private db: IDBDatabase | null = null;
async init(): Promise<void> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => reject(request.error);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
// Config Store
if (!db.objectStoreNames.contains("config")) {
db.createObjectStore("config", { keyPath: "id" });
}
// Games Store
if (!db.objectStoreNames.contains("games")) {
const gameStore = db.createObjectStore("games", { keyPath: "id" });
gameStore.createIndex("source", "source", { unique: false });
gameStore.createIndex("title", "title", { unique: false });
}
// Settings Store
if (!db.objectStoreNames.contains("settings")) {
db.createObjectStore("settings", { keyPath: "key" });
}
// Sync Log (für zukünftige Cloud-Sync)
if (!db.objectStoreNames.contains("syncLog")) {
db.createObjectStore("syncLog", {
keyPath: "id",
autoIncrement: true,
});
}
};
request.onsuccess = () => {
this.db = request.result;
resolve();
};
});
}
async getConfig(): Promise<DbConfig | null> {
if (!this.db) await this.init();
return new Promise((resolve, reject) => {
const tx = this.db!.transaction("config", "readonly");
const store = tx.objectStore("config");
const request = store.get("main");
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result || null);
});
}
async saveConfig(config: DbConfig): Promise<void> {
if (!this.db) await this.init();
return new Promise((resolve, reject) => {
const tx = this.db!.transaction("config", "readwrite");
const store = tx.objectStore("config");
const request = store.put({ id: "main", ...config });
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve();
});
}
async saveGames(games: Game[]): Promise<void> {
if (!this.db) await this.init();
return new Promise((resolve, reject) => {
const tx = this.db!.transaction("games", "readwrite");
const store = tx.objectStore("games");
// Lösche alte Spiele
const clearRequest = store.clear();
clearRequest.onsuccess = () => {
// Füge neue Spiele ein
games.forEach((game) => store.add(game));
resolve();
};
clearRequest.onerror = () => reject(clearRequest.error);
});
}
async getGames(): Promise<Game[]> {
if (!this.db) await this.init();
return new Promise((resolve, reject) => {
const tx = this.db!.transaction("games", "readonly");
const store = tx.objectStore("games");
const request = store.getAll();
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result || []);
});
}
async getGamesBySource(source: string): Promise<Game[]> {
if (!this.db) await this.init();
return new Promise((resolve, reject) => {
const tx = this.db!.transaction("games", "readonly");
const store = tx.objectStore("games");
const index = store.index("source");
const request = index.getAll(source);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result || []);
});
}
async getSetting(key: string): Promise<any> {
if (!this.db) await this.init();
return new Promise((resolve, reject) => {
const tx = this.db!.transaction("settings", "readonly");
const store = tx.objectStore("settings");
const request = store.get(key);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result?.value || null);
});
}
async setSetting(key: string, value: any): Promise<void> {
if (!this.db) await this.init();
return new Promise((resolve, reject) => {
const tx = this.db!.transaction("settings", "readwrite");
const store = tx.objectStore("settings");
const request = store.put({ key, value });
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve();
});
}
async clear(): Promise<void> {
if (!this.db) await this.init();
return new Promise((resolve, reject) => {
const tx = this.db!.transaction(
["config", "games", "settings", "syncLog"],
"readwrite",
);
["config", "games", "settings", "syncLog"].forEach((storeName) => {
tx.objectStore(storeName).clear();
});
tx.onerror = () => reject(tx.error);
tx.oncomplete = () => resolve();
});
}
}
// Singleton
export const db = new Database();

View File

@@ -1,6 +1,32 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import { handleSteamRefresh, handleConfigLoad } from "./server/steam-api.mjs";
const apiMiddlewarePlugin = {
name: "api-middleware",
configureServer(server) {
server.middlewares.use((req, res, next) => {
const url = req.url ?? "";
if (url.startsWith("/api/steam/refresh")) {
return handleSteamRefresh(req, res);
}
if (url.startsWith("/api/config/load")) {
return handleConfigLoad(req, res);
}
next();
});
},
};
export default defineConfig({
plugins: [react()],
plugins: [react(), apiMiddlewarePlugin],
server: {
port: 5173,
hmr: {
overlay: true,
},
watch: {
usePolling: true,
},
},
});