overhaul settings UI, move sync logic to sync store

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-02 21:23:15 +01:00
parent db1f66ced2
commit 05d05ed05e
7 changed files with 244 additions and 262 deletions

View File

@@ -1,4 +1,13 @@
import { Button } from "@/shared/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import { ListItem } from "@/shared/components/ui/list-item"
import { t } from "@/shared/i18n"
import { useRef, useState } from "react"
import { useDataManagement } from "../hooks/use-data-management"
@@ -7,6 +16,7 @@ export function DataSettings() {
const { exportData, importData, clearAll } = useDataManagement()
const fileRef = useRef<HTMLInputElement>(null)
const [status, setStatus] = useState<string | null>(null)
const [confirmOpen, setConfirmOpen] = useState(false)
const handleImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
@@ -20,35 +30,72 @@ export function DataSettings() {
}
const handleClear = async () => {
if (!window.confirm(t("settings.data.clearConfirm"))) return
setConfirmOpen(false)
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"
<>
<div className="divide-y rounded-lg border bg-card">
<ListItem
title={t("settings.data.export")}
after={
<Button size="sm" variant="outline" onClick={exportData}>
{t("settings.data.export")}
</Button>
}
/>
<ListItem
title={t("settings.data.import")}
after={
<Button size="sm" variant="outline" onClick={() => fileRef.current?.click()}>
{t("settings.data.import")}
</Button>
}
/>
<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>
<input ref={fileRef} type="file" accept=".json" onChange={handleImport} className="hidden" />
{status && <p className="text-sm text-muted-foreground">{status}</p>}
</div>
<div className="mt-4 divide-y rounded-lg border bg-card">
<ListItem
title={t("settings.data.clear")}
after={
<Button
size="sm"
variant="ghost"
className="text-red-500"
onClick={() => setConfirmOpen(true)}
>
{t("settings.data.clear")}
</Button>
}
/>
</div>
{status && (
<div className="mt-4">
<p className="text-sm text-muted-foreground">{status}</p>
</div>
)}
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogContent showCloseButton={false}>
<DialogHeader>
<DialogTitle>{t("settings.data.clear")}</DialogTitle>
<DialogDescription>{t("settings.data.clearConfirm")}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setConfirmOpen(false)}>
{t("general.cancel")}
</Button>
<Button variant="destructive" onClick={handleClear}>
{t("general.confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -1,9 +1,10 @@
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { ListItem } from "@/shared/components/ui/list-item"
import { useConfig, useSaveConfig } from "@/shared/db/hooks"
import { t } from "@/shared/i18n"
import { useSyncStore } from "@/shared/stores/sync-store"
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"
@@ -12,13 +13,15 @@ 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 { syncing, error, lastCount } = useSyncStore((s) => s.gog)
const connectGog = useSyncStore((s) => s.connectGog)
const syncGogGames = useSyncStore((s) => s.syncGogGames)
const [code, setCode] = useState("")
const isConnected = Boolean(gogConfig?.accessToken)
const handleConnect = async () => {
const tokens = await connect(code)
const tokens = await connectGog(code)
if (tokens) {
setCode("")
}
@@ -26,7 +29,7 @@ export function GogSettings() {
const handleSync = () => {
if (gogConfig) {
syncGames(gogConfig.accessToken, gogConfig.refreshToken)
syncGogGames(gogConfig.accessToken, gogConfig.refreshToken)
}
}
@@ -35,63 +38,79 @@ export function GogSettings() {
}
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 className="space-y-2">
<ol className="list-inside list-decimal space-y-1 text-sm text-muted-foreground">
<li>Open the GOG login page below</li>
<li>Log in with your GOG account</li>
<li>Copy the authorization code from the URL</li>
<li>Paste the code below</li>
</ol>
<a
href={GOG_AUTH_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-block text-sm text-blue-500 underline"
>
Open GOG Login
</a>
</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")}
<div className="mt-4">
{/* biome-ignore lint/a11y/noLabelWithoutControl: Input is inside the label */}
<label className="space-y-1">
<span className="text-sm font-medium">{t("settings.gog.code")}</span>
<Input
type="text"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="Paste authorization 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>
<div className="mt-4">
<Button onClick={handleConnect} disabled={syncing || !code}>
{syncing ? t("settings.syncing") : t("settings.gog.connect")}
</Button>
</div>
</>
) : (
<>
<p className="text-sm text-muted-foreground">Connected as user {gogConfig?.userId}</p>
<div className="flex gap-2">
<div className="divide-y rounded-lg border bg-card">
<ListItem title="Account" after={gogConfig?.userId} />
</div>
{lastSync && (
<div className="mt-4 divide-y rounded-lg border bg-card">
<ListItem
title={t("settings.lastSync")}
after={new Date(lastSync).toLocaleString()}
/>
</div>
)}
<div className="mt-4 flex gap-2">
<Button onClick={handleSync} disabled={syncing}>
{syncing ? t("settings.syncing") : t("settings.steam.sync")}
</Button>
<Button onClick={handleDisconnect} variant="destructive">
<Button variant="ghost" className="text-red-500" onClick={handleDisconnect}>
{t("settings.gog.disconnect")}
</Button>
</div>
</>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
{error && (
<div className="mt-4">
<p className="text-sm text-red-500">{error}</p>
</div>
)}
{lastCount !== null && (
<p className="text-sm text-muted-foreground">
{t("settings.syncSuccess", { count: lastCount })}
</p>
<div className="mt-4">
<p className="text-sm text-muted-foreground">
{t("settings.syncSuccess", { count: lastCount })}
</p>
</div>
)}
{lastSync && (
<p className="text-xs text-muted-foreground">
{t("settings.lastSync")}: {new Date(lastSync).toLocaleString()}
</p>
)}
</div>
</>
)
}

View File

@@ -1,14 +1,49 @@
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { useConfig, useSaveConfig } from "@/shared/db/hooks"
import { ListItem } from "@/shared/components/ui/list-item"
import { useConfig } from "@/shared/db/hooks"
import { t } from "@/shared/i18n"
import { useSyncStore } from "@/shared/stores/sync-store"
import { Loader2 } from "lucide-react"
import { useState } from "react"
import { useSteamSync } from "../hooks/use-steam-sync"
function SyncProgress({ progress }: { progress: string | null }) {
if (!progress) return null
if (progress === "fetching") {
return (
<div className="mt-4 flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t("settings.syncFetching")}
</div>
)
}
if (progress.startsWith("saving:")) {
const [, current, total] = progress.split(":")
return (
<div className="mt-4 space-y-1.5">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t("settings.syncSaving", { current, total })}
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${(Number(current) / Number(total)) * 100}%` }}
/>
</div>
</div>
)
}
return null
}
export function SteamSettings() {
const savedConfig = useConfig<{ apiKey: string; steamId: string }>("steam")
const saveConfig = useSaveConfig()
const { sync, syncing, error, lastCount } = useSteamSync()
const { syncing, error, lastCount, progress } = useSyncStore((s) => s.steam)
const syncSteam = useSyncStore((s) => s.syncSteam)
const lastSync = useConfig<string>("steam_last_sync")
const [apiKey, setApiKey] = useState("")
@@ -21,69 +56,73 @@ export function SteamSettings() {
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 })
syncSteam({ apiKey, steamId: steamId.trim() })
}
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 className="space-y-2">
<p className="text-sm text-muted-foreground">{t("settings.steam.instructions")}</p>
<a
href="https://steamcommunity.com/dev/apikey"
target="_blank"
rel="noopener noreferrer"
className="inline-block text-sm text-blue-500 underline"
>
steamcommunity.com/dev/apikey
</a>
</div>
<div>
<label htmlFor="steam-id" className="mb-1 block text-sm font-medium">
{t("settings.steam.steamId")}
<div className="mt-4 space-y-3">
{/* biome-ignore lint/a11y/noLabelWithoutControl: Input is inside the label */}
<label className="space-y-1">
<span className="text-sm font-medium">{t("settings.steam.steamId")}</span>
<Input
type="text"
value={steamId}
onChange={(e) => setSteamId(e.target.value)}
placeholder="Steam ID or profile URL"
/>
</label>
{/* biome-ignore lint/a11y/noLabelWithoutControl: Input is inside the label */}
<label className="space-y-1">
<span className="text-sm font-medium">{t("settings.steam.apiKey")}</span>
<Input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="Your Steam Web API Key"
/>
</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>
<div className="mt-4">
<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>
<SyncProgress progress={progress} />
{error && (
<div className="mt-4">
<p className="text-sm text-red-500">{error}</p>
</div>
)}
{!syncing && lastCount !== null && (
<div className="mt-4">
<p className="text-sm text-muted-foreground">
{t("settings.syncSuccess", { count: lastCount })}
</p>
</div>
)}
{lastSync && (
<p className="text-xs text-muted-foreground">
{t("settings.lastSync")}: {new Date(lastSync).toLocaleString()}
</p>
<div className="mt-4 divide-y rounded-lg border bg-card">
<ListItem title={t("settings.lastSync")} after={new Date(lastSync).toLocaleString()} />
</div>
)}
</div>
</>
)
}

View File

@@ -1,81 +0,0 @@
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

@@ -1,49 +0,0 @@
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

@@ -1,12 +1,9 @@
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: ProviderSettingsPage,
})
import { Button } from "@/shared/components/ui/button"
import { createFileRoute, useNavigate } from "@tanstack/react-router"
import { ChevronLeft } from "lucide-react"
const titles: Record<string, string> = {
steam: "Steam",
@@ -14,19 +11,27 @@ const titles: Record<string, string> = {
data: "Data",
}
export const Route = createFileRoute("/settings/$provider")({
component: ProviderSettingsPage,
})
function ProviderSettingsPage() {
const { provider } = Route.useParams()
const navigate = useNavigate()
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>
<header className="flex items-center gap-2 px-2 pt-4 pb-2">
<Button variant="ghost" size="icon-sm" onClick={() => navigate({ to: "/settings" })}>
<ChevronLeft className="h-5 w-5" />
</Button>
<h1 className="text-xl font-bold">{titles[provider] ?? provider}</h1>
</header>
<div className="mx-auto max-w-lg p-4">
{provider === "steam" && <SteamSettings />}
{provider === "gog" && <GogSettings />}
{provider === "data" && <DataSettings />}
</div>
</div>
)
}

View File

@@ -8,8 +8,10 @@ export const Route = createFileRoute("/settings/")({
function SettingsPage() {
return (
<div className="mx-auto max-w-lg p-4">
<h1 className="mb-6 text-2xl font-bold">{t("settings.title")}</h1>
<div>
<header className="px-4 pt-4 pb-2">
<h1 className="text-2xl font-bold">{t("settings.title")}</h1>
</header>
<SettingsList />
</div>
)