diff --git a/package.json b/package.json index a5d05e3..1dba0ca 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/clear-storage.html b/public/clear-storage.html new file mode 100644 index 0000000..9250908 --- /dev/null +++ b/public/clear-storage.html @@ -0,0 +1,20 @@ + + + + Clear Storage + + +

Clearing Storage...

+ + + diff --git a/scripts/steam-cli.mjs b/scripts/steam-cli.mjs new file mode 100644 index 0000000..1254617 --- /dev/null +++ b/scripts/steam-cli.mjs @@ -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 "); + console.error( + " oder config.local.json mit steam.apiKey und steam.steamId", + ); + process.exit(1); + } + + console.log("\nParameter:"); + console.log(" API Key:", apiKey.substring(0, 8) + "..."); + console.log(" Steam ID:", steamId); + console.log("\nRufe Steam API auf...\n"); + + try { + const result = await fetchSteamGames(apiKey, steamId); + + console.log("=".repeat(70)); + console.log("✓ Erfolgreich!"); + console.log("=".repeat(70)); + console.log(`\nAnzahl Spiele: ${result.count}`); + + if (result.count > 0) { + console.log("\nErste 5 Spiele:"); + console.log("-".repeat(70)); + result.games.slice(0, 5).forEach((game, idx) => { + console.log(`\n${idx + 1}. ${game.title}`); + console.log(` ID: ${game.id}`); + console.log(` Spielzeit: ${game.playtimeHours}h`); + console.log(` Zuletzt gespielt: ${game.lastPlayed || "nie"}`); + console.log(` URL: ${game.url}`); + }); + + console.log("\n" + "-".repeat(70)); + console.log("\nKomplettes JSON (erste 3 Spiele):"); + console.log(JSON.stringify(result.games.slice(0, 3), null, 2)); + } + + console.log("\n" + "=".repeat(70)); + console.log("✓ Test erfolgreich abgeschlossen"); + console.log("=".repeat(70) + "\n"); + } catch (error) { + console.error("\n" + "=".repeat(70)); + console.error("❌ Fehler:"); + console.error("=".repeat(70)); + console.error("\nMessage:", error.message); + if (error.stack) { + console.error("\nStack:"); + console.error(error.stack); + } + console.error("\n" + "=".repeat(70) + "\n"); + process.exit(1); + } +} + +main(); diff --git a/scripts/test-api.mjs b/scripts/test-api.mjs new file mode 100644 index 0000000..9df28ec --- /dev/null +++ b/scripts/test-api.mjs @@ -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); diff --git a/scripts/test-backend.mjs b/scripts/test-backend.mjs new file mode 100644 index 0000000..3d6929a --- /dev/null +++ b/scripts/test-backend.mjs @@ -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)); diff --git a/scripts/test-config-load.mjs b/scripts/test-config-load.mjs new file mode 100644 index 0000000..82e2a54 --- /dev/null +++ b/scripts/test-config-load.mjs @@ -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); +} diff --git a/server/steam-api.mjs b/server/steam-api.mjs new file mode 100644 index 0000000..b7f95b3 --- /dev/null +++ b/server/steam-api.mjs @@ -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", + }), + ); + } +} diff --git a/server/steam-backend.mjs b/server/steam-backend.mjs new file mode 100644 index 0000000..f86ac22 --- /dev/null +++ b/server/steam-backend.mjs @@ -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, + }; +} diff --git a/server/steam-backend.test.mjs b/server/steam-backend.test.mjs new file mode 100644 index 0000000..8785b0b --- /dev/null +++ b/server/steam-backend.test.mjs @@ -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/, + ); + }); + }); +}); diff --git a/src/pages/Library/LibraryPage.tsx b/src/pages/Library/LibraryPage.tsx index d65cea2..649c8b1 100644 --- a/src/pages/Library/LibraryPage.tsx +++ b/src/pages/Library/LibraryPage.tsx @@ -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(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) { diff --git a/src/pages/Settings/SettingsDetailPage.css b/src/pages/Settings/SettingsDetailPage.css index beae07c..1c6e70d 100644 --- a/src/pages/Settings/SettingsDetailPage.css +++ b/src/pages/Settings/SettingsDetailPage.css @@ -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; +} diff --git a/src/pages/Settings/SettingsDetailPage.tsx b/src/pages/Settings/SettingsDetailPage.tsx index e6a9bdd..bac311d 100644 --- a/src/pages/Settings/SettingsDetailPage.tsx +++ b/src/pages/Settings/SettingsDetailPage.tsx @@ -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({}); const [showAlert, setShowAlert] = useState(false); const [alertMessage, setAlertMessage] = useState(""); + const [apiOutput, setApiOutput] = useState(""); 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() { Steam API Key handleDraftChange("steam", { @@ -238,15 +348,19 @@ export default function SettingsDetailPage() { /> - Steam ID + Steam Profil URL oder ID + 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, + }); + }} /> @@ -278,7 +392,7 @@ export default function SettingsDetailPage() { GOG User ID @@ -291,7 +405,7 @@ export default function SettingsDetailPage() { Access Token @@ -451,7 +565,7 @@ export default function SettingsDetailPage() { Client ID @@ -464,7 +578,7 @@ export default function SettingsDetailPage() { Client Secret @@ -518,11 +632,10 @@ export default function SettingsDetailPage() { Config exportieren - + Config importieren - handleClearConfig()} > - - Alle Einstellungen löschen + + Alle Einstellungen löschen )} {isProvider && ( -
- - Letzter Abruf:{" "} - {formatLastRefresh(lastRefresh)} - -
+ <> +
+ + Letzter Abruf:{" "} + {formatLastRefresh(lastRefresh)} + +
+ {apiOutput && ( +
+ + API Response: + +
{apiOutput}
+
+ )} + )}
diff --git a/src/pages/Settings/SettingsTutorialPage.css b/src/pages/Settings/SettingsTutorialPage.css index 59462a4..ca7c868 100644 --- a/src/pages/Settings/SettingsTutorialPage.css +++ b/src/pages/Settings/SettingsTutorialPage.css @@ -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 { diff --git a/src/services/ConfigService.ts b/src/services/ConfigService.ts index 7f555b4..8db3281 100644 --- a/src/services/ConfigService.ts +++ b/src/services/ConfigService.ts @@ -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 { 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 { - 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"); } diff --git a/src/services/Database.ts b/src/services/Database.ts new file mode 100644 index 0000000..2a363c8 --- /dev/null +++ b/src/services/Database.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(); diff --git a/vite.config.ts b/vite.config.ts index 58bd0a9..8850076 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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, + }, + }, });