overhaul settings UI, move sync logic to sync store
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,13 @@
|
|||||||
import { Button } from "@/shared/components/ui/button"
|
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 { t } from "@/shared/i18n"
|
||||||
import { useRef, useState } from "react"
|
import { useRef, useState } from "react"
|
||||||
import { useDataManagement } from "../hooks/use-data-management"
|
import { useDataManagement } from "../hooks/use-data-management"
|
||||||
@@ -7,6 +16,7 @@ export function DataSettings() {
|
|||||||
const { exportData, importData, clearAll } = useDataManagement()
|
const { exportData, importData, clearAll } = useDataManagement()
|
||||||
const fileRef = useRef<HTMLInputElement>(null)
|
const fileRef = useRef<HTMLInputElement>(null)
|
||||||
const [status, setStatus] = useState<string | null>(null)
|
const [status, setStatus] = useState<string | null>(null)
|
||||||
|
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||||
|
|
||||||
const handleImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0]
|
const file = e.target.files?.[0]
|
||||||
@@ -20,35 +30,72 @@ export function DataSettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleClear = async () => {
|
const handleClear = async () => {
|
||||||
if (!window.confirm(t("settings.data.clearConfirm"))) return
|
setConfirmOpen(false)
|
||||||
await clearAll()
|
await clearAll()
|
||||||
setStatus("All data cleared")
|
setStatus("All data cleared")
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<>
|
||||||
<Button onClick={exportData} variant="secondary" className="w-full">
|
<div className="divide-y rounded-lg border bg-card">
|
||||||
{t("settings.data.export")}
|
<ListItem
|
||||||
</Button>
|
title={t("settings.data.export")}
|
||||||
|
after={
|
||||||
<div>
|
<Button size="sm" variant="outline" onClick={exportData}>
|
||||||
<input
|
{t("settings.data.export")}
|
||||||
ref={fileRef}
|
</Button>
|
||||||
type="file"
|
}
|
||||||
accept=".json"
|
/>
|
||||||
onChange={handleImport}
|
<ListItem
|
||||||
className="hidden"
|
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>
|
</div>
|
||||||
|
|
||||||
<Button onClick={handleClear} variant="destructive" className="w-full">
|
<input ref={fileRef} type="file" accept=".json" onChange={handleImport} className="hidden" />
|
||||||
{t("settings.data.clear")}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{status && <p className="text-sm text-muted-foreground">{status}</p>}
|
<div className="mt-4 divide-y rounded-lg border bg-card">
|
||||||
</div>
|
<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>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { Input } from "@/shared/components/ui/input"
|
import { Input } from "@/shared/components/ui/input"
|
||||||
|
import { ListItem } from "@/shared/components/ui/list-item"
|
||||||
import { useConfig, useSaveConfig } from "@/shared/db/hooks"
|
import { useConfig, useSaveConfig } from "@/shared/db/hooks"
|
||||||
import { t } from "@/shared/i18n"
|
import { t } from "@/shared/i18n"
|
||||||
|
import { useSyncStore } from "@/shared/stores/sync-store"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { useGogSync } from "../hooks/use-gog-sync"
|
|
||||||
|
|
||||||
const GOG_AUTH_URL =
|
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"
|
"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 gogConfig = useConfig<{ accessToken: string; refreshToken: string; userId: string }>("gog")
|
||||||
const saveConfig = useSaveConfig()
|
const saveConfig = useSaveConfig()
|
||||||
const lastSync = useConfig<string>("gog_last_sync")
|
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 [code, setCode] = useState("")
|
||||||
const isConnected = Boolean(gogConfig?.accessToken)
|
const isConnected = Boolean(gogConfig?.accessToken)
|
||||||
|
|
||||||
const handleConnect = async () => {
|
const handleConnect = async () => {
|
||||||
const tokens = await connect(code)
|
const tokens = await connectGog(code)
|
||||||
if (tokens) {
|
if (tokens) {
|
||||||
setCode("")
|
setCode("")
|
||||||
}
|
}
|
||||||
@@ -26,7 +29,7 @@ export function GogSettings() {
|
|||||||
|
|
||||||
const handleSync = () => {
|
const handleSync = () => {
|
||||||
if (gogConfig) {
|
if (gogConfig) {
|
||||||
syncGames(gogConfig.accessToken, gogConfig.refreshToken)
|
syncGogGames(gogConfig.accessToken, gogConfig.refreshToken)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,63 +38,79 @@ export function GogSettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<>
|
||||||
{!isConnected ? (
|
{!isConnected ? (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-2 text-sm text-muted-foreground">
|
<div className="space-y-2">
|
||||||
<p>1. Open the GOG login page below</p>
|
<ol className="list-inside list-decimal space-y-1 text-sm text-muted-foreground">
|
||||||
<p>2. Log in with your GOG account</p>
|
<li>Open the GOG login page below</li>
|
||||||
<p>3. Copy the authorization code from the URL</p>
|
<li>Log in with your GOG account</li>
|
||||||
<p>4. Paste the code below</p>
|
<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>
|
</div>
|
||||||
<a
|
<div className="mt-4">
|
||||||
href={GOG_AUTH_URL}
|
{/* biome-ignore lint/a11y/noLabelWithoutControl: Input is inside the label */}
|
||||||
target="_blank"
|
<label className="space-y-1">
|
||||||
rel="noopener noreferrer"
|
<span className="text-sm font-medium">{t("settings.gog.code")}</span>
|
||||||
className="inline-block text-sm text-primary underline"
|
<Input
|
||||||
>
|
type="text"
|
||||||
Open GOG Login →
|
value={code}
|
||||||
</a>
|
onChange={(e) => setCode(e.target.value)}
|
||||||
<div>
|
placeholder="Paste authorization code"
|
||||||
<label htmlFor="gog-code" className="mb-1 block text-sm font-medium">
|
/>
|
||||||
{t("settings.gog.code")}
|
|
||||||
</label>
|
</label>
|
||||||
<Input
|
|
||||||
id="gog-code"
|
|
||||||
value={code}
|
|
||||||
onChange={(e) => setCode(e.target.value)}
|
|
||||||
placeholder="Paste authorization code"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={handleConnect} disabled={syncing || !code}>
|
<div className="mt-4">
|
||||||
{syncing ? t("settings.syncing") : t("settings.gog.connect")}
|
<Button onClick={handleConnect} disabled={syncing || !code}>
|
||||||
</Button>
|
{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="divide-y rounded-lg border bg-card">
|
||||||
<div className="flex gap-2">
|
<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}>
|
<Button onClick={handleSync} disabled={syncing}>
|
||||||
{syncing ? t("settings.syncing") : t("settings.steam.sync")}
|
{syncing ? t("settings.syncing") : t("settings.steam.sync")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleDisconnect} variant="destructive">
|
<Button variant="ghost" className="text-red-500" onClick={handleDisconnect}>
|
||||||
{t("settings.gog.disconnect")}
|
{t("settings.gog.disconnect")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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 && (
|
{lastCount !== null && (
|
||||||
<p className="text-sm text-muted-foreground">
|
<div className="mt-4">
|
||||||
{t("settings.syncSuccess", { count: lastCount })}
|
<p className="text-sm text-muted-foreground">
|
||||||
</p>
|
{t("settings.syncSuccess", { count: lastCount })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{lastSync && (
|
</>
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t("settings.lastSync")}: {new Date(lastSync).toLocaleString()}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,49 @@
|
|||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { Input } from "@/shared/components/ui/input"
|
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 { t } from "@/shared/i18n"
|
||||||
|
import { useSyncStore } from "@/shared/stores/sync-store"
|
||||||
|
import { Loader2 } from "lucide-react"
|
||||||
import { useState } from "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() {
|
export function SteamSettings() {
|
||||||
const savedConfig = useConfig<{ apiKey: string; steamId: string }>("steam")
|
const savedConfig = useConfig<{ apiKey: string; steamId: string }>("steam")
|
||||||
const saveConfig = useSaveConfig()
|
const { syncing, error, lastCount, progress } = useSyncStore((s) => s.steam)
|
||||||
const { sync, syncing, error, lastCount } = useSteamSync()
|
const syncSteam = useSyncStore((s) => s.syncSteam)
|
||||||
const lastSync = useConfig<string>("steam_last_sync")
|
const lastSync = useConfig<string>("steam_last_sync")
|
||||||
|
|
||||||
const [apiKey, setApiKey] = useState("")
|
const [apiKey, setApiKey] = useState("")
|
||||||
@@ -21,69 +56,73 @@ export function SteamSettings() {
|
|||||||
setInitialized(true)
|
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 handleSync = () => {
|
||||||
const id = extractSteamId(steamId)
|
syncSteam({ apiKey, steamId: steamId.trim() })
|
||||||
sync({ apiKey, steamId: id })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<>
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<label htmlFor="steam-api-key" className="mb-1 block text-sm font-medium">
|
<p className="text-sm text-muted-foreground">{t("settings.steam.instructions")}</p>
|
||||||
{t("settings.steam.apiKey")}
|
<a
|
||||||
</label>
|
href="https://steamcommunity.com/dev/apikey"
|
||||||
<Input
|
target="_blank"
|
||||||
id="steam-api-key"
|
rel="noopener noreferrer"
|
||||||
type="password"
|
className="inline-block text-sm text-blue-500 underline"
|
||||||
value={apiKey}
|
>
|
||||||
onChange={(e) => setApiKey(e.target.value)}
|
steamcommunity.com/dev/apikey →
|
||||||
placeholder="Your Steam Web API Key"
|
</a>
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="mt-4 space-y-3">
|
||||||
<label htmlFor="steam-id" className="mb-1 block text-sm font-medium">
|
{/* biome-ignore lint/a11y/noLabelWithoutControl: Input is inside the label */}
|
||||||
{t("settings.steam.steamId")}
|
<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>
|
</label>
|
||||||
<Input
|
|
||||||
id="steam-id"
|
|
||||||
value={steamId}
|
|
||||||
onChange={(e) => setSteamId(e.target.value)}
|
|
||||||
placeholder="Steam ID or profile URL"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="mt-4">
|
||||||
<Button onClick={handleSave} variant="secondary">
|
|
||||||
{t("general.save")}
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleSync} disabled={syncing || !apiKey || !steamId}>
|
<Button onClick={handleSync} disabled={syncing || !apiKey || !steamId}>
|
||||||
{syncing ? t("settings.syncing") : t("settings.steam.sync")}
|
{syncing ? t("settings.syncing") : t("settings.steam.sync")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
<SyncProgress progress={progress} />
|
||||||
{lastCount !== null && (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
{error && (
|
||||||
{t("settings.syncSuccess", { count: lastCount })}
|
<div className="mt-4">
|
||||||
</p>
|
<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 && (
|
{lastSync && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<div className="mt-4 divide-y rounded-lg border bg-card">
|
||||||
{t("settings.lastSync")}: {new Date(lastSync).toLocaleString()}
|
<ListItem title={t("settings.lastSync")} after={new Date(lastSync).toLocaleString()} />
|
||||||
</p>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
}
|
|
||||||
@@ -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 }
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,9 @@
|
|||||||
import { DataSettings } from "@/features/settings/components/data-settings"
|
import { DataSettings } from "@/features/settings/components/data-settings"
|
||||||
import { GogSettings } from "@/features/settings/components/gog-settings"
|
import { GogSettings } from "@/features/settings/components/gog-settings"
|
||||||
import { SteamSettings } from "@/features/settings/components/steam-settings"
|
import { SteamSettings } from "@/features/settings/components/steam-settings"
|
||||||
import { Link, createFileRoute } from "@tanstack/react-router"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { ArrowLeft } from "lucide-react"
|
import { createFileRoute, useNavigate } from "@tanstack/react-router"
|
||||||
|
import { ChevronLeft } from "lucide-react"
|
||||||
export const Route = createFileRoute("/settings/$provider")({
|
|
||||||
component: ProviderSettingsPage,
|
|
||||||
})
|
|
||||||
|
|
||||||
const titles: Record<string, string> = {
|
const titles: Record<string, string> = {
|
||||||
steam: "Steam",
|
steam: "Steam",
|
||||||
@@ -14,19 +11,27 @@ const titles: Record<string, string> = {
|
|||||||
data: "Data",
|
data: "Data",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/settings/$provider")({
|
||||||
|
component: ProviderSettingsPage,
|
||||||
|
})
|
||||||
|
|
||||||
function ProviderSettingsPage() {
|
function ProviderSettingsPage() {
|
||||||
const { provider } = Route.useParams()
|
const { provider } = Route.useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-lg p-4">
|
<div>
|
||||||
<Link to="/settings" className="mb-4 flex items-center gap-1 text-sm text-muted-foreground">
|
<header className="flex items-center gap-2 px-2 pt-4 pb-2">
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<Button variant="ghost" size="icon-sm" onClick={() => navigate({ to: "/settings" })}>
|
||||||
Back
|
<ChevronLeft className="h-5 w-5" />
|
||||||
</Link>
|
</Button>
|
||||||
<h1 className="mb-6 text-2xl font-bold">{titles[provider] ?? provider}</h1>
|
<h1 className="text-xl font-bold">{titles[provider] ?? provider}</h1>
|
||||||
{provider === "steam" && <SteamSettings />}
|
</header>
|
||||||
{provider === "gog" && <GogSettings />}
|
<div className="mx-auto max-w-lg p-4">
|
||||||
{provider === "data" && <DataSettings />}
|
{provider === "steam" && <SteamSettings />}
|
||||||
|
{provider === "gog" && <GogSettings />}
|
||||||
|
{provider === "data" && <DataSettings />}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ export const Route = createFileRoute("/settings/")({
|
|||||||
|
|
||||||
function SettingsPage() {
|
function SettingsPage() {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-lg p-4">
|
<div>
|
||||||
<h1 className="mb-6 text-2xl font-bold">{t("settings.title")}</h1>
|
<header className="px-4 pt-4 pb-2">
|
||||||
|
<h1 className="text-2xl font-bold">{t("settings.title")}</h1>
|
||||||
|
</header>
|
||||||
<SettingsList />
|
<SettingsList />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user