add list-item component, sync store, refine layout, styles
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
157
src/app.css
157
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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
45
src/shared/components/ui/list-item.tsx
Normal file
45
src/shared/components/ui/list-item.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
205
src/shared/stores/sync-store.ts
Normal file
205
src/shared/stores/sync-store.ts
Normal 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 } }))
|
||||
},
|
||||
}))
|
||||
Reference in New Issue
Block a user