diff --git a/src/features/settings/components/data-settings.tsx b/src/features/settings/components/data-settings.tsx new file mode 100644 index 0000000..82138e8 --- /dev/null +++ b/src/features/settings/components/data-settings.tsx @@ -0,0 +1,54 @@ +import { Button } from "@/shared/components/ui/button" +import { t } from "@/shared/i18n" +import { useRef, useState } from "react" +import { useDataManagement } from "../hooks/use-data-management" + +export function DataSettings() { + const { exportData, importData, clearAll } = useDataManagement() + const fileRef = useRef(null) + const [status, setStatus] = useState(null) + + const handleImport = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + try { + await importData(file) + setStatus("Import complete") + } catch { + setStatus("Import failed") + } + } + + const handleClear = async () => { + if (!window.confirm(t("settings.data.clearConfirm"))) return + await clearAll() + setStatus("All data cleared") + } + + return ( +
+ + +
+ + +
+ + + + {status &&

{status}

} +
+ ) +} diff --git a/src/features/settings/components/gog-settings.tsx b/src/features/settings/components/gog-settings.tsx new file mode 100644 index 0000000..9eaa005 --- /dev/null +++ b/src/features/settings/components/gog-settings.tsx @@ -0,0 +1,97 @@ +import { Button } from "@/shared/components/ui/button" +import { Input } from "@/shared/components/ui/input" +import { useConfig, useSaveConfig } from "@/shared/db/hooks" +import { t } from "@/shared/i18n" +import { useState } from "react" +import { useGogSync } from "../hooks/use-gog-sync" + +const GOG_AUTH_URL = + "https://auth.gog.com/auth?client_id=46899977096215655&redirect_uri=https%3A%2F%2Fembed.gog.com%2Fon_login_success%3Forigin%3Dclient&response_type=code&layout=client2" + +export function GogSettings() { + const gogConfig = useConfig<{ accessToken: string; refreshToken: string; userId: string }>("gog") + const saveConfig = useSaveConfig() + const lastSync = useConfig("gog_last_sync") + const { connect, syncGames, syncing, error, lastCount } = useGogSync() + + const [code, setCode] = useState("") + const isConnected = Boolean(gogConfig?.accessToken) + + const handleConnect = async () => { + const tokens = await connect(code) + if (tokens) { + setCode("") + } + } + + const handleSync = () => { + if (gogConfig) { + syncGames(gogConfig.accessToken, gogConfig.refreshToken) + } + } + + const handleDisconnect = async () => { + await saveConfig("gog", null) + } + + return ( +
+ {!isConnected ? ( + <> +
+

1. Open the GOG login page below

+

2. Log in with your GOG account

+

3. Copy the authorization code from the URL

+

4. Paste the code below

+
+ + Open GOG Login → + +
+ + setCode(e.target.value)} + placeholder="Paste authorization code" + /> +
+ + + ) : ( + <> +

Connected as user {gogConfig?.userId}

+
+ + +
+ + )} + + {error &&

{error}

} + {lastCount !== null && ( +

+ {t("settings.syncSuccess", { count: lastCount })} +

+ )} + {lastSync && ( +

+ {t("settings.lastSync")}: {new Date(lastSync).toLocaleString()} +

+ )} +
+ ) +} diff --git a/src/features/settings/components/settings-list.tsx b/src/features/settings/components/settings-list.tsx new file mode 100644 index 0000000..dac867f --- /dev/null +++ b/src/features/settings/components/settings-list.tsx @@ -0,0 +1,52 @@ +import { Badge } from "@/shared/components/ui/badge" +import { Card } from "@/shared/components/ui/card" +import { useConfig } from "@/shared/db/hooks" +import { t } from "@/shared/i18n" +import { Link } from "@tanstack/react-router" + +const providers = [ + { id: "steam", label: "Steam" }, + { id: "gog", label: "GOG" }, +] as const + +export function SettingsList() { + const steamConfig = useConfig<{ apiKey: string; steamId: string }>("steam") + const gogConfig = useConfig<{ accessToken: string }>("gog") + + const isConnected = (id: string) => { + if (id === "steam") return Boolean(steamConfig?.apiKey) + if (id === "gog") return Boolean(gogConfig?.accessToken) + return false + } + + return ( +
+
+

+ {t("settings.providers")} +

+
+ {providers.map((p) => ( + + + {p.label} + + {isConnected(p.id) ? "Connected" : "Not configured"} + + + + ))} +
+
+ +
+

{t("settings.data")}

+ + + {t("settings.data")} + + +
+
+ ) +} diff --git a/src/features/settings/components/steam-settings.tsx b/src/features/settings/components/steam-settings.tsx new file mode 100644 index 0000000..ee5160d --- /dev/null +++ b/src/features/settings/components/steam-settings.tsx @@ -0,0 +1,89 @@ +import { Button } from "@/shared/components/ui/button" +import { Input } from "@/shared/components/ui/input" +import { useConfig, useSaveConfig } from "@/shared/db/hooks" +import { t } from "@/shared/i18n" +import { useState } from "react" +import { useSteamSync } from "../hooks/use-steam-sync" + +export function SteamSettings() { + const savedConfig = useConfig<{ apiKey: string; steamId: string }>("steam") + const saveConfig = useSaveConfig() + const { sync, syncing, error, lastCount } = useSteamSync() + const lastSync = useConfig("steam_last_sync") + + const [apiKey, setApiKey] = useState("") + const [steamId, setSteamId] = useState("") + const [initialized, setInitialized] = useState(false) + + if (savedConfig && !initialized) { + setApiKey(savedConfig.apiKey || "") + setSteamId(savedConfig.steamId || "") + setInitialized(true) + } + + const extractSteamId = (input: string) => { + const match = input.match(/\/profiles\/(\d+)/) || input.match(/\/id\/([^/]+)/) + return match ? match[1] : input + } + + const handleSave = async () => { + const id = extractSteamId(steamId) + await saveConfig("steam", { apiKey, steamId: id }) + setSteamId(id) + } + + const handleSync = () => { + const id = extractSteamId(steamId) + sync({ apiKey, steamId: id }) + } + + return ( +
+
+ + setApiKey(e.target.value)} + placeholder="Your Steam Web API Key" + /> +
+ +
+ + setSteamId(e.target.value)} + placeholder="Steam ID or profile URL" + /> +
+ +
+ + +
+ + {error &&

{error}

} + {lastCount !== null && ( +

+ {t("settings.syncSuccess", { count: lastCount })} +

+ )} + {lastSync && ( +

+ {t("settings.lastSync")}: {new Date(lastSync).toLocaleString()} +

+ )} +
+ ) +} diff --git a/src/features/settings/hooks/use-data-management.ts b/src/features/settings/hooks/use-data-management.ts new file mode 100644 index 0000000..a0c53b3 --- /dev/null +++ b/src/features/settings/hooks/use-data-management.ts @@ -0,0 +1,99 @@ +import { getDb } from "@/shared/db/client" +import { useCallback } from "react" + +export function useDataManagement() { + const exportData = useCallback(async () => { + const db = await getDb() + const games = await db.query("SELECT * FROM games") + const playlists = await db.query("SELECT * FROM playlists") + const playlistGames = await db.query("SELECT * FROM playlist_games") + const config = await db.query("SELECT * FROM config") + + const data = { + version: "2026.03.01", + exportedAt: new Date().toISOString(), + games: games.rows, + playlists: playlists.rows, + playlistGames: playlistGames.rows, + config: config.rows, + } + + const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }) + const url = URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = `whattoplay-export-${new Date().toISOString().slice(0, 10)}.json` + a.click() + URL.revokeObjectURL(url) + }, []) + + const importData = useCallback(async (file: File) => { + const text = await file.text() + const data = JSON.parse(text) + const db = await getDb() + + if (data.games) { + for (const game of data.games) { + await db.query( + `INSERT INTO games (id, title, source, source_id, platform, last_played, playtime_hours, url, canonical_id, rating, game_state, is_favorite) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + ON CONFLICT (id) DO UPDATE SET + title = $2, last_played = $6, playtime_hours = $7, url = $8, canonical_id = $9, + rating = $10, game_state = $11, is_favorite = $12, updated_at = NOW()`, + [ + game.id, + game.title, + game.source, + game.source_id, + game.platform, + game.last_played, + game.playtime_hours, + game.url, + game.canonical_id, + game.rating ?? -1, + game.game_state ?? "not_set", + game.is_favorite ?? false, + ], + ) + } + } + + if (data.playlists) { + for (const pl of data.playlists) { + await db.query( + "INSERT INTO playlists (id, name, is_static) VALUES ($1, $2, $3) ON CONFLICT (id) DO NOTHING", + [pl.id, pl.name, pl.is_static], + ) + } + } + + if (data.playlistGames) { + for (const pg of data.playlistGames) { + await db.query( + "INSERT INTO playlist_games (playlist_id, game_id) VALUES ($1, $2) ON CONFLICT DO NOTHING", + [pg.playlist_id, pg.game_id], + ) + } + } + + if (data.config) { + for (const cfg of data.config) { + await db.query( + `INSERT INTO config (key, value, updated_at) VALUES ($1, $2, NOW()) + ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()`, + [cfg.key, JSON.stringify(cfg.value)], + ) + } + } + }, []) + + const clearAll = useCallback(async () => { + const db = await getDb() + await db.query("DELETE FROM playlist_games") + await db.query("DELETE FROM games") + await db.query("DELETE FROM playlists WHERE is_static = FALSE") + await db.query("DELETE FROM config") + }, []) + + return { exportData, importData, clearAll } +} diff --git a/src/features/settings/hooks/use-gog-sync.ts b/src/features/settings/hooks/use-gog-sync.ts new file mode 100644 index 0000000..2415dec --- /dev/null +++ b/src/features/settings/hooks/use-gog-sync.ts @@ -0,0 +1,81 @@ +import { useSaveConfig, useSaveGamesBySource } from "@/shared/db/hooks" +import { api } from "@/shared/lib/api" +import { useCallback, useState } from "react" + +export function useGogSync() { + const [syncing, setSyncing] = useState(false) + const [error, setError] = useState(null) + const [lastCount, setLastCount] = useState(null) + const saveConfig = useSaveConfig() + const saveGames = useSaveGamesBySource() + + const connect = useCallback( + async (code: string) => { + setSyncing(true) + setError(null) + try { + const res = await api.api.gog.auth.$post({ json: { code } }) + if (!res.ok) throw new Error(`GOG auth failed: ${res.status}`) + const tokens = await res.json() + + await saveConfig("gog", { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + userId: tokens.user_id, + }) + + return tokens + } catch (err) { + setError((err as Error).message) + return null + } finally { + setSyncing(false) + } + }, + [saveConfig], + ) + + const syncGames = useCallback( + async (accessToken: string, refreshToken: string) => { + setSyncing(true) + setError(null) + try { + const res = await api.api.gog.games.$post({ + json: { accessToken, refreshToken }, + }) + if (!res.ok) throw new Error(`GOG sync failed: ${res.status}`) + const data = await res.json() + + if (data.newAccessToken && data.newRefreshToken) { + await saveConfig("gog", { + accessToken: data.newAccessToken, + refreshToken: data.newRefreshToken, + }) + } + + const dbGames = data.games.map((g) => ({ + id: g.id, + title: g.title, + source: g.source, + source_id: g.sourceId, + platform: g.platform, + last_played: null, + playtime_hours: 0, + url: g.url ?? null, + canonical_id: null, + })) + + await saveGames("gog", dbGames) + await saveConfig("gog_last_sync", new Date().toISOString()) + setLastCount(data.count) + } catch (err) { + setError((err as Error).message) + } finally { + setSyncing(false) + } + }, + [saveConfig, saveGames], + ) + + return { connect, syncGames, syncing, error, lastCount } +} diff --git a/src/features/settings/hooks/use-steam-sync.ts b/src/features/settings/hooks/use-steam-sync.ts new file mode 100644 index 0000000..ae304b9 --- /dev/null +++ b/src/features/settings/hooks/use-steam-sync.ts @@ -0,0 +1,49 @@ +import { useSaveConfig, useSaveGamesBySource } from "@/shared/db/hooks" +import { api } from "@/shared/lib/api" +import { useCallback, useState } from "react" +import type { SteamConfig } from "../schema" + +export function useSteamSync() { + const [syncing, setSyncing] = useState(false) + const [error, setError] = useState(null) + const [lastCount, setLastCount] = useState(null) + const saveConfig = useSaveConfig() + const saveGames = useSaveGamesBySource() + + const sync = useCallback( + async (config: SteamConfig) => { + setSyncing(true) + setError(null) + try { + const res = await api.api.steam.games.$post({ + json: { apiKey: config.apiKey, steamId: config.steamId }, + }) + if (!res.ok) throw new Error(`Steam sync failed: ${res.status}`) + const data = await res.json() + + const dbGames = data.games.map((g) => ({ + id: g.id, + title: g.title, + source: g.source, + source_id: g.sourceId, + platform: g.platform, + last_played: g.lastPlayed, + playtime_hours: g.playtimeHours, + url: g.url, + canonical_id: null, + })) + + await saveGames("steam", dbGames) + await saveConfig("steam_last_sync", new Date().toISOString()) + setLastCount(data.count) + } catch (err) { + setError((err as Error).message) + } finally { + setSyncing(false) + } + }, + [saveConfig, saveGames], + ) + + return { sync, syncing, error, lastCount } +} diff --git a/src/features/settings/schema.ts b/src/features/settings/schema.ts new file mode 100644 index 0000000..59957dd --- /dev/null +++ b/src/features/settings/schema.ts @@ -0,0 +1,14 @@ +import { z } from "zod" + +export const steamConfigSchema = z.object({ + apiKey: z.string().min(1, "API key is required"), + steamId: z.string().min(1, "Steam ID is required"), +}) +export type SteamConfig = z.infer + +export const gogConfigSchema = z.object({ + accessToken: z.string(), + refreshToken: z.string(), + userId: z.string(), +}) +export type GogConfig = z.infer diff --git a/src/routes/settings/$provider.tsx b/src/routes/settings/$provider.tsx index de38413..ae260ce 100644 --- a/src/routes/settings/$provider.tsx +++ b/src/routes/settings/$provider.tsx @@ -1,5 +1,32 @@ -import { createFileRoute } from "@tanstack/react-router" +import { DataSettings } from "@/features/settings/components/data-settings" +import { GogSettings } from "@/features/settings/components/gog-settings" +import { SteamSettings } from "@/features/settings/components/steam-settings" +import { Link, createFileRoute } from "@tanstack/react-router" +import { ArrowLeft } from "lucide-react" export const Route = createFileRoute("/settings/$provider")({ - component: () =>
Provider Settings
, + component: ProviderSettingsPage, }) + +const titles: Record = { + steam: "Steam", + gog: "GOG", + data: "Data", +} + +function ProviderSettingsPage() { + const { provider } = Route.useParams() + + return ( +
+ + + Back + +

{titles[provider] ?? provider}

+ {provider === "steam" && } + {provider === "gog" && } + {provider === "data" && } +
+ ) +} diff --git a/src/routes/settings/index.tsx b/src/routes/settings/index.tsx index edb0c05..e8cc130 100644 --- a/src/routes/settings/index.tsx +++ b/src/routes/settings/index.tsx @@ -1,5 +1,16 @@ +import { SettingsList } from "@/features/settings/components/settings-list" +import { t } from "@/shared/i18n" import { createFileRoute } from "@tanstack/react-router" export const Route = createFileRoute("/settings/")({ - component: () =>
Settings
, + component: SettingsPage, }) + +function SettingsPage() { + return ( +
+

{t("settings.title")}

+ +
+ ) +}