add game detail component, route at /games/$gameId

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-02 21:52:16 +01:00
parent 7f16657a84
commit ac5ac570e2
3 changed files with 129 additions and 0 deletions

View File

@@ -0,0 +1,82 @@
import { Badge } from "@/shared/components/ui/badge"
import { useGame } from "@/shared/db/hooks"
import type { Game } from "@/shared/db/schema"
import { t } from "@/shared/i18n"
import { ExternalLink } from "lucide-react"
import { formatPlaytime } from "../schema"
import { FavoriteButton } from "./favorite-button"
import { GameStateSelect } from "./game-state-select"
import { StarRating } from "./star-rating"
function getSteamHeaderImage(sourceId: string): string {
return `https://cdn.akamai.steamstatic.com/steam/apps/${sourceId}/header.jpg`
}
interface GameDetailProps {
gameId: string
}
export function GameDetail({ gameId }: GameDetailProps) {
const { game, loading, reload } = useGame(gameId)
if (loading) return null
if (!game) {
return <p className="py-8 text-center text-muted-foreground">{t("game.notFound")}</p>
}
return <GameDetailContent game={game} onUpdate={reload} />
}
function GameDetailContent({ game, onUpdate }: { game: Game; onUpdate: () => void }) {
const imageUrl = game.source === "steam" ? getSteamHeaderImage(game.source_id) : null
return (
<div className="space-y-4">
{imageUrl && (
<img
src={imageUrl}
alt={game.title}
className="w-full rounded-xl object-cover aspect-video"
/>
)}
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h2 className="text-xl font-bold">{game.title}</h2>
<Badge variant="secondary" className="shrink-0">
{game.source}
</Badge>
</div>
<div className="mt-1 flex items-center gap-3 text-sm text-muted-foreground">
{game.playtime_hours > 0 && <span>{formatPlaytime(game.playtime_hours)}</span>}
{game.last_played && (
<span>
{t("game.lastPlayed")}: {game.last_played}
</span>
)}
</div>
</div>
<FavoriteButton gameId={game.id} isFavorite={game.is_favorite} onChange={onUpdate} />
</div>
<div className="flex items-center justify-between">
<StarRating gameId={game.id} rating={game.rating} onChange={onUpdate} />
<GameStateSelect gameId={game.id} state={game.game_state} onChange={onUpdate} />
</div>
{game.url && (
<a
href={game.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 rounded-lg border px-4 py-2 text-sm hover:bg-muted"
>
<ExternalLink className="h-4 w-4" />
{t("game.openStore")}
</a>
)}
</div>
)
}

View File

@@ -16,6 +16,7 @@ import { Route as LibraryIndexRouteImport } from './routes/library/index'
import { Route as DiscoverIndexRouteImport } from './routes/discover/index'
import { Route as SettingsProviderRouteImport } from './routes/settings/$provider'
import { Route as PlaylistsPlaylistIdRouteImport } from './routes/playlists/$playlistId'
import { Route as GamesGameIdRouteImport } from './routes/games/$gameId'
const IndexRoute = IndexRouteImport.update({
id: '/',
@@ -52,9 +53,15 @@ const PlaylistsPlaylistIdRoute = PlaylistsPlaylistIdRouteImport.update({
path: '/playlists/$playlistId',
getParentRoute: () => rootRouteImport,
} as any)
const GamesGameIdRoute = GamesGameIdRouteImport.update({
id: '/games/$gameId',
path: '/games/$gameId',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/games/$gameId': typeof GamesGameIdRoute
'/playlists/$playlistId': typeof PlaylistsPlaylistIdRoute
'/settings/$provider': typeof SettingsProviderRoute
'/discover/': typeof DiscoverIndexRoute
@@ -64,6 +71,7 @@ export interface FileRoutesByFullPath {
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/games/$gameId': typeof GamesGameIdRoute
'/playlists/$playlistId': typeof PlaylistsPlaylistIdRoute
'/settings/$provider': typeof SettingsProviderRoute
'/discover': typeof DiscoverIndexRoute
@@ -74,6 +82,7 @@ export interface FileRoutesByTo {
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/games/$gameId': typeof GamesGameIdRoute
'/playlists/$playlistId': typeof PlaylistsPlaylistIdRoute
'/settings/$provider': typeof SettingsProviderRoute
'/discover/': typeof DiscoverIndexRoute
@@ -85,6 +94,7 @@ export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/games/$gameId'
| '/playlists/$playlistId'
| '/settings/$provider'
| '/discover/'
@@ -94,6 +104,7 @@ export interface FileRouteTypes {
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/games/$gameId'
| '/playlists/$playlistId'
| '/settings/$provider'
| '/discover'
@@ -103,6 +114,7 @@ export interface FileRouteTypes {
id:
| '__root__'
| '/'
| '/games/$gameId'
| '/playlists/$playlistId'
| '/settings/$provider'
| '/discover/'
@@ -113,6 +125,7 @@ export interface FileRouteTypes {
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
GamesGameIdRoute: typeof GamesGameIdRoute
PlaylistsPlaylistIdRoute: typeof PlaylistsPlaylistIdRoute
SettingsProviderRoute: typeof SettingsProviderRoute
DiscoverIndexRoute: typeof DiscoverIndexRoute
@@ -172,11 +185,19 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof PlaylistsPlaylistIdRouteImport
parentRoute: typeof rootRouteImport
}
'/games/$gameId': {
id: '/games/$gameId'
path: '/games/$gameId'
fullPath: '/games/$gameId'
preLoaderRoute: typeof GamesGameIdRouteImport
parentRoute: typeof rootRouteImport
}
}
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
GamesGameIdRoute: GamesGameIdRoute,
PlaylistsPlaylistIdRoute: PlaylistsPlaylistIdRoute,
SettingsProviderRoute: SettingsProviderRoute,
DiscoverIndexRoute: DiscoverIndexRoute,

View File

@@ -0,0 +1,26 @@
import { GameDetail } from "@/features/games/components/game-detail"
import { createFileRoute, useRouter } from "@tanstack/react-router"
import { ArrowLeft } from "lucide-react"
export const Route = createFileRoute("/games/$gameId")({
component: GameDetailPage,
})
function GameDetailPage() {
const { gameId } = Route.useParams()
const router = useRouter()
return (
<div className="mx-auto max-w-lg p-4">
<button
type="button"
onClick={() => router.history.back()}
className="mb-4 flex items-center gap-1 text-sm text-muted-foreground"
>
<ArrowLeft className="h-4 w-4" />
Back
</button>
<GameDetail gameId={gameId} />
</div>
)
}