diff --git a/client/next.config.mjs b/client/next.config.mjs
index 8b6bf03..0fcd33e 100644
--- a/client/next.config.mjs
+++ b/client/next.config.mjs
@@ -13,7 +13,11 @@ const config = {
},
outputFileTracingRoot: import.meta.dirname,
images: {
- domains: ["localhost"],
+ remotePatterns: [
+ { protocol: 'http', hostname: '127.0.0.1' },
+ { protocol: 'http', hostname: 'localhost' },
+ { protocol: 'https', hostname: 'upload.wikimedia.org' },
+ ],
},
// Turns on file change polling for the Windows Dev Container
// Doesn't work currently for turbopack, so file changes will not automatically update the client.
diff --git a/client/src/components/ui/ItchEmbed.tsx b/client/src/components/ui/ItchEmbed.tsx
new file mode 100644
index 0000000..eccff19
--- /dev/null
+++ b/client/src/components/ui/ItchEmbed.tsx
@@ -0,0 +1,17 @@
+type ItchEmbedProps = {
+ embedID: string;
+ name: string;
+};
+
+export function ItchEmbed({ embedID, name }: ItchEmbedProps) {
+ return (
+
+
+
+ );
+}
diff --git a/client/src/hooks/useGames.ts b/client/src/hooks/useGames.ts
new file mode 100644
index 0000000..ea3bd4d
--- /dev/null
+++ b/client/src/hooks/useGames.ts
@@ -0,0 +1,81 @@
+import { useQuery } from "@tanstack/react-query";
+import { AxiosError } from "axios";
+
+import api from "@/lib/api";
+
+type Contributor = {
+ member_id: number;
+ name: string;
+ role: string;
+};
+
+type ApiGame = {
+ name: string;
+ description: string;
+ completion: number;
+ active: boolean;
+ hostURL: string;
+ isItch: boolean;
+ // TO DO: Add support for no itchEmbedID for non-itch games
+ itchEmbedID: string;
+ pathToThumbnail: string | null;
+ event: number | null;
+ contributors: Contributor[];
+};
+
+type UiGame = Omit & {
+ gameCover: string;
+};
+
+/**
+ * Normalizes Next.js router query parameter to a single string ID.
+ * Handles both string and array formats from dynamic routes.
+ */
+function normalizeGameId(
+ gameId: string | string[] | undefined,
+): string | undefined {
+ if (!gameId) return undefined;
+ return typeof gameId === "string" ? gameId : gameId[0];
+}
+
+function transformApiGameToUiGame(data: ApiGame): UiGame {
+ return {
+ ...data,
+ gameCover: data.pathToThumbnail ?? "/game_dev_club_logo.svg",
+ };
+}
+
+/**
+ * Custom hook to fetch a single game by ID.
+ *
+ * @param gameId - game ID from Next.js router query (can be string, string[], or undefined)
+ * @returns React Query result with transformed UI game data
+ *
+ * @example
+ * ```tsx
+ * const { id } = router.query;
+ * const { data: game, isPending, error } = useGame(id);
+ * ```
+ */
+export function useGame(gameId: string | string[] | undefined) {
+ const id = normalizeGameId(gameId);
+
+ return useQuery({
+ queryKey: ["games", id],
+ queryFn: async () => {
+ if (!id) {
+ throw new Error("Game ID is required");
+ }
+ const response = await api.get(`/games/${id}/`);
+ return response.data;
+ },
+ enabled: !!id,
+ select: transformApiGameToUiGame,
+ retry: (failureCount, error) => {
+ if (error!.response?.status === 404) {
+ return false;
+ }
+ return failureCount < 3;
+ },
+ });
+}
diff --git a/client/src/pages/games/[id].tsx b/client/src/pages/games/[id].tsx
new file mode 100644
index 0000000..e9e5d5d
--- /dev/null
+++ b/client/src/pages/games/[id].tsx
@@ -0,0 +1,187 @@
+import Image from "next/image";
+import { useRouter } from "next/router";
+import React from "react";
+
+import { ItchEmbed } from "@/components/ui/ItchEmbed";
+import { useGame } from "@/hooks/useGames";
+
+export default function IndividualGamePage() {
+ const router = useRouter();
+ const { id } = router.query;
+
+ const {
+ data: game,
+ isPending,
+ error,
+ isError,
+ } = useGame(router.isReady ? id : undefined);
+
+ if (isPending) {
+ return (
+
+ Loading Game...
+
+ );
+ }
+
+ if (isError) {
+ const errorMessage =
+ error?.response?.status === 404
+ ? "Game not found."
+ : "Failed to Load Game";
+
+ return (
+
+
+ {errorMessage}
+
+
+ );
+ }
+
+ if (!game) {
+ return (
+
+ No Game data available.
+
+ );
+ }
+
+ const gameTitle = game.name;
+ const gameCover = game.gameCover;
+ const gameDescription = game.description.split("\n");
+
+ const completionLabels: Record = {
+ 1: "WIP",
+ 2: "Playable Dev",
+ 3: "Beta",
+ 4: "Completed",
+ };
+
+ const devStage = completionLabels[game.completion] ?? "Stage Unknown";
+
+ // TODO ADD EVENT
+ const event = "Game Jam November 2025";
+ // TODO ADD ARTIMAGES
+ const artImages = [
+ {
+ src: "https://upload.wikimedia.org/wikipedia/commons/thumb/a/aa/Minecraft_Zombie.png/120px-Minecraft_Zombie.png",
+ alt: "Minecraft Zombie",
+ },
+ {
+ src: "https://upload.wikimedia.org/wikipedia/commons/thumb/d/d8/Minecraft_Enderman.png/120px-Minecraft_Enderman.png",
+ alt: "Minecraft Enderman",
+ },
+ {
+ src: "https://upload.wikimedia.org/wikipedia/en/thumb/1/17/Minecraft_explore_landscape.png/375px-Minecraft_explore_landscape.png",
+ alt: "Minecraft Landscape",
+ },
+ ];
+
+ return (
+
+
+
+
+
+
+ {gameTitle}
+
+
+
+
+
+ |
+ Contributors
+ |
+
+
+ {game.contributors.map((c) => (
+
+
+ {c.name}
+
+ {c.role}
+
+ ))}
+
+ |
+
+
+ |
+ Development Stage
+ |
+ {devStage} |
+
+
+ |
+ Host Site
+ |
+
+
+ {game.hostURL}
+
+ |
+
+
+ |
+ Event
+ |
+ {event} |
+
+
+
+
+
+ {gameDescription.map((desc, i) => (
+ - {desc}
+ ))}
+
+
+
+
+ {game.isItch && (
+
+ )}
+ ARTWORK
+
+
+ {artImages.map((img) => (
+
+
+
+ ))}
+
+
+
+ {/*
*/}
+
+ );
+}
diff --git a/client/src/pages/games/index.tsx b/client/src/pages/games/index.tsx
new file mode 100644
index 0000000..e69de29
diff --git a/server/api/urls.py b/server/api/urls.py
index 5d54527..944a906 100644
--- a/server/api/urls.py
+++ b/server/api/urls.py
@@ -20,11 +20,11 @@
from django.conf import settings
from django.conf.urls.static import static
+
urlpatterns = [
path("admin/", admin.site.urls),
path("api/", include("game_dev.urls")),
]
if settings.DEBUG:
- urlpatterns += static(settings.MEDIA_URL,
- document_root=settings.MEDIA_ROOT)
+ urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
diff --git a/server/game_dev/admin.py b/server/game_dev/admin.py
index 46d358c..0de3b85 100644
--- a/server/game_dev/admin.py
+++ b/server/game_dev/admin.py
@@ -1,14 +1,25 @@
from django.contrib import admin
-from .models import Member, Event
+from .models import Member, Game, Event, GameContributor
class MemberAdmin(admin.ModelAdmin):
pass
-class EventAdmin(admin.ModelAdmin):
- list_display = ("name", "date", "location", "publicationDate")
+# Sample EventsAdmin Class made
+class EventsAdmin(admin.ModelAdmin):
+ pass
+
+
+class GameContributorAdmin(admin.ModelAdmin):
+ pass
+
+
+class GamesAdmin(admin.ModelAdmin):
+ list_display = ("name", "description", "completion", "active", "hostURL", "isItch", "itchEmbedID", "pathToThumbnail", "event")
admin.site.register(Member, MemberAdmin)
-admin.site.register(Event, EventAdmin)
+admin.site.register(Event, EventsAdmin)
+admin.site.register(Game, GamesAdmin)
+admin.site.register(GameContributor, GameContributorAdmin)
diff --git a/server/game_dev/migrations/0003_event_date.py b/server/game_dev/migrations/0003_event_date.py
deleted file mode 100644
index fe8c64d..0000000
--- a/server/game_dev/migrations/0003_event_date.py
+++ /dev/null
@@ -1,18 +0,0 @@
-# Generated by Django 5.1.14 on 2025-12-06 07:23
-
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ("game_dev", "0002_event"),
- ]
-
- operations = [
- migrations.AddField(
- model_name="event",
- name="date",
- field=models.DateTimeField(blank=True, null=True),
- ),
- ]
diff --git a/server/game_dev/migrations/0003_event_date_game.py b/server/game_dev/migrations/0003_event_date_game.py
new file mode 100644
index 0000000..2cd3495
--- /dev/null
+++ b/server/game_dev/migrations/0003_event_date_game.py
@@ -0,0 +1,69 @@
+# Generated by Django 5.1.14 on 2026-01-10 03:27
+
+import django.db.models.deletion
+import django.db.models.functions.datetime
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("game_dev", "0002_event"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="event",
+ name="date",
+ field=models.DateTimeField(
+ db_default=django.db.models.functions.datetime.Now()
+ ),
+ ),
+ migrations.CreateModel(
+ name="Game",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("name", models.CharField(max_length=200)),
+ ("description", models.TextField()),
+ (
+ "completion",
+ models.IntegerField(
+ choices=[
+ (1, "Work in Progress (Unplayable)"),
+ (2, "Playable - In Development"),
+ (3, "Beta - Stable but not Final"),
+ (4, "Completed"),
+ ],
+ default=1,
+ ),
+ ),
+ ("active", models.BooleanField(default=True)),
+ (
+ "hostURL",
+ models.CharField(
+ help_text="If game is stored on itch.io, please enter the 7 digit long game id as its hostURL, i.e., 1000200",
+ max_length=2083,
+ ),
+ ),
+ ("isItch", models.BooleanField(default=True)),
+ ("pathToThumbnail", models.ImageField(null=True, upload_to="games/")),
+ (
+ "event",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ to="game_dev.event",
+ ),
+ ),
+ ],
+ ),
+ ]
diff --git a/server/game_dev/migrations/0004_alter_event_date.py b/server/game_dev/migrations/0004_alter_event_date.py
deleted file mode 100644
index edca9b7..0000000
--- a/server/game_dev/migrations/0004_alter_event_date.py
+++ /dev/null
@@ -1,18 +0,0 @@
-# Generated by Django 5.1.14 on 2025-12-06 07:46
-
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ("game_dev", "0003_event_date"),
- ]
-
- operations = [
- migrations.AlterField(
- model_name="event",
- name="date",
- field=models.DateTimeField(),
- ),
- ]
diff --git a/server/game_dev/migrations/0004_gamecontributors.py b/server/game_dev/migrations/0004_gamecontributors.py
new file mode 100644
index 0000000..78f166f
--- /dev/null
+++ b/server/game_dev/migrations/0004_gamecontributors.py
@@ -0,0 +1,48 @@
+# Generated by Django 5.1.14 on 2026-01-10 04:59
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("game_dev", "0003_event_date_game"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="GameContributors",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("role", models.CharField(max_length=100)),
+ (
+ "game",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="game_contributors",
+ to="game_dev.game",
+ ),
+ ),
+ (
+ "member",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="member_games",
+ to="game_dev.member",
+ ),
+ ),
+ ],
+ options={
+ "unique_together": {("game", "member")},
+ },
+ ),
+ ]
diff --git a/server/game_dev/migrations/0005_rename_gamecontributors_gamecontributor.py b/server/game_dev/migrations/0005_rename_gamecontributors_gamecontributor.py
new file mode 100644
index 0000000..5fd7927
--- /dev/null
+++ b/server/game_dev/migrations/0005_rename_gamecontributors_gamecontributor.py
@@ -0,0 +1,17 @@
+# Generated by Django 5.1.14 on 2026-01-17 07:49
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("game_dev", "0004_gamecontributors"),
+ ]
+
+ operations = [
+ migrations.RenameModel(
+ old_name="GameContributors",
+ new_name="GameContributor",
+ ),
+ ]
diff --git a/server/game_dev/models.py b/server/game_dev/models.py
index 6398070..8a1c770 100644
--- a/server/game_dev/models.py
+++ b/server/game_dev/models.py
@@ -1,4 +1,5 @@
from django.db import models
+from django.db.models.functions import Now
class Member(models.Model):
@@ -14,7 +15,7 @@ def __str__(self):
class Event(models.Model):
name = models.CharField(max_length=200)
- date = models.DateTimeField()
+ date = models.DateTimeField(db_default=Now())
description = models.CharField(max_length=256, blank=True)
publicationDate = models.DateField()
cover_image = models.ImageField(upload_to="events/", null=True)
@@ -22,3 +23,48 @@ class Event(models.Model):
def __str__(self):
return self.name
+
+
+# GameContributor table: links Game, Member, and role (composite PK)
+class GameContributor(models.Model):
+ game = models.ForeignKey('Game', on_delete=models.CASCADE, related_name='game_contributors')
+ member = models.ForeignKey('Member', on_delete=models.CASCADE, related_name='member_games')
+ role = models.CharField(max_length=100)
+
+ class Meta:
+ unique_together = (('game', 'member'),)
+
+ def __str__(self):
+ return f"{self.member.name} ({self.role}) for {self.game.name}"
+
+
+class Game(models.Model):
+ # Enum choices
+ class CompletionStatus(models.IntegerChoices):
+ WIP = 1, "Work in Progress (Unplayable)"
+ PLAYABLE_DEV = 2, "Playable - In Development"
+ BETA = 3, "Beta - Stable but not Final"
+ COMPLETED = 4, "Completed"
+
+ name = models.CharField(max_length=200, null=False)
+ description = models.TextField()
+ completion = models.IntegerField(
+ choices=CompletionStatus.choices,
+ default=CompletionStatus.WIP,
+ null=False,
+ )
+ active = models.BooleanField(default=True, null=False)
+ hostURL = models.CharField(max_length=2083)
+ isItch = models.BooleanField(default=True, null=False)
+ itchEmbedID = models.PositiveIntegerField(
+ default=None,
+ null=True,
+ blank=True,
+ help_text="If game is stored on itch.io, please enter the 7 digit long game id as its itchEmbedID, i.e., 1000200"
+ )
+
+ pathToThumbnail = models.ImageField(upload_to="games/", null=True)
+ event = models.ForeignKey(Event, on_delete=models.SET_NULL, null=True, blank=True)
+
+ def __str__(self):
+ return str(self.name)
diff --git a/server/game_dev/serializers.py b/server/game_dev/serializers.py
index b50638d..ea9388a 100644
--- a/server/game_dev/serializers.py
+++ b/server/game_dev/serializers.py
@@ -1,5 +1,5 @@
from rest_framework import serializers
-from .models import Event, Member
+from .models import Event, Member, Game, GameContributor
class EventSerializer(serializers.ModelSerializer):
@@ -16,6 +16,28 @@ class Meta:
]
+# This is child serializer of GameSerializer
+class GameContributorSerializer(serializers.ModelSerializer):
+ member_id = serializers.IntegerField(source="member.id") # to link contributors to their member/[id] page
+ name = serializers.CharField(source="member.name")
+
+ class Meta:
+ model = GameContributor
+ fields = ("member_id", "name", "role")
+
+
+class GamesSerializer(serializers.ModelSerializer):
+ contributors = GameContributorSerializer(
+ many=True,
+ source="game_contributors",
+ read_only=True
+ )
+
+ class Meta:
+ model = Game
+ fields = ('id', 'name', 'description', 'completion', 'active', 'hostURL', 'isItch', 'itchEmbedID', 'pathToThumbnail', 'event', "contributors")
+
+
class MemberSerializer(serializers.ModelSerializer):
class Meta:
model = Member
diff --git a/server/game_dev/urls.py b/server/game_dev/urls.py
index d42d6a8..bde19ad 100644
--- a/server/game_dev/urls.py
+++ b/server/game_dev/urls.py
@@ -1,7 +1,9 @@
from django.urls import path
-from .views import EventListAPIView, EventDetailAPIView
+from .views import EventListAPIView, EventDetailAPIView, GamesDetailAPIView, itch_embed_proxy
urlpatterns = [
path("events/", EventListAPIView.as_view(), name="events-list"),
path("events//", EventDetailAPIView.as_view()),
+ path("games//", GamesDetailAPIView.as_view()),
+ path("itch-embed//", itch_embed_proxy),
]
diff --git a/server/game_dev/views.py b/server/game_dev/views.py
index 89ce3a9..0dae241 100644
--- a/server/game_dev/views.py
+++ b/server/game_dev/views.py
@@ -1,7 +1,9 @@
-# from django.shortcuts import render
-
-# Create your views here.
from rest_framework import generics
+from .serializers import GamesSerializer
+from .models import Game
+import urllib.request
+from django.http import JsonResponse
+from django.views.decorators.csrf import csrf_exempt
from .models import Event
from .serializers import EventSerializer
from django.utils import timezone
@@ -42,6 +44,28 @@ def get_queryset(self):
{"type": "Invalid value. Use 'past' or 'upcoming'."})
+class GamesDetailAPIView(generics.RetrieveAPIView):
+ """
+ GET /api/games//
+ """
+ serializer_class = GamesSerializer
+ lookup_url_kwarg = "id"
+
+ def get_queryset(self):
+ return Game.objects.filter(id=self.kwargs["id"])
+
+
+@csrf_exempt
+def itch_embed_proxy(request, embed_id):
+ url = f"https://itch.io/embed/{embed_id}"
+ try:
+ with urllib.request.urlopen(url, timeout=10) as response:
+ html = response.read().decode("utf-8")
+ return JsonResponse({"html": html})
+ except Exception as e:
+ return JsonResponse({"error": str(e)}, status=500)
+
+
class EventDetailAPIView(generics.RetrieveAPIView):
"""
GET /api/events//