add working settings for steam
This commit is contained in:
@@ -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
20
public/clear-storage.html
Normal 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
101
scripts/steam-cli.mjs
Normal 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
75
scripts/test-api.mjs
Normal 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
54
scripts/test-backend.mjs
Normal 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));
|
||||
28
scripts/test-config-load.mjs
Normal file
28
scripts/test-config-load.mjs
Normal 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
92
server/steam-api.mjs
Normal 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
54
server/steam-backend.mjs
Normal 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,
|
||||
};
|
||||
}
|
||||
104
server/steam-backend.test.mjs
Normal file
104
server/steam-backend.test.mjs
Normal 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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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" }} />
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
210
src/services/Database.ts
Normal 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();
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user