add settings feature: steam, gog providers, data management

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 13:57:49 +01:00
parent 1d444e6e4e
commit d907f26683
10 changed files with 576 additions and 3 deletions

View File

@@ -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<HTMLInputElement>(null)
const [status, setStatus] = useState<string | null>(null)
const handleImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<div className="space-y-4">
<Button onClick={exportData} variant="secondary" className="w-full">
{t("settings.data.export")}
</Button>
<div>
<input
ref={fileRef}
type="file"
accept=".json"
onChange={handleImport}
className="hidden"
/>
<Button onClick={() => fileRef.current?.click()} variant="secondary" className="w-full">
{t("settings.data.import")}
</Button>
</div>
<Button onClick={handleClear} variant="destructive" className="w-full">
{t("settings.data.clear")}
</Button>
{status && <p className="text-sm text-muted-foreground">{status}</p>}
</div>
)
}

View File

@@ -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<string>("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 (
<div className="space-y-4">
{!isConnected ? (
<>
<div className="space-y-2 text-sm text-muted-foreground">
<p>1. Open the GOG login page below</p>
<p>2. Log in with your GOG account</p>
<p>3. Copy the authorization code from the URL</p>
<p>4. Paste the code below</p>
</div>
<a
href={GOG_AUTH_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-block text-sm text-primary underline"
>
Open GOG Login
</a>
<div>
<label htmlFor="gog-code" className="mb-1 block text-sm font-medium">
{t("settings.gog.code")}
</label>
<Input
id="gog-code"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="Paste authorization code"
/>
</div>
<Button onClick={handleConnect} disabled={syncing || !code}>
{syncing ? t("settings.syncing") : t("settings.gog.connect")}
</Button>
</>
) : (
<>
<p className="text-sm text-muted-foreground">Connected as user {gogConfig?.userId}</p>
<div className="flex gap-2">
<Button onClick={handleSync} disabled={syncing}>
{syncing ? t("settings.syncing") : t("settings.steam.sync")}
</Button>
<Button onClick={handleDisconnect} variant="destructive">
{t("settings.gog.disconnect")}
</Button>
</div>
</>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
{lastCount !== null && (
<p className="text-sm text-muted-foreground">
{t("settings.syncSuccess", { count: lastCount })}
</p>
)}
{lastSync && (
<p className="text-xs text-muted-foreground">
{t("settings.lastSync")}: {new Date(lastSync).toLocaleString()}
</p>
)}
</div>
)
}

View File

@@ -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 (
<div className="space-y-6">
<section>
<h2 className="mb-3 text-sm font-medium text-muted-foreground">
{t("settings.providers")}
</h2>
<div className="space-y-2">
{providers.map((p) => (
<Link key={p.id} to="/settings/$provider" params={{ provider: p.id }}>
<Card className="flex items-center justify-between p-4">
<span className="font-medium">{p.label}</span>
<Badge variant={isConnected(p.id) ? "default" : "secondary"}>
{isConnected(p.id) ? "Connected" : "Not configured"}
</Badge>
</Card>
</Link>
))}
</div>
</section>
<section>
<h2 className="mb-3 text-sm font-medium text-muted-foreground">{t("settings.data")}</h2>
<Link to="/settings/$provider" params={{ provider: "data" }}>
<Card className="p-4">
<span className="font-medium">{t("settings.data")}</span>
</Card>
</Link>
</section>
</div>
)
}

View File

@@ -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<string>("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 (
<div className="space-y-4">
<div>
<label htmlFor="steam-api-key" className="mb-1 block text-sm font-medium">
{t("settings.steam.apiKey")}
</label>
<Input
id="steam-api-key"
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="Your Steam Web API Key"
/>
</div>
<div>
<label htmlFor="steam-id" className="mb-1 block text-sm font-medium">
{t("settings.steam.steamId")}
</label>
<Input
id="steam-id"
value={steamId}
onChange={(e) => setSteamId(e.target.value)}
placeholder="Steam ID or profile URL"
/>
</div>
<div className="flex gap-2">
<Button onClick={handleSave} variant="secondary">
{t("general.save")}
</Button>
<Button onClick={handleSync} disabled={syncing || !apiKey || !steamId}>
{syncing ? t("settings.syncing") : t("settings.steam.sync")}
</Button>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
{lastCount !== null && (
<p className="text-sm text-muted-foreground">
{t("settings.syncSuccess", { count: lastCount })}
</p>
)}
{lastSync && (
<p className="text-xs text-muted-foreground">
{t("settings.lastSync")}: {new Date(lastSync).toLocaleString()}
</p>
)}
</div>
)
}

View File

@@ -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 }
}

View File

@@ -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<string | null>(null)
const [lastCount, setLastCount] = useState<number | null>(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 }
}

View File

@@ -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<string | null>(null)
const [lastCount, setLastCount] = useState<number | null>(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 }
}

View File

@@ -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<typeof steamConfigSchema>
export const gogConfigSchema = z.object({
accessToken: z.string(),
refreshToken: z.string(),
userId: z.string(),
})
export type GogConfig = z.infer<typeof gogConfigSchema>

View File

@@ -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: () => <div>Provider Settings</div>,
component: ProviderSettingsPage,
})
const titles: Record<string, string> = {
steam: "Steam",
gog: "GOG",
data: "Data",
}
function ProviderSettingsPage() {
const { provider } = Route.useParams()
return (
<div className="mx-auto max-w-lg p-4">
<Link to="/settings" className="mb-4 flex items-center gap-1 text-sm text-muted-foreground">
<ArrowLeft className="h-4 w-4" />
Back
</Link>
<h1 className="mb-6 text-2xl font-bold">{titles[provider] ?? provider}</h1>
{provider === "steam" && <SteamSettings />}
{provider === "gog" && <GogSettings />}
{provider === "data" && <DataSettings />}
</div>
)
}

View File

@@ -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: () => <div>Settings</div>,
component: SettingsPage,
})
function SettingsPage() {
return (
<div className="mx-auto max-w-lg p-4">
<h1 className="mb-6 text-2xl font-bold">{t("settings.title")}</h1>
<SettingsList />
</div>
)
}