start using claude code

This commit is contained in:
2026-02-05 21:09:19 +01:00
parent 27af351695
commit 34732fdceb
15 changed files with 1513 additions and 1152 deletions

1462
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,14 @@
"build": "vite build",
"preview": "vite preview",
"test": "node --test server/**/*.test.mjs",
"test:steam": "node scripts/steam-cli.mjs"
"oauth": "node workers/oauth-proxy.mjs",
"worker:dev": "wrangler dev --config workers/wrangler.toml",
"worker:deploy": "wrangler deploy --config workers/wrangler.toml",
"fetch:steam": "node scripts/fetch-steam.mjs",
"fetch:gog": "node scripts/fetch-gog.mjs",
"fetch:epic": "node scripts/fetch-epic.mjs",
"fetch:amazon": "node scripts/fetch-amazon.mjs",
"fetch:all": "node scripts/fetch-all.mjs"
},
"dependencies": {
"@ionic/react": "^8.0.0",
@@ -26,6 +33,7 @@
"@types/react-router-dom": "^5.3.3",
"@vitejs/plugin-react": "^4.2.1",
"typescript": "^5.3.3",
"vite": "^5.0.0"
"vite": "^5.0.0",
"wrangler": "^4.63.0"
}
}

View File

@@ -1,42 +0,0 @@
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const runScript = (scriptName) => {
return new Promise((resolve, reject) => {
const scriptPath = join(__dirname, scriptName);
const child = spawn("node", [scriptPath], {
stdio: "inherit",
cwd: __dirname,
});
child.on("close", (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`${scriptName} exited with code ${code}`));
}
});
child.on("error", reject);
});
};
const run = async () => {
console.log("Starte alle API-Importer...\n");
try {
await runScript("fetch-steam.mjs");
await runScript("fetch-epic.mjs");
await runScript("fetch-gog.mjs");
await runScript("fetch-blizzard.mjs");
console.log("\n✓ Alle Importer erfolgreich ausgeführt.");
} catch (error) {
console.error("\n✗ Fehler beim Ausführen der Importer:", error.message);
process.exit(1);
}
};
run();

View File

@@ -1,183 +0,0 @@
import fs from "fs";
import path from "path";
/**
* Blizzard Account Library Importer
* Nutzt OAuth 2.0 für Authentifizierung
*
* Unterstützt:
* - World of Warcraft
* - Diablo
* - Overwatch
* - StarCraft
* - Warcraft III
* - Heroes of the Storm
* - Hearthstone
*/
const loadConfig = () => {
const configPath = path.join(process.cwd(), "config.local.json");
try {
if (fs.existsSync(configPath)) {
return JSON.parse(fs.readFileSync(configPath, "utf-8"));
}
} catch (error) {
console.log("⚠️ Config nicht lesbar, nutze Defaults");
}
return {
blizzard: {
clientId: "",
clientSecret: "",
accountName: "",
region: "eu",
},
};
};
const fetchBlizzardGames = async ({ clientId, clientSecret, region }) => {
// OAuth 2.0 Token Endpoint
const tokenUrl = `https://${region}.battle.net/oauth/token`;
const libraryUrl = `https://${region}.api.blizzard.com/d3/profile/${clientId}/hero`;
try {
// Schritt 1: Bearer Token holen (Client Credentials Flow)
const tokenResponse = await fetch(tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString("base64")}`,
},
body: new URLSearchParams({
grant_type: "client_credentials",
scope: "d3.profile.us",
}),
});
if (!tokenResponse.ok) {
throw new Error(
`Token-Fehler: ${tokenResponse.status} - ${await tokenResponse.text()}`,
);
}
const { access_token } = await tokenResponse.json();
// Schritt 2: Games/Accountinfo laden
const gamesResponse = await fetch(libraryUrl, {
headers: {
Authorization: `Bearer ${access_token}`,
"User-Agent": "WhatToPlay/1.0",
},
});
if (!gamesResponse.ok) {
console.warn(
`⚠️ Blizzard API: ${gamesResponse.status} - Möglicherweise falscher Region oder Credentials`,
);
return [];
}
const data = await gamesResponse.json();
// Blizzard gibt Heros statt Games zurück
// Wir extrahieren Informationen über verfügbare Spiele
return data.heroes || [];
} catch (error) {
console.error(`❌ Blizzard Fehler: ${error.message}`);
return [];
}
};
const buildBlizzardEntry = (hero, gameType = "Diablo III") => ({
id: `blizzard-${hero.id}`,
title: `${gameType} - ${hero.name}`,
platform: "Blizzard",
class: hero.class,
level: hero.level,
experience: hero.experience,
killed: hero.kills?.elites || 0,
hardcore: hero.hardcore || false,
lastPlayed: hero.lastUpdated
? new Date(hero.lastUpdated).toISOString()
: null,
url: `https://www.diablo3.com/en/profile/${hero.id}/`,
});
const buildTextFile = (game) => {
const lines = [
`# ${game.title}`,
"",
`**Plattform**: ${game.platform}`,
`**Charaktertyp**: ${game.class || "Unbekannt"}`,
`**Level**: ${game.level || "N/A"}`,
game.hardcore ? `**Hardcore**: Ja ⚔️` : "",
`**Elite-Kills**: ${game.killed || 0}`,
`**Erfahrung**: ${game.experience || 0}`,
game.lastPlayed
? `**Zuletzt gespielt**: ${new Date(game.lastPlayed).toLocaleDateString("de-DE")}`
: "",
"",
`[Im Profil anschauen](${game.url})`,
];
return lines.filter(Boolean).join("\n");
};
const writeBlizzardData = async (games) => {
const dataDir = path.join(process.cwd(), "public/data");
const textDir = path.join(dataDir, "blizzard-text");
// Stelle sicher dass Verzeichnisse existieren
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
if (!fs.existsSync(textDir)) fs.mkdirSync(textDir, { recursive: true });
// Schreibe JSON-Datei
fs.writeFileSync(
path.join(dataDir, "blizzard.json"),
JSON.stringify(games, null, 2),
"utf-8",
);
// Schreibe Text-Dateien für jeden Hero
games.forEach((game) => {
const textFile = `${game.id}.txt`;
const filePath = path.join(textDir, textFile);
const content = buildTextFile(game);
fs.writeFileSync(filePath, content, "utf-8");
});
return games.length;
};
const main = async () => {
const config = loadConfig();
const { clientId, clientSecret, region } = config.blizzard || {};
if (!clientId || !clientSecret) {
console.log(
"⚠️ Blizzard: Keine Credentials - Überspringe\n → Für iOS/Web: Backend mit OAuth benötigt\n → Siehe docs/BLIZZARD-SETUP.md für Development-Setup",
);
return;
}
console.log("⏳ Blizzard-Games laden...");
const games = await fetchBlizzardGames({
clientId,
clientSecret,
region: region || "eu",
});
if (games.length === 0) {
console.log(
"⚠️ Keine Blizzard-Games gefunden\n → Stelle sicher dass der Account mit Heros in Diablo III hat",
);
return;
}
// Verarbeite jeden Hero
const processedGames = games.map((hero) => buildBlizzardEntry(hero));
const count = await writeBlizzardData(processedGames);
console.log(`✓ Blizzard-Export fertig: ${count} Charaktere`);
};
main().catch(console.error);

View File

@@ -1,96 +0,0 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
const loadConfig = async () => {
const configUrl = new URL("../config.local.json", import.meta.url);
try {
const raw = await readFile(configUrl, "utf-8");
return JSON.parse(raw);
} catch {
return {};
}
};
const sanitizeFileName = (value) => {
const normalized = value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return normalized || "spiel";
};
const fetchEpicGames = async ({ accountId, accessToken }) => {
// ⚠️ Epic Games Store hat KEINE öffentliche API!
// Legendary (Python CLI) funktioniert nicht auf iOS/Web
// Lösung: Backend mit Epic OAuth oder manuelle Import-Funktion
console.warn("⚠️ Epic Games: Keine öffentliche API verfügbar");
console.log(" → Für iOS/Web: Backend mit Epic OAuth benötigt");
console.log(" → Alternative: Manuelle Library-Import-Funktion\n");
return [];
};
const buildEpicEntry = (game) => ({
id: game.id || game.catalogItemId,
title: game.title || game.displayName,
platform: "PC",
lastPlayed: game.lastPlayed || null,
playtimeHours: game.playtimeMinutes
? Math.round((game.playtimeMinutes / 60) * 10) / 10
: 0,
tags: game.categories || [],
url: game.productSlug
? `https://store.epicgames.com/en-US/p/${game.productSlug}`
: null,
});
const buildTextFile = (entry) => {
const lines = [
`Titel: ${entry.title}`,
`Epic ID: ${entry.id}`,
`Zuletzt gespielt: ${entry.lastPlayed ?? "-"}`,
`Spielzeit (h): ${entry.playtimeHours ?? 0}`,
`Store: ${entry.url ?? "-"}`,
"Quelle: epic",
];
return lines.join("\n") + "\n";
};
const writeOutputs = async (entries) => {
const dataDir = new URL("../public/data/", import.meta.url);
const textDir = new URL("../public/data/epic-text/", import.meta.url);
await mkdir(dataDir, { recursive: true });
await mkdir(textDir, { recursive: true });
const jsonPath = new URL("epic.json", dataDir);
await writeFile(jsonPath, JSON.stringify(entries, null, 2) + "\n", "utf-8");
await Promise.all(
entries.map(async (entry) => {
const fileName = `${sanitizeFileName(entry.title)}__${entry.id}.txt`;
const filePath = new URL(fileName, textDir);
await writeFile(filePath, buildTextFile(entry), "utf-8");
}),
);
};
const run = async () => {
const config = await loadConfig();
const accountId = config.epic?.accountId || process.env.EPIC_ACCOUNT_ID;
const accessToken = config.epic?.accessToken || process.env.EPIC_ACCESS_TOKEN;
if (!accountId || !accessToken) {
console.warn(
"Epic-Zugangsdaten nicht gesetzt. Erstelle leere Datei als Platzhalter.",
);
}
const games = await fetchEpicGames({ accountId, accessToken });
const entries = games.map(buildEpicEntry);
await writeOutputs(entries);
console.log(`Epic-Export fertig: ${entries.length} Spiele.`);
};
run().catch((error) => {
console.error(error);
process.exit(1);
});

View File

@@ -1,112 +0,0 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
const loadConfig = async () => {
const configUrl = new URL("../config.local.json", import.meta.url);
try {
const raw = await readFile(configUrl, "utf-8");
return JSON.parse(raw);
} catch {
return {};
}
};
const sanitizeFileName = (value) => {
const normalized = value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return normalized || "spiel";
};
const fetchGogGames = async ({ userId, accessToken }) => {
if (!userId || !accessToken) {
console.warn("⚠️ GOG: Keine Credentials - Überspringe");
console.log(" → Für iOS/Web: Backend mit OAuth benötigt");
console.log(" → Development: Token aus Browser DevTools kopieren\n");
return [];
}
try {
// GOG Galaxy Library API (wie Heroic Launcher)
const url = `https://galaxy-library.gog.com/users/${userId}/releases`;
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${accessToken}`,
"User-Agent": "WhatToPlay/1.0",
},
});
if (!response.ok) {
throw new Error(`GOG API Fehler: ${response.status}`);
}
const payload = await response.json();
// Galaxy API gibt items zurück, nicht owned
return payload.items || [];
} catch (error) {
console.error("GOG API-Aufruf fehlgeschlagen:", error.message);
console.log("💡 Tipp: Token abgelaufen? Neu aus gog.com holen\n");
return [];
}
};
const buildGogEntry = (game) => ({
// Galaxy Library API gibt external_id (GOG Product ID)
id: String(game.external_id || game.id),
title: game.title || `GOG Game ${game.external_id}`,
platform: "PC",
lastPlayed: game.date_created
? new Date(game.date_created * 1000).toISOString()
: null,
playtimeHours: 0, // Galaxy API hat keine Spielzeit in /releases endpoint
tags: [],
url: `https://www.gog.com/game/${game.external_id}`,
});
const buildTextFile = (entry) => {
const lines = [
`Titel: ${entry.title}`,
`GOG ID: ${entry.id}`,
`Zuletzt gespielt: ${entry.lastPlayed ?? "-"}`,
`Spielzeit (h): ${entry.playtimeHours ?? 0}`,
`Store: ${entry.url}`,
"Quelle: gog",
];
return lines.join("\n") + "\n";
};
const writeOutputs = async (entries) => {
const dataDir = new URL("../public/data/", import.meta.url);
const textDir = new URL("../public/data/gog-text/", import.meta.url);
await mkdir(dataDir, { recursive: true });
await mkdir(textDir, { recursive: true });
const jsonPath = new URL("gog.json", dataDir);
await writeFile(jsonPath, JSON.stringify(entries, null, 2) + "\n", "utf-8");
await Promise.all(
entries.map(async (entry) => {
const fileName = `${sanitizeFileName(entry.title)}__${entry.id}.txt`;
const filePath = new URL(fileName, textDir);
await writeFile(filePath, buildTextFile(entry), "utf-8");
}),
);
};
const run = async () => {
const config = await loadConfig();
const userId = config.gog?.userId || process.env.GOG_USER_ID;
const accessToken = config.gog?.accessToken || process.env.GOG_ACCESS_TOKEN;
const games = await fetchGogGames({ userId, accessToken });
const entries = games.map(buildGogEntry);
await writeOutputs(entries);
console.log(`GOG-Export fertig: ${entries.length} Spiele.`);
};
run().catch((error) => {
console.error(error);
process.exit(1);
});

View File

@@ -23,7 +23,6 @@ import LibraryPage from "./pages/Library/LibraryPage";
import PlaylistsPage from "./pages/Playlists/PlaylistsPage";
import SettingsPage from "./pages/Settings/SettingsPage";
import SettingsDetailPage from "./pages/Settings/SettingsDetailPage";
import SettingsTutorialPage from "./pages/Settings/SettingsTutorialPage";
import "./App.css";
@@ -43,11 +42,6 @@ export default function App() {
path="/settings/:serviceId"
component={SettingsDetailPage}
/>
<Route
exact
path="/settings/:serviceId/tutorial"
component={SettingsTutorialPage}
/>
<Route exact path="/">
<Redirect to="/home" />
</Route>

View File

@@ -1,141 +0,0 @@
import React from "react";
import {
IonPopover,
IonHeader,
IonToolbar,
IonTitle,
IonContent,
IonButtons,
IonButton,
IonIcon,
IonCard,
IonCardContent,
IonCardHeader,
IonCardTitle,
IonText,
} from "@ionic/react";
import { closeOutline } from "ionicons/icons";
import { TUTORIALS, type TutorialStep } from "../data/tutorials";
interface TutorialModalProps {
service: string | null;
onClose: () => void;
anchorEvent?: Event;
}
export default function TutorialModal({
service,
onClose,
anchorEvent,
}: TutorialModalProps) {
const tutorial = service ? TUTORIALS[service] : null;
return (
<IonPopover
isOpen={!!service}
event={anchorEvent}
onDidDismiss={onClose}
showBackdrop
>
<IonHeader>
<IonToolbar>
<IonTitle>
<IonIcon icon={tutorial?.icon} /> {tutorial?.title}
</IonTitle>
<IonButtons slot="end">
<IonButton onClick={onClose}>
<IonIcon icon={closeOutline} />
</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent fullscreen>
{tutorial && (
<>
<div style={{ paddingTop: "12px" }}>
{tutorial.steps.map((step: TutorialStep) => (
<IonCard key={step.title}>
<IonCardHeader>
<IonCardTitle
style={{
fontSize: "16px",
}}
>
{step.title}
</IonCardTitle>
</IonCardHeader>
<IonCardContent>
<p>{step.description}</p>
{step.code && (
<div
className="code-block"
style={{
backgroundColor: "#222",
color: "#0f0",
padding: "12px",
borderRadius: "4px",
fontFamily: "monospace",
fontSize: "12px",
marginTop: "8px",
overflowX: "auto",
}}
>
<code>{step.code}</code>
</div>
)}
{step.hint && (
<div
style={{
backgroundColor: "rgba(255, 193, 7, 0.1)",
borderLeft: "3px solid #ffc107",
padding: "8px 12px",
marginTop: "8px",
borderRadius: "3px",
fontSize: "13px",
}}
>
💡 {step.hint}
</div>
)}
</IonCardContent>
</IonCard>
))}
</div>
<IonCard
style={{
margin: "12px",
backgroundColor: "rgba(102, 126, 234, 0.1)",
}}
>
<IonCardHeader>
<IonCardTitle style={{ fontSize: "16px" }}>
💡 Tipps
</IonCardTitle>
</IonCardHeader>
<IonCardContent>
<ul style={{ marginLeft: "20px" }}>
{tutorial.tips.map((tip: string) => (
<li
key={tip}
style={{
marginBottom: "8px",
fontSize: "14px",
}}
>
{tip}
</li>
))}
</ul>
</IonCardContent>
</IonCard>
<div style={{ paddingBottom: "40px" }} />
</>
)}
</IonContent>
</IonPopover>
);
}

View File

@@ -237,8 +237,7 @@ export default function LibraryPage() {
<div>
<h1>Spielebibliothek</h1>
<p>
Konsolidierte Übersicht aus Steam, Epic Games und GOG. Duplikate
werden automatisch zusammengeführt.
Deine Spiele aus Steam.
</p>
</div>
<div className="hero-stats">

View File

@@ -12,14 +12,11 @@ import {
IonLabel,
IonList,
IonPage,
IonSelect,
IonSelectOption,
IonText,
IonTitle,
IonToolbar,
} from "@ionic/react";
import {
informationCircleOutline,
refreshOutline,
saveOutline,
settingsOutline,
@@ -45,37 +42,15 @@ const SERVICE_META = {
steam: {
title: "Steam",
description: "Deine Steam-Bibliothek",
tutorialKey: "steam",
},
gog: {
title: "GOG",
description: "GOG Galaxy Bibliothek",
tutorialKey: "gog",
},
epic: {
title: "Epic Games",
description: "Epic Games Launcher",
tutorialKey: "epic",
},
amazon: {
title: "Amazon Games",
description: "Prime Gaming / Luna",
tutorialKey: "amazon",
},
blizzard: {
title: "Blizzard",
description: "Battle.net / WoW / Diablo",
tutorialKey: "blizzard",
},
data: {
title: "Datenverwaltung",
description: "Export, Import und Reset",
tutorialKey: null,
},
} as const;
type ServiceId = keyof typeof SERVICE_META;
const PROVIDER_IDS = ["steam", "gog", "epic", "amazon", "blizzard"] as const;
const PROVIDER_IDS = ["steam"] as const;
export default function SettingsDetailPage() {
const { serviceId } = useParams<SettingsRouteParams>();
@@ -97,51 +72,19 @@ export default function SettingsDetailPage() {
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);
const testConfig = await response.json();
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,
);
console.log("Test-Modus: config.local.json geladen", testConfig);
}
} 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 } : {}),
},
};
console.warn("config.local.json konnte nicht geladen werden", error);
}
}
setConfig(loadedConfig);
};
loadConfig();
}, [serviceId]);
@@ -167,6 +110,8 @@ export default function SettingsDetailPage() {
setApiOutput("Rufe API auf...");
try {
let result: { games: any[]; count: number } | null = null;
if (service === "steam") {
const steamConfig = config.steam;
if (!steamConfig?.apiKey || !steamConfig?.steamId) {
@@ -176,7 +121,6 @@ export default function SettingsDetailPage() {
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" },
@@ -194,32 +138,31 @@ export default function SettingsDetailPage() {
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)}`,
);
result = await response.json();
}
const updatedConfig = {
...config,
[service]: {
...config[service],
lastRefresh: new Date().toISOString(),
},
};
setConfig(updatedConfig);
await ConfigService.saveConfig(updatedConfig);
setAlertMessage(`${service.toUpperCase()} aktualisiert`);
setShowAlert(true);
if (result) {
await db.saveGames(result.games);
const updatedConfig = {
...config,
[service]: {
...config[service],
lastRefresh: new Date().toISOString(),
},
};
setConfig(updatedConfig);
await ConfigService.saveConfig(updatedConfig);
setApiOutput(
`${result.count} Spiele abgerufen\n\nBeispiel:\n${JSON.stringify(result.games.slice(0, 2), null, 2)}`,
);
setAlertMessage(`${result.count} Spiele 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`,
);
setApiOutput(`❌ Fehler: ${errorMsg}`);
setAlertMessage("Aktualisierung fehlgeschlagen");
setShowAlert(true);
}
@@ -364,16 +307,6 @@ export default function SettingsDetailPage() {
/>
</IonItem>
</IonList>
<IonList inset>
<IonItem
button
detail
routerLink={`/settings/${serviceId}/tutorial`}
routerDirection="forward"
>
<IonLabel>Anleitung anzeigen</IonLabel>
</IonItem>
</IonList>
<div className="settings-detail-actions">
<IonButton
expand="block"
@@ -386,247 +319,6 @@ export default function SettingsDetailPage() {
</>
)}
{serviceId === "gog" && (
<>
<IonList inset>
<IonItem>
<IonLabel position="stacked">GOG User ID</IonLabel>
<IonInput
type="text"
placeholder="galaxyUserId"
value={config.gog?.userId || ""}
onIonChange={(e) =>
handleDraftChange("gog", {
userId: e.detail.value || "",
})
}
/>
</IonItem>
<IonItem>
<IonLabel position="stacked">Access Token</IonLabel>
<IonInput
type="text"
placeholder="Bearer token"
value={config.gog?.accessToken || ""}
onIonChange={(e) =>
handleDraftChange("gog", {
accessToken: e.detail.value || "",
})
}
/>
</IonItem>
</IonList>
<IonList inset>
<IonItem
button
detail
routerLink={`/settings/${serviceId}/tutorial`}
routerDirection="forward"
>
<IonLabel>Anleitung anzeigen</IonLabel>
</IonItem>
</IonList>
<div className="settings-detail-actions">
<IonButton
expand="block"
onClick={() => handleSaveService("gog")}
>
<IonIcon slot="start" icon={saveOutline} />
<IonLabel>Speichern</IonLabel>
</IonButton>
</div>
</>
)}
{serviceId === "epic" && (
<>
<IonList inset>
<IonItem>
<IonLabel position="stacked">Account E-Mail</IonLabel>
<IonInput
type="email"
placeholder="dein@email.com"
value={config.epic?.email || ""}
onIonChange={(e) =>
handleDraftChange("epic", {
email: e.detail.value || "",
})
}
/>
</IonItem>
<IonItem>
<IonLabel>Import-Methode</IonLabel>
<IonSelect
value={config.epic?.method || "manual"}
onIonChange={(e) =>
handleDraftChange("epic", {
method: e.detail.value || "manual",
})
}
>
<IonSelectOption value="manual">
Manuelle JSON-Upload
</IonSelectOption>
<IonSelectOption value="oauth">
OAuth (benötigt Backend)
</IonSelectOption>
</IonSelect>
</IonItem>
</IonList>
<IonItem lines="none" className="settings-detail-note">
<IonIcon icon={informationCircleOutline} />
<IonText>
Epic hat keine öffentliche API. Nutze manuellen Import oder
Backend OAuth.
</IonText>
</IonItem>
<IonList inset>
<IonItem
button
detail
routerLink={`/settings/${serviceId}/tutorial`}
routerDirection="forward"
>
<IonLabel>Anleitung anzeigen</IonLabel>
</IonItem>
</IonList>
<div className="settings-detail-actions">
<IonButton
expand="block"
onClick={() => handleSaveService("epic")}
>
<IonIcon slot="start" icon={saveOutline} />
<IonLabel>Speichern</IonLabel>
</IonButton>
</div>
</>
)}
{serviceId === "amazon" && (
<>
<IonList inset>
<IonItem>
<IonLabel position="stacked">Account E-Mail</IonLabel>
<IonInput
type="email"
placeholder="dein@amazon.com"
value={config.amazon?.email || ""}
onIonChange={(e) =>
handleDraftChange("amazon", {
email: e.detail.value || "",
})
}
/>
</IonItem>
<IonItem>
<IonLabel>Import-Methode</IonLabel>
<IonSelect
value={config.amazon?.method || "manual"}
onIonChange={(e) =>
handleDraftChange("amazon", {
method: e.detail.value || "manual",
})
}
>
<IonSelectOption value="manual">
Manuelle JSON-Upload
</IonSelectOption>
<IonSelectOption value="oauth">
OAuth (benötigt Backend)
</IonSelectOption>
</IonSelect>
</IonItem>
</IonList>
<IonList inset>
<IonItem
button
detail
routerLink={`/settings/${serviceId}/tutorial`}
routerDirection="forward"
>
<IonLabel>Anleitung anzeigen</IonLabel>
</IonItem>
</IonList>
<div className="settings-detail-actions">
<IonButton
expand="block"
onClick={() => handleSaveService("amazon")}
>
<IonIcon slot="start" icon={saveOutline} />
<IonLabel>Speichern</IonLabel>
</IonButton>
</div>
</>
)}
{serviceId === "blizzard" && (
<>
<IonList inset>
<IonItem>
<IonLabel position="stacked">Client ID</IonLabel>
<IonInput
type="text"
placeholder="your_client_id"
value={config.blizzard?.clientId || ""}
onIonChange={(e) =>
handleDraftChange("blizzard", {
clientId: e.detail.value || "",
})
}
/>
</IonItem>
<IonItem>
<IonLabel position="stacked">Client Secret</IonLabel>
<IonInput
type="text"
placeholder="your_client_secret"
value={config.blizzard?.clientSecret || ""}
onIonChange={(e) =>
handleDraftChange("blizzard", {
clientSecret: e.detail.value || "",
})
}
/>
</IonItem>
<IonItem>
<IonLabel>Region</IonLabel>
<IonSelect
value={config.blizzard?.region || "eu"}
onIonChange={(e) =>
handleDraftChange("blizzard", {
region: e.detail.value || "eu",
})
}
>
<IonSelectOption value="us">🇺🇸 North America</IonSelectOption>
<IonSelectOption value="eu">🇪🇺 Europe</IonSelectOption>
<IonSelectOption value="kr">🇰🇷 Korea</IonSelectOption>
<IonSelectOption value="tw">🇹🇼 Taiwan</IonSelectOption>
</IonSelect>
</IonItem>
</IonList>
<IonList inset>
<IonItem
button
detail
routerLink={`/settings/${serviceId}/tutorial`}
routerDirection="forward"
>
<IonLabel>Anleitung anzeigen</IonLabel>
</IonItem>
</IonList>
<div className="settings-detail-actions">
<IonButton
expand="block"
onClick={() => handleSaveService("blizzard")}
>
<IonIcon slot="start" icon={saveOutline} />
<IonLabel>Speichern</IonLabel>
</IonButton>
</div>
</>
)}
{serviceId === "data" && (
<>
<IonList inset>

View File

@@ -16,9 +16,6 @@ import {
cloudOutline,
cogOutline,
gameControllerOutline,
globeOutline,
shieldOutline,
storefrontOutline,
} from "ionicons/icons";
import "./SettingsPage.css";
@@ -46,34 +43,6 @@ export default function SettingsPage() {
<IonLabel>Steam</IonLabel>
<IonNote slot="end">API Key · Steam ID</IonNote>
</IonItem>
<IonItem routerLink="/settings/gog" routerDirection="forward" detail>
<IonIcon slot="start" icon={globeOutline} />
<IonLabel>GOG</IonLabel>
<IonNote slot="end">Token</IonNote>
</IonItem>
<IonItem routerLink="/settings/epic" routerDirection="forward" detail>
<IonIcon slot="start" icon={shieldOutline} />
<IonLabel>Epic Games</IonLabel>
<IonNote slot="end">Import</IonNote>
</IonItem>
<IonItem
routerLink="/settings/amazon"
routerDirection="forward"
detail
>
<IonIcon slot="start" icon={storefrontOutline} />
<IonLabel>Amazon Games</IonLabel>
<IonNote slot="end">Import</IonNote>
</IonItem>
<IonItem
routerLink="/settings/blizzard"
routerDirection="forward"
detail
>
<IonIcon slot="start" icon={cloudOutline} />
<IonLabel>Blizzard</IonLabel>
<IonNote slot="end">OAuth</IonNote>
</IonItem>
</IonList>
<IonList inset>

View File

@@ -1,58 +0,0 @@
.settings-tutorial-section {
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: #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: 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 {
padding: 24px;
text-align: center;
}

View File

@@ -1,101 +0,0 @@
import React, { useMemo } from "react";
import {
IonBackButton,
IonButtons,
IonContent,
IonHeader,
IonIcon,
IonPage,
IonText,
IonTitle,
IonToolbar,
IonCard,
IonCardContent,
IonCardHeader,
IonCardTitle,
} from "@ionic/react";
import { useParams } from "react-router-dom";
import { TUTORIALS } from "../../data/tutorials";
import "./SettingsTutorialPage.css";
interface TutorialRouteParams {
serviceId: string;
}
export default function SettingsTutorialPage() {
const { serviceId } = useParams<TutorialRouteParams>();
const tutorial = useMemo(() => TUTORIALS[serviceId], [serviceId]);
if (!tutorial) {
return (
<IonPage>
<IonHeader>
<IonToolbar>
<IonButtons slot="start">
<IonBackButton defaultHref="/settings" />
</IonButtons>
<IonTitle>Anleitung</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent fullscreen>
<div className="settings-tutorial-empty">
<IonText color="medium">Keine Anleitung verfügbar.</IonText>
</div>
</IonContent>
</IonPage>
);
}
return (
<IonPage>
<IonHeader>
<IonToolbar>
<IonButtons slot="start">
<IonBackButton defaultHref={`/settings/${serviceId}`} />
</IonButtons>
<IonTitle>
<IonIcon icon={tutorial.icon} /> {tutorial.title}
</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent fullscreen>
<div className="settings-tutorial-section">
{tutorial.steps.map((step, index) => (
<IonCard key={`${step.title}-${index}`}>
<IonCardHeader>
<IonCardTitle>{step.title}</IonCardTitle>
</IonCardHeader>
<IonCardContent>
<p>{step.description}</p>
{step.code && (
<pre className="settings-tutorial-code">{step.code}</pre>
)}
{step.hint && (
<div className="settings-tutorial-hint">{step.hint}</div>
)}
</IonCardContent>
</IonCard>
))}
</div>
<div className="settings-tutorial-section">
<IonCard>
<IonCardHeader>
<IonCardTitle>Tipps</IonCardTitle>
</IonCardHeader>
<IonCardContent>
<ul>
{tutorial.tips.map((tip) => (
<li key={tip}>{tip}</li>
))}
</ul>
</IonCardContent>
</IonCard>
</div>
</IonContent>
</IonPage>
);
}

View File

@@ -6,29 +6,8 @@ import { db } from "./Database";
export interface ServiceConfig {
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";
apiKey?: string;
steamId?: string;
lastRefresh?: string;
};
}
@@ -142,17 +121,6 @@ export class ConfigService {
if (!config.steam.steamId) errors.push("Steam: Steam ID fehlt");
}
if (config.gog) {
if (!config.gog.userId) errors.push("GOG: User ID fehlt");
if (!config.gog.accessToken) errors.push("GOG: Access Token fehlt");
}
if (config.blizzard) {
if (!config.blizzard.clientId) errors.push("Blizzard: Client ID fehlt");
if (!config.blizzard.clientSecret)
errors.push("Blizzard: Client Secret fehlt");
}
return {
valid: errors.length === 0,
errors,

View File

@@ -5,29 +5,33 @@
export interface DbConfig {
steam?: {
apiKey: string;
steamId: string;
apiKey?: string;
steamId?: string;
lastRefresh?: string;
};
gog?: {
userId: string;
accessToken: string;
userId?: string;
accessToken?: string;
refreshToken?: string;
lastRefresh?: string;
};
epic?: {
email?: string;
accessToken?: string;
method?: "oauth" | "manual";
lastRefresh?: string;
};
amazon?: {
email?: string;
manualGames?: Array<{ name: string; gameId?: string; source?: string }>;
method?: "oauth" | "manual";
lastRefresh?: string;
};
blizzard?: {
clientId: string;
clientSecret: string;
region: "us" | "eu" | "kr" | "tw";
clientId?: string;
clientSecret?: string;
accessToken?: string;
region?: "us" | "eu" | "kr" | "tw";
lastRefresh?: string;
};
}