add game detail component, route at /games/$gameId
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
82
src/features/games/components/game-detail.tsx
Normal file
82
src/features/games/components/game-detail.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
26
src/routes/games/$gameId.tsx
Normal file
26
src/routes/games/$gameId.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user