From db1f66ced2c42371f93804708869cfa821bb800c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20F=C3=B6rtsch?= Date: Mon, 2 Mar 2026 21:21:15 +0100 Subject: [PATCH] add list-item component, sync store, refine layout, styles Co-Authored-By: Claude Opus 4.6 --- src/app.css | 157 +++++++++++-------- src/main.tsx | 5 +- src/routes/__root.tsx | 54 +++---- src/shared/components/ui/list-item.tsx | 45 ++++++ src/shared/stores/sync-store.ts | 205 +++++++++++++++++++++++++ 5 files changed, 377 insertions(+), 89 deletions(-) create mode 100644 src/shared/components/ui/list-item.tsx create mode 100644 src/shared/stores/sync-store.ts diff --git a/src/app.css b/src/app.css index 12c40b8..fe52382 100644 --- a/src/app.css +++ b/src/app.css @@ -1,72 +1,105 @@ @import "tailwindcss"; -@custom-variant dark (&:is(.dark *)); - -@theme { - --color-background: hsl(0 0% 100%); - --color-foreground: hsl(222.2 84% 4.9%); - --color-card: hsl(0 0% 100%); - --color-card-foreground: hsl(222.2 84% 4.9%); - --color-popover: hsl(0 0% 100%); - --color-popover-foreground: hsl(222.2 84% 4.9%); - --color-primary: hsl(222.2 47.4% 11.2%); - --color-primary-foreground: hsl(210 40% 98%); - --color-secondary: hsl(210 40% 96.1%); - --color-secondary-foreground: hsl(222.2 47.4% 11.2%); - --color-muted: hsl(210 40% 96.1%); - --color-muted-foreground: hsl(215.4 16.3% 46.9%); - --color-accent: hsl(210 40% 96.1%); - --color-accent-foreground: hsl(222.2 47.4% 11.2%); - --color-destructive: hsl(0 84.2% 60.2%); - --color-destructive-foreground: hsl(210 40% 98%); - --color-border: hsl(214.3 31.8% 91.4%); - --color-input: hsl(214.3 31.8% 91.4%); - --color-ring: hsl(222.2 84% 4.9%); - --radius: 0.5rem; - - --color-sidebar: hsl(0 0% 98%); - --color-sidebar-foreground: hsl(240 5.3% 26.1%); - --color-sidebar-primary: hsl(240 5.9% 10%); - --color-sidebar-primary-foreground: hsl(0 0% 98%); - --color-sidebar-accent: hsl(240 4.8% 95.9%); - --color-sidebar-accent-foreground: hsl(240 5.9% 10%); - --color-sidebar-border: hsl(220 13% 91%); - --color-sidebar-ring: hsl(217.2 91.2% 59.8%); +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --animate-in: enter; + --animate-out: exit; } -@layer base { - *, - ::after, - ::before, - ::backdrop, - ::file-selector-button { - border-color: var(--color-border); +@keyframes enter { + from { + opacity: var(--tw-enter-opacity, 1); + transform: translate3d( + var(--tw-enter-translate-x, 0), + var(--tw-enter-translate-y, 0), + 0 + ) + scale3d( + var(--tw-enter-scale, 1), + var(--tw-enter-scale, 1), + var(--tw-enter-scale, 1) + ) + rotate(var(--tw-enter-rotate, 0)); } - body { - background-color: var(--color-background); - color: var(--color-foreground); - font-family: system-ui, -apple-system, sans-serif; +} + +@keyframes exit { + to { + opacity: var(--tw-exit-opacity, 1); + transform: translate3d( + var(--tw-exit-translate-x, 0), + var(--tw-exit-translate-y, 0), + 0 + ) + scale3d( + var(--tw-exit-scale, 1), + var(--tw-exit-scale, 1), + var(--tw-exit-scale, 1) + ) + rotate(var(--tw-exit-rotate, 0)); } } +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.965 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.965 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.965 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); +} + .dark { - --color-background: hsl(222.2 84% 4.9%); - --color-foreground: hsl(210 40% 98%); - --color-card: hsl(222.2 84% 4.9%); - --color-card-foreground: hsl(210 40% 98%); - --color-popover: hsl(222.2 84% 4.9%); - --color-popover-foreground: hsl(210 40% 98%); - --color-primary: hsl(210 40% 98%); - --color-primary-foreground: hsl(222.2 47.4% 11.2%); - --color-secondary: hsl(217.2 32.6% 17.5%); - --color-secondary-foreground: hsl(210 40% 98%); - --color-muted: hsl(217.2 32.6% 17.5%); - --color-muted-foreground: hsl(215 20.2% 65.1%); - --color-accent: hsl(217.2 32.6% 17.5%); - --color-accent-foreground: hsl(210 40% 98%); - --color-destructive: hsl(0 62.8% 30.6%); - --color-destructive-foreground: hsl(210 40% 98%); - --color-border: hsl(217.2 32.6% 17.5%); - --color-input: hsl(217.2 32.6% 17.5%); - --color-ring: hsl(212.7 26.8% 83.9%); + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.985 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.3 0 0); + --input: oklch(0.3 0 0); + --ring: oklch(0.556 0 0); } diff --git a/src/main.tsx b/src/main.tsx index e8e5b5e..d6a2f5d 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4,7 +4,10 @@ import { createRoot } from "react-dom/client" import { routeTree } from "./routeTree.gen" import "./app.css" -const router = createRouter({ routeTree }) +const router = createRouter({ + routeTree, + basepath: "/whattoplay", +}) declare module "@tanstack/react-router" { interface Register { diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index ddfeca0..ff4f44b 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -1,42 +1,44 @@ import { t } from "@/shared/i18n" -import { Link, Outlet, createRootRoute } from "@tanstack/react-router" +import { Outlet, createRootRoute, useNavigate, useRouterState } from "@tanstack/react-router" import { Gamepad2, Library, ListMusic, Settings } from "lucide-react" export const Route = createRootRoute({ component: RootLayout, }) +const tabs = [ + { to: "/library", icon: Library, label: () => t("nav.library") }, + { to: "/discover", icon: Gamepad2, label: () => t("nav.discover") }, + { to: "/playlists", icon: ListMusic, label: () => t("nav.playlists") }, + { to: "/settings", icon: Settings, label: () => t("nav.settings") }, +] as const + function RootLayout() { + const pathname = useRouterState({ select: (s) => s.location.pathname }) + const navigate = useNavigate() + return ( -
+
-
) } - -function NavLink({ - to, - icon: Icon, - label, -}: { to: string; icon: React.ComponentType<{ className?: string }>; label: string }) { - return ( - - - {label} - - ) -} diff --git a/src/shared/components/ui/list-item.tsx b/src/shared/components/ui/list-item.tsx new file mode 100644 index 0000000..4cf16d9 --- /dev/null +++ b/src/shared/components/ui/list-item.tsx @@ -0,0 +1,45 @@ +import { cn } from "@/shared/lib/utils" +import { ChevronRight } from "lucide-react" +import type * as React from "react" + +interface ListItemProps { + title: string + subtitle?: string + media?: React.ReactNode + after?: React.ReactNode + link?: boolean + onClick?: () => void + className?: string +} + +export function ListItem({ + title, + subtitle, + media, + after, + link, + onClick, + className, +}: ListItemProps) { + const Comp = onClick ? "button" : "div" + + return ( + + {media &&
{media}
} +
+
{title}
+ {subtitle &&
{subtitle}
} +
+ {after &&
{after}
} + {link && } +
+ ) +} diff --git a/src/shared/stores/sync-store.ts b/src/shared/stores/sync-store.ts new file mode 100644 index 0000000..6ae478e --- /dev/null +++ b/src/shared/stores/sync-store.ts @@ -0,0 +1,205 @@ +import { getDb } from "@/shared/db/client" +import type { Game } from "@/shared/db/schema" +import { api } from "@/shared/lib/api" +import { create } from "zustand" + +interface SourceState { + syncing: boolean + error: string | null + lastCount: number | null + progress: string | null +} + +interface SyncStore { + steam: SourceState + gog: SourceState + syncSteam: (config: { apiKey: string; steamId: string }) => Promise + connectGog: ( + code: string, + ) => Promise<{ access_token: string; refresh_token: string; user_id: string } | null> + syncGogGames: (accessToken: string, refreshToken: string) => Promise + clearError: (source: "steam" | "gog") => void +} + +const initial: SourceState = { syncing: false, error: null, lastCount: null, progress: null } + +async function saveConfig(key: string, value: unknown) { + const db = await getDb() + 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()`, + [key, JSON.stringify(value)], + ) +} + +async function saveGamesBySource( + _source: string, + games: Omit[], + onProgress?: (current: number, total: number) => void, +) { + const db = await getDb() + for (let i = 0; i < games.length; i++) { + const game = games[i] + await db.query( + `INSERT INTO games (id, title, source, source_id, platform, last_played, playtime_hours, url, canonical_id, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW()) + ON CONFLICT (id) DO UPDATE SET + title = $2, last_played = $6, playtime_hours = $7, url = $8, canonical_id = $9, 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, + ], + ) + if (onProgress && (i + 1) % 10 === 0) { + onProgress(i + 1, games.length) + } + } + onProgress?.(games.length, games.length) +} + +export const useSyncStore = create((set, get) => ({ + steam: { ...initial }, + gog: { ...initial }, + + syncSteam: async (config) => { + if (get().steam.syncing) return + set({ steam: { syncing: true, error: null, lastCount: null, progress: "fetching" } }) + try { + await saveConfig("steam", { apiKey: config.apiKey, steamId: config.steamId }) + + const res = await api.steam.games.$post({ + json: { apiKey: config.apiKey, steamId: config.steamId }, + }) + if (!res.ok) { + const body = await res.text() + let message = `Steam sync failed (${res.status})` + try { + const json = JSON.parse(body) + if (json.error) message = json.error + } catch { + // not JSON + } + throw new Error(message) + } + 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 saveGamesBySource("steam", dbGames, (current, total) => { + set({ steam: { ...get().steam, progress: `saving:${current}:${total}` } }) + }) + await saveConfig("steam_last_sync", new Date().toISOString()) + set({ steam: { syncing: false, error: null, lastCount: data.count, progress: null } }) + } catch (err) { + set({ + steam: { syncing: false, error: (err as Error).message, lastCount: null, progress: null }, + }) + } + }, + + connectGog: async (code) => { + if (get().gog.syncing) return null + set({ gog: { syncing: true, error: null, lastCount: null, progress: "fetching" } }) + try { + const res = await api.gog.auth.$post({ json: { code } }) + if (!res.ok) { + const body = await res.text() + let message = `GOG auth failed (${res.status})` + try { + const json = JSON.parse(body) + if (json.error) message = json.error + } catch { + // not JSON + } + throw new Error(message) + } + const tokens = await res.json() + + await saveConfig("gog", { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + userId: tokens.user_id, + }) + + set({ gog: { syncing: false, error: null, lastCount: null, progress: null } }) + return tokens + } catch (err) { + set({ + gog: { syncing: false, error: (err as Error).message, lastCount: null, progress: null }, + }) + return null + } + }, + + syncGogGames: async (accessToken, refreshToken) => { + if (get().gog.syncing) return + set({ gog: { syncing: true, error: null, lastCount: null, progress: "fetching" } }) + try { + const res = await api.gog.games.$post({ + json: { accessToken, refreshToken }, + }) + if (!res.ok) { + const body = await res.text() + let message = `GOG sync failed (${res.status})` + try { + const json = JSON.parse(body) + if (json.error) message = json.error + } catch { + // not JSON + } + throw new Error(message) + } + 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 saveGamesBySource("gog", dbGames, (current, total) => { + set({ gog: { ...get().gog, progress: `saving:${current}:${total}` } }) + }) + await saveConfig("gog_last_sync", new Date().toISOString()) + set({ gog: { syncing: false, error: null, lastCount: data.count, progress: null } }) + } catch (err) { + set({ + gog: { syncing: false, error: (err as Error).message, lastCount: null, progress: null }, + }) + } + }, + + clearError: (source) => { + set((state) => ({ [source]: { ...state[source], error: null } })) + }, +}))