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