Skip to content

Commit 254d5d4

Browse files
Merge pull request #56 from codersforcauses/issue-49-Games_Showcase_Page
Issue 49 games showcase page
2 parents d0d7b60 + 2185a70 commit 254d5d4

17 files changed

Lines changed: 872 additions & 11 deletions

client/next.config.mjs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@ const config = {
1313
},
1414
outputFileTracingRoot: import.meta.dirname,
1515
images: {
16-
domains: ["localhost"],
16+
remotePatterns: [
17+
{ protocol: 'http', hostname: '127.0.0.1' },
18+
{ protocol: 'http', hostname: 'localhost' },
19+
],
1720
},
1821
// Turns on file change polling for the Windows Dev Container
1922
// Doesn't work currently for turbopack, so file changes will not automatically update the client.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
type ItchEmbedProps = {
2+
embedID: string;
3+
name: string;
4+
};
5+
6+
export function ItchEmbed({ embedID, name }: ItchEmbedProps) {
7+
return (
8+
<div className="mb-6 w-full max-w-[552px] px-4 shadow-[0_12px_40px_-16px_hsl(var(--secondary)_/_0.45)] sm:aspect-[552/167] sm:px-0">
9+
<iframe
10+
className="h-full w-full border-0"
11+
src={`https://itch.io/embed/${embedID}?dark=1`}
12+
title={name}
13+
allowFullScreen
14+
/>
15+
</div>
16+
);
17+
}

client/src/hooks/useGames.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { useQuery } from "@tanstack/react-query";
2+
import { AxiosError } from "axios";
3+
4+
import api from "@/lib/api";
5+
6+
type Contributor = {
7+
member_id: number;
8+
name: string;
9+
role: string;
10+
};
11+
12+
type ApiGame = {
13+
name: string;
14+
description: string;
15+
completion: number;
16+
active: boolean;
17+
hostURL: string;
18+
// TO DO: Add support for no itchEmbedID for non-itch games
19+
itchEmbedID: string;
20+
thumbnail: string | null;
21+
event: number | null;
22+
contributors: Contributor[];
23+
};
24+
25+
type UiGame = Omit<ApiGame, "thumbnail"> & {
26+
gameCover: string;
27+
};
28+
29+
/**
30+
* Normalizes Next.js router query parameter to a single string ID.
31+
* Handles both string and array formats from dynamic routes.
32+
*/
33+
function normalizeGameId(
34+
gameId: string | string[] | undefined,
35+
): string | undefined {
36+
if (!gameId) return undefined;
37+
return typeof gameId === "string" ? gameId : gameId[0];
38+
}
39+
40+
function transformApiGameToUiGame(data: ApiGame): UiGame {
41+
return {
42+
...data,
43+
gameCover: data.thumbnail ?? "/game_dev_club_logo.svg",
44+
};
45+
}
46+
47+
/**
48+
* Custom hook to fetch a single game by ID.
49+
*
50+
* @param gameId - game ID from Next.js router query (can be string, string[], or undefined)
51+
* @returns React Query result with transformed UI game data
52+
*
53+
* @example
54+
* ```tsx
55+
* const { id } = router.query;
56+
* const { data: game, isPending, error } = useGame(id);
57+
* ```
58+
*/
59+
export function useGame(gameId: string | string[] | undefined) {
60+
const id = normalizeGameId(gameId);
61+
62+
return useQuery<ApiGame, AxiosError, UiGame>({
63+
queryKey: ["games", id],
64+
queryFn: async () => {
65+
if (!id) {
66+
throw new Error("Game ID is required");
67+
}
68+
const response = await api.get<ApiGame>(`/games/${id}/`);
69+
return response.data;
70+
},
71+
enabled: !!id,
72+
select: transformApiGameToUiGame,
73+
retry: (failureCount, error) => {
74+
if (error!.response?.status === 404) {
75+
return false;
76+
}
77+
return failureCount < 3;
78+
},
79+
});
80+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { useQuery } from "@tanstack/react-query";
2+
import { AxiosError } from "axios";
3+
4+
import api from "@/lib/api";
5+
6+
type Contributor = {
7+
name: string;
8+
role: string;
9+
};
10+
11+
type ApiShowcaseGame = {
12+
game_id: number;
13+
game_name: string;
14+
description: string;
15+
game_description: string;
16+
contributors: Contributor[];
17+
game_cover_thumbnail?: string | null;
18+
};
19+
20+
type UiShowcaseGame = Omit<ApiShowcaseGame, "game_cover_thumbnail"> & {
21+
gameCover: string;
22+
};
23+
24+
function getGameCoverUrl(
25+
game_cover_thumbnail: string | null | undefined,
26+
): string {
27+
if (!game_cover_thumbnail) return "/game_dev_club_logo.svg";
28+
if (game_cover_thumbnail.startsWith("http")) return game_cover_thumbnail;
29+
// Use environment variable for Django backend base URL
30+
const apiBaseUrl =
31+
process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000";
32+
return `${apiBaseUrl}${game_cover_thumbnail}`;
33+
}
34+
35+
function transformApiShowcaseGameToUi(data: ApiShowcaseGame): UiShowcaseGame {
36+
return {
37+
...data,
38+
gameCover: getGameCoverUrl(data.game_cover_thumbnail),
39+
};
40+
}
41+
42+
export function useGameshowcase() {
43+
return useQuery<ApiShowcaseGame[], AxiosError, UiShowcaseGame[]>({
44+
queryKey: ["showcaseGames"],
45+
queryFn: async () => {
46+
const res = await api.get<ApiShowcaseGame[]>("/gameshowcase/");
47+
return res.data;
48+
},
49+
select: (data) => data.map(transformApiShowcaseGameToUi),
50+
retry: (failureCount, error) => {
51+
if (error?.response?.status === 404) return false;
52+
return failureCount < 3;
53+
},
54+
});
55+
}

client/src/pages/games/[id].tsx

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
import Image from "next/image";
2+
import { useRouter } from "next/router";
3+
import React from "react";
4+
5+
import { ItchEmbed } from "@/components/ui/ItchEmbed";
6+
import { useGame } from "@/hooks/useGames";
7+
8+
export default function IndividualGamePage() {
9+
const router = useRouter();
10+
const { id } = router.query;
11+
12+
const {
13+
data: game,
14+
isPending,
15+
error,
16+
isError,
17+
} = useGame(router.isReady ? id : undefined);
18+
19+
if (isPending) {
20+
return (
21+
<main className="mx-auto min-h-dvh max-w-6xl px-6 py-16 md:px-20">
22+
<p>Loading Game...</p>
23+
</main>
24+
);
25+
}
26+
27+
if (isError) {
28+
const errorMessage =
29+
error?.response?.status === 404
30+
? "Game not found."
31+
: "Failed to Load Game";
32+
33+
return (
34+
<main className="mx-auto min-h-screen max-w-6xl px-6 py-16 md:px-20">
35+
<p className="text-red-500" role="alert">
36+
{errorMessage}
37+
</p>
38+
</main>
39+
);
40+
}
41+
42+
if (!game) {
43+
return (
44+
<main className="mx-auto min-h-dvh max-w-6xl px-6 py-16 md:px-20">
45+
<p>No Game data available.</p>
46+
</main>
47+
);
48+
}
49+
50+
const gameTitle = game.name;
51+
const gameCover = game.gameCover;
52+
const gameDescription = game.description.split("\n");
53+
54+
const completionLabels: Record<number, string> = {
55+
1: "WIP",
56+
2: "Playable Dev",
57+
3: "Beta",
58+
4: "Completed",
59+
};
60+
61+
const devStage = completionLabels[game.completion] ?? "Stage Unknown";
62+
63+
// TODO ADD EVENT
64+
const event = "Game Jam November 2025";
65+
// TODO ADD ARTIMAGES
66+
const artImages: { src: string; alt: string }[] = [];
67+
// const artImages = [
68+
// {
69+
// src: "https://upload.wikimedia.org/wikipedia/commons/thumb/a/aa/Minecraft_Zombie.png/120px-Minecraft_Zombie.png",
70+
// alt: "Minecraft Zombie",
71+
// },
72+
// {
73+
// src: "https://upload.wikimedia.org/wikipedia/commons/thumb/d/d8/Minecraft_Enderman.png/120px-Minecraft_Enderman.png",
74+
// alt: "Minecraft Enderman",
75+
// },
76+
// {
77+
// src: "https://upload.wikimedia.org/wikipedia/en/thumb/1/17/Minecraft_explore_landscape.png/375px-Minecraft_explore_landscape.png",
78+
// alt: "Minecraft Landscape",
79+
// },
80+
// ];
81+
82+
return (
83+
<div className="min-h-screen bg-background font-sans text-foreground">
84+
<main>
85+
<section className="w-full bg-popover">
86+
<div className="mx-auto max-w-7xl p-0 sm:p-8">
87+
<Image
88+
src={gameCover}
89+
alt="Game Cover"
90+
width={800}
91+
height={800}
92+
className="max-h-[60vh] w-full object-cover sm:mx-auto sm:h-auto sm:max-h-[60vh] sm:rounded-2xl sm:object-contain"
93+
priority
94+
/>
95+
</div>
96+
</section>
97+
98+
<section className="mx-auto max-w-7xl px-4 py-6 sm:px-8 sm:py-8 lg:px-24 lg:py-12">
99+
<h1 className="mb-6 text-center font-jersey10 text-5xl font-bold tracking-wide text-primary sm:mb-10 sm:text-6xl">
100+
{gameTitle}
101+
</h1>
102+
<div className="mb-6 w-full max-w-full sm:float-right sm:mb-4 sm:ml-8 sm:w-96">
103+
<table className="w-full min-w-[220px] border-collapse border-spacing-0 text-sm sm:text-base">
104+
<tbody>
105+
<tr className="border-b-2 border-gray-300">
106+
<td className="py-1 pr-2 text-muted-foreground sm:py-2">
107+
Contributors
108+
</td>
109+
<td className="py-1 text-right sm:py-2">
110+
<div className="grid grid-cols-[auto_auto] gap-x-1 gap-y-1">
111+
{game.contributors.map((c) => (
112+
<React.Fragment key={c.member_id}>
113+
<a
114+
href={`/member/${c.member_id}`}
115+
className="text-primary hover:underline"
116+
>
117+
{c.name}
118+
</a>
119+
<span>{c.role}</span>
120+
</React.Fragment>
121+
))}
122+
</div>
123+
</td>
124+
</tr>
125+
<tr className="border-b-2 border-gray-300">
126+
<td className="py-1 pr-2 text-muted-foreground sm:py-2">
127+
Development Stage
128+
</td>
129+
<td className="py-1 text-right sm:py-2">{devStage}</td>
130+
</tr>
131+
<tr className="border-b-2 border-gray-300">
132+
<td className="py-1 pr-2 text-muted-foreground sm:py-2">
133+
Host Site
134+
</td>
135+
<td className="py-1 text-right sm:py-2">
136+
<a
137+
href={game.hostURL}
138+
className="text-primary underline hover:underline"
139+
>
140+
{game.hostURL}
141+
</a>
142+
</td>
143+
</tr>
144+
<tr>
145+
<td className="py-1 pr-2 text-muted-foreground sm:py-2">
146+
Event
147+
</td>
148+
<td className="py-1 text-right sm:py-2">{event}</td>
149+
</tr>
150+
</tbody>
151+
</table>
152+
</div>
153+
<ul className="space-y-3 text-base leading-8 sm:text-lg lg:text-xl">
154+
{gameDescription.map((desc, i) => (
155+
<li key={i}>{desc}</li>
156+
))}
157+
</ul>
158+
</section>
159+
160+
<section className="mt-8 flex w-full flex-col items-center gap-6">
161+
{game.itchEmbedID && (
162+
<ItchEmbed embedID={game.itchEmbedID} name={gameTitle} />
163+
)}
164+
<h2 className="font-jersey10 text-5xl text-primary">ARTWORK</h2>
165+
166+
<div className="mx-auto mb-6 flex h-auto w-full max-w-4xl flex-col items-center gap-4 px-4 sm:flex-row sm:justify-center sm:gap-6 sm:px-6 md:h-60">
167+
{artImages.map((img) => (
168+
<div
169+
key={img.src}
170+
className="h-48 w-full overflow-hidden rounded-lg bg-popover shadow-md sm:h-60 sm:w-1/3"
171+
>
172+
<Image
173+
key={img.alt}
174+
src={img.src}
175+
alt={img.alt}
176+
width={240}
177+
height={240}
178+
className="h-full w-full object-cover"
179+
/>
180+
</div>
181+
))}
182+
</div>
183+
</section>
184+
</main>
185+
{/* <Footer /> */}
186+
</div>
187+
);
188+
}

0 commit comments

Comments
 (0)