add list-item component, sync store, refine layout, styles

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-02 21:21:15 +01:00
parent ee32bfd206
commit db1f66ced2
5 changed files with 377 additions and 89 deletions

View File

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

View File

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

View File

@@ -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 (
<div className="flex min-h-dvh flex-col bg-background">
<div className="flex min-h-dvh flex-col">
<main className="flex-1 overflow-y-auto pb-20">
<Outlet />
</main>
<nav className="fixed inset-x-0 bottom-0 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="mx-auto flex max-w-lg justify-around py-2">
<NavLink to="/library" icon={Library} label={t("nav.library")} />
<NavLink to="/discover" icon={Gamepad2} label={t("nav.discover")} />
<NavLink to="/playlists" icon={ListMusic} label={t("nav.playlists")} />
<NavLink to="/settings" icon={Settings} label={t("nav.settings")} />
</div>
<nav className="fixed inset-x-0 bottom-0 z-40 flex border-t bg-background pb-[env(safe-area-inset-bottom)]">
{tabs.map((tab) => {
const Icon = tab.icon
const active = pathname.startsWith(tab.to)
return (
<button
key={tab.to}
type="button"
onClick={() => navigate({ to: tab.to })}
className={`flex flex-1 flex-col items-center gap-0.5 py-2 text-[10px] ${active ? "text-primary font-medium" : "text-muted-foreground"}`}
>
<Icon className="h-5 w-5" />
{tab.label()}
</button>
)
})}
</nav>
</div>
)
}
function NavLink({
to,
icon: Icon,
label,
}: { to: string; icon: React.ComponentType<{ className?: string }>; label: string }) {
return (
<Link
to={to}
className="flex flex-col items-center gap-1 px-3 py-1 text-muted-foreground transition-colors [&.active]:text-foreground"
activeProps={{ className: "active text-foreground" }}
>
<Icon className="h-5 w-5" />
<span className="text-xs">{label}</span>
</Link>
)
}

View File

@@ -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 (
<Comp
type={onClick ? "button" : undefined}
onClick={onClick}
className={cn(
"flex w-full items-center gap-3 px-4 py-3 text-left",
onClick && "hover:bg-accent active:bg-accent/80",
className,
)}
>
{media && <div className="shrink-0">{media}</div>}
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{title}</div>
{subtitle && <div className="truncate text-xs text-muted-foreground">{subtitle}</div>}
</div>
{after && <div className="shrink-0">{after}</div>}
{link && <ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />}
</Comp>
)
}

View File

@@ -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<void>
connectGog: (
code: string,
) => Promise<{ access_token: string; refresh_token: string; user_id: string } | null>
syncGogGames: (accessToken: string, refreshToken: string) => Promise<void>
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<Game, "rating" | "game_state" | "is_favorite">[],
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<SyncStore>((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 } }))
},
}))