Skip to content

Commit 9d22dea

Browse files
committed
Add basic GameView with Dynamic Segments React Router
1 parent 8aac82e commit 9d22dea

4 files changed

Lines changed: 101 additions & 43 deletions

File tree

src/components/GameCard.tsx

Lines changed: 14 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1+
import { GamevaultGame } from "@/api/models/GamevaultGame";
12
import { Media } from "@/components/Media";
23
import { useAuth } from "@/context/AuthContext";
34
import { useDownloads } from "@/context/DownloadContext";
4-
import { Game, getGameCoverMediaId } from "@/hooks/useGames";
5+
import { getGameCoverMediaId } from "@/hooks/useGames";
56
import { CloudArrowDownIcon } from "@heroicons/react/16/solid";
67
import { Button } from "@tw/button";
78
import {
@@ -13,23 +14,15 @@ import {
1314
} from "@tw/dropdown";
1415
import clsx from "clsx";
1516
import { useCallback } from "react";
17+
import { useNavigate } from "react-router";
1618

17-
export function GameCard({ game }: { game: Game }) {
19+
export function GameCard({ game }: { game: GamevaultGame }) {
1820
const coverId = getGameCoverMediaId(game);
1921
const { serverUrl } = useAuth();
2022
const { startDownload } = useDownloads() as any;
2123

22-
const filename = (() => {
23-
const p = game.path || (game as any).Path;
24-
if (!p) return `${game.title}.zip`;
25-
// Derive filename similar to Path.GetFileName
26-
try {
27-
const parts = p.split(/\\|\//);
28-
const last = parts[parts.length - 1];
29-
return last || `${game.title}.zip`;
30-
} catch {
31-
return `${game.title}.zip`;
32-
}
24+
const filename = (() => {
25+
return `${game.title}.zip`;
3326
})();
3427

3528
const rawSize = (game as any).size;
@@ -71,14 +64,15 @@ export function GameCard({ game }: { game: Game }) {
7164
[game.id],
7265
);
7366

74-
const handleClientOpen = useCallback(
67+
const navigate = useNavigate();
68+
69+
const handleOpenGameView = useCallback(
7570
(e: React.MouseEvent) => {
7671
e.preventDefault();
7772
e.stopPropagation();
78-
const url = `gamevault://show?gameid=${game.id}`;
79-
window.location.href = url;
73+
navigate(`/library/${game.id}`);
8074
},
81-
[game.id],
75+
[navigate, game.id],
8276
);
8377

8478
return (
@@ -97,10 +91,10 @@ export function GameCard({ game }: { game: Game }) {
9791
className="h-full w-full object-contain rounded-none"
9892
square
9993
alt={game.title}
100-
onClick={handleClientOpen}
94+
onClick={handleOpenGameView}
10195
/>
10296
) : (
103-
<div onClick={handleClientOpen} className="text-xs text-fg-muted">
97+
<div onClick={handleOpenGameView} className="text-xs text-fg-muted">
10498
No Cover
10599
</div>
106100
)}
@@ -130,7 +124,7 @@ export function GameCard({ game }: { game: Game }) {
130124
<h3 className="text-sm font-medium truncate" title={game.title}>
131125
{game.metadata?.title || game.title}
132126
</h3>
133-
{game.sort_title && game.sort_title !== game.title && (
127+
{game.sortTitle && game.sortTitle !== game.title && (
134128
<p
135129
className="mt-0.5 text-xs text-fg-muted truncate"
136130
title={game.title}

src/hooks/useGames.ts

Lines changed: 5 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,7 @@
1+
import { GamevaultGame } from "@/api/models/GamevaultGame";
12
import { useAuth } from "@/context/AuthContext";
23
import { useCallback, useEffect, useRef, useState } from "react";
34

4-
export interface GameMediaRef {
5-
id: number | string;
6-
}
7-
export interface GameMetadata {
8-
cover?: GameMediaRef | { ID?: number | string };
9-
title?: string;
10-
}
11-
export interface Game {
12-
id: number;
13-
title: string;
14-
sort_title?: string;
15-
metadata?: GameMetadata | null;
16-
// Optional file system path provided by backend (used for deriving download filename)
17-
path?: string;
18-
Path?: string; // legacy / PascalCase variant just in case
19-
// Size of the game in bytes (optional, provided by backend if available)
20-
size?: number;
21-
Size?: number; // legacy / alternate casing safeguard
22-
}
235

246
interface PaginatedData<T> {
257
data: T[];
@@ -42,7 +24,7 @@ export function useGames({
4224
}: UseGamesOptions) {
4325
const { serverUrl, authFetch } = useAuth();
4426
const [count, setCount] = useState(0);
45-
const [games, setGames] = useState<Game[]>([]);
27+
const [games, setGames] = useState<GamevaultGame[]>([]);
4628
const [next, setNext] = useState<string | null>(null);
4729
const [loading, setLoading] = useState(false);
4830
const [error, setError] = useState<string | null>(null);
@@ -64,7 +46,7 @@ export function useGames({
6446
const url = `${base}/api/games?${params.toString()}`;
6547
const res = await authFetch(url, { method: "GET", signal: ac.signal });
6648
if (!res.ok) throw new Error(`Games fetch failed (${res.status})`);
67-
const json: PaginatedData<Game> = await res.json();
49+
const json: PaginatedData<GamevaultGame> = await res.json();
6850
setCount(json.meta.totalItems);
6951
setGames(json.data || []);
7052
setNext(json.links?.next || null);
@@ -92,7 +74,7 @@ export function useGames({
9274
}
9375
const res = await authFetch(url, { method: "GET" });
9476
if (!res.ok) throw new Error(`Games fetch failed (${res.status})`);
95-
const json: PaginatedData<Game> = await res.json();
77+
const json: PaginatedData<GamevaultGame> = await res.json();
9678
setGames((prev) => [...prev, ...(json.data || [])]);
9779
setNext(json.links?.next || null);
9880
} catch (e: any) {
@@ -118,7 +100,7 @@ export function useGames({
118100
};
119101
}
120102

121-
export function getGameCoverMediaId(game: Game): number | string | null {
103+
export function getGameCoverMediaId(game: GamevaultGame): number | string | null {
122104
const id =
123105
(game.metadata as any)?.cover?.id ?? (game.metadata as any)?.cover?.ID;
124106
return id ?? null;

src/main.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { PermissionRole } from "@/types/api";
1717
import ProtectedRoute from "./guards/ProtectedRoute";
1818
import Community from "./pages/Community";
1919
import Library from "./pages/Library";
20+
import GameView from "./pages/GameView";
2021
import NotFound from "./pages/NotFound";
2122
import Settings from "./pages/Settings";
2223

@@ -37,6 +38,7 @@ createRoot(document.getElementById("root")!).render(
3738

3839
<Route element={<DashboardLayout />}>
3940
<Route index path="library" element={<Library />} />
41+
<Route path="library/:id" element={<GameView />} />
4042
<Route path="community" element={<Community />} />
4143
<Route path="settings" element={<Settings />} />
4244
<Route

src/pages/GameView.tsx

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { useParams } from "react-router";
2+
import { useAuth } from "@/context/AuthContext";
3+
import { useEffect, useState } from "react";
4+
import { Heading, Subheading } from "@tw/heading";
5+
import { Divider } from "@tw/divider";
6+
import { Media } from "@/components/Media";
7+
import { Button } from "@tw/button";
8+
import { GamevaultGame } from "@/api/models/GamevaultGame";
9+
10+
11+
export default function GameView() {
12+
const { id } = useParams<{ id: string }>();
13+
const numericId = Number(id);
14+
const { serverUrl, authFetch } = useAuth();
15+
const [game, setGame] = useState<GamevaultGame | null>(null);
16+
const [loading, setLoading] = useState(false);
17+
const [error, setError] = useState<string | null>(null);
18+
19+
useEffect(() => {
20+
if (!serverUrl || !numericId || Number.isNaN(numericId)) return;
21+
let cancelled = false;
22+
(async () => {
23+
try {
24+
setLoading(true);
25+
setError(null);
26+
const base = serverUrl.replace(/\/+$/, "");
27+
const res = await authFetch(`${base}/api/games/${numericId}`, { method: 'GET' });
28+
if (!res.ok) throw new Error(`Failed to load game (${res.status})`);
29+
const json = await res.json();
30+
if (!cancelled) setGame(json);
31+
} catch (e: any) {
32+
if (!cancelled) setError(e?.message || 'Failed to load game');
33+
} finally {
34+
if (!cancelled) setLoading(false);
35+
}
36+
})();
37+
return () => { cancelled = true; };
38+
}, [serverUrl, authFetch, numericId]);
39+
40+
const coverId = (game?.metadata as any)?.cover?.id;
41+
const title = game?.metadata?.title || game?.title;
42+
const description = (game as any)?.metadata?.description || null;
43+
44+
return (
45+
<div className="flex flex-col h-full overflow-auto pb-12">
46+
<Heading>{title}</Heading>
47+
<Divider />
48+
{loading && (
49+
<div className="p-6 text-sm text-fg-muted">Loading game…</div>
50+
)}
51+
{error && (
52+
<div className="p-6 text-sm text-red-500 bg-red-500/10 rounded-md max-w-xl">{error}</div>
53+
)}
54+
{!loading && !error && game && (
55+
<div className="p-2 max-w-4xl space-y-6">
56+
<div className="flex flex-col md:flex-row gap-6">
57+
<div className="w-56 md:w-64 shrink-0">
58+
{coverId ? (
59+
<Media media={{ id: coverId } as any} size={256} className="w-full aspect-[3/4]" square alt={title} />
60+
) : (
61+
<div className="w-full aspect-[3/4] flex items-center justify-center rounded-xl bg-zinc-200 dark:bg-zinc-800 text-xs text-fg-muted">No Cover</div>
62+
)}
63+
</div>
64+
<div className="flex-1 min-w-0 space-y-4">
65+
<Subheading level={2}>Description</Subheading>
66+
{description ? (
67+
<p className="text-sm leading-relaxed whitespace-pre-line text-fg-muted">{description}</p>
68+
) : (
69+
<p className="text-sm italic text-fg-muted/70">No description available.</p>
70+
)}
71+
<div>
72+
<Button color="indigo" onClick={() => window.history.back()} className="mt-2">Back</Button>
73+
</div>
74+
</div>
75+
</div>
76+
</div>
77+
)}
78+
</div>
79+
);
80+
}

0 commit comments

Comments
 (0)