diff --git a/client/next.config.mjs b/client/next.config.mjs
index 950b3e64..abff464b 100644
--- a/client/next.config.mjs
+++ b/client/next.config.mjs
@@ -1,12 +1,6 @@
-// import os from "node:os";
-// import isInsideContainer from "is-inside-container";
-
-// const isWindowsDevContainer = () =>
-// os.release().toLowerCase().includes("microsoft") && isInsideContainer();
-
/** @type {import('next').NextConfig} */
-const config = {
+const nextConfig = {
reactStrictMode: true,
turbopack: {
root: import.meta.dirname,
@@ -27,4 +21,4 @@ const config = {
// : undefined,
};
-export default config;
+export default nextConfig;
diff --git a/client/public/go-back-icon.svg b/client/public/go-back-icon.svg
new file mode 100644
index 00000000..e920f5a5
--- /dev/null
+++ b/client/public/go-back-icon.svg
@@ -0,0 +1,10 @@
+
diff --git a/client/public/placeholder-icon.svg b/client/public/placeholder-icon.svg
new file mode 100644
index 00000000..6879e787
--- /dev/null
+++ b/client/public/placeholder-icon.svg
@@ -0,0 +1,6 @@
+
diff --git a/client/public/placeholder1293x405.svg b/client/public/placeholder1293x405.svg
new file mode 100644
index 00000000..34f928c8
--- /dev/null
+++ b/client/public/placeholder1293x405.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/client/src/components/ui/go-back-button.tsx b/client/src/components/ui/go-back-button.tsx
new file mode 100644
index 00000000..53ca3501
--- /dev/null
+++ b/client/src/components/ui/go-back-button.tsx
@@ -0,0 +1,30 @@
+import Image from "next/image";
+import Link from "next/link";
+
+interface GoBackButtonProps {
+ url: string;
+ label: string;
+}
+const GoBackButton = ({ url, label }: GoBackButtonProps) => {
+ return (
+
+
+
+ );
+};
+
+export default GoBackButton;
diff --git a/client/src/components/ui/image-card.tsx b/client/src/components/ui/image-card.tsx
new file mode 100644
index 00000000..32fba7e0
--- /dev/null
+++ b/client/src/components/ui/image-card.tsx
@@ -0,0 +1,99 @@
+import Image from "next/image";
+import { useRouter } from "next/router";
+import React from "react";
+
+interface ImageCardProps {
+ imageSrc?: string;
+ imageAlt?: string;
+ /** Optional content rendered on the front (over the image or placeholder). */
+ children?: React.ReactNode;
+ /** Optional content rendered on the back when hovering/focused. */
+ backContent?: React.ReactNode;
+ /** Optional href for navigation when clicking the front face */
+ href?: string;
+}
+
+const ImageCard = ({
+ imageSrc,
+ imageAlt = "Image",
+ children,
+ backContent,
+ href,
+}: ImageCardProps) => {
+ const router = useRouter();
+ const [isFlipped, setIsFlipped] = React.useState(false);
+ const [isMobile, setIsMobile] = React.useState(false);
+
+ React.useEffect(() => {
+ const checkMobile = () => {
+ setIsMobile(window.innerWidth < 768);
+ };
+
+ checkMobile();
+ window.addEventListener("resize", checkMobile);
+ return () => window.removeEventListener("resize", checkMobile);
+ }, []);
+
+ const handleClick = () => {
+ // On mobile, navigate directly if href is provided
+ if (isMobile && href) {
+ router.push(href);
+ } else if (backContent) {
+ // On desktop, toggle flip state
+ setIsFlipped(!isFlipped);
+ }
+ };
+
+ return (
+
+
+
+ {imageSrc ? (
+ <>
+
+ {children && (
+
+ {children}
+
+ )}
+ >
+ ) : (
+
+ {children || No Image}
+
+ )}
+
+
+ {backContent && (
+
+ {backContent}
+
+ )}
+
+
+ );
+};
+
+export default ImageCard;
diff --git a/client/src/components/ui/image-placeholder.tsx b/client/src/components/ui/image-placeholder.tsx
new file mode 100644
index 00000000..c694f378
--- /dev/null
+++ b/client/src/components/ui/image-placeholder.tsx
@@ -0,0 +1,21 @@
+import Image from "next/image";
+import React from "react";
+
+const ImagePlaceholder = () => {
+ return (
+
+ );
+};
+export default ImagePlaceholder;
diff --git a/client/src/components/ui/modal/error-modal.tsx b/client/src/components/ui/modal/error-modal.tsx
new file mode 100644
index 00000000..ba1c2404
--- /dev/null
+++ b/client/src/components/ui/modal/error-modal.tsx
@@ -0,0 +1,45 @@
+import React, { useState } from "react";
+
+interface ErrorModalProps {
+ message: string | null;
+ onClose: () => void;
+}
+
+const ErrorModal = ({ message, onClose = () => {} }: ErrorModalProps) => {
+ const [isVisible, setIsVisible] = useState(true);
+ if (!isVisible || !message) {
+ return null;
+ }
+
+ function onModalClose() {
+ setIsVisible(false);
+ onClose();
+ }
+
+ return (
+ // Backdrop overlay
+
+ {/* Modal content container */}
+
e.stopPropagation()} // Prevent closing when clicking inside the modal
+ >
+
Error
+
{message}
+
+
+
+
+
+ );
+};
+
+export default ErrorModal;
diff --git a/client/src/hooks/use-artwork-data.ts b/client/src/hooks/use-artwork-data.ts
new file mode 100644
index 00000000..60a65adf
--- /dev/null
+++ b/client/src/hooks/use-artwork-data.ts
@@ -0,0 +1,58 @@
+import { Art } from "@/types/art";
+
+export const generateMockArtworks = (count: number): Art[] => {
+ const artworks: Art[] = [];
+ for (let i = 1; i <= count; i++) {
+ artworks.push({
+ id: i,
+ name: `Artwork ${i}`,
+ description: "Mock artwork description",
+ //source_game: "Mock Game",
+ media: "",
+ active: true,
+ contributors: [
+ {
+ id: i * 10 + 1,
+ art_id: i,
+ member_name: "Contributor 1",
+ role: "artist",
+ },
+ {
+ id: i * 10 + 2,
+ art_id: i,
+ member_name: "Contributor 2",
+ role: "designer",
+ },
+ ],
+ //created_at: new Date().toISOString(),
+ });
+ }
+ return artworks;
+};
+
+export const generateMockArtwork = (id: string): Art => {
+ return {
+ id: Number(id),
+ name: "Mock Artwork Title",
+ description:
+ "Lorem ipsum dolor sit amet. Non numquam dicta nam autem dicta 33 error molestias et repellat consequatur eum iste expedita est dolorem libero et quas provident!",
+ //source_game: "Mock Game",
+ media: "",
+ active: true,
+ //created_at: new Date().toISOString(),
+ contributors: [
+ {
+ id: 1,
+ art_id: Number(id),
+ member_name: "Contributor 1",
+ role: "user1",
+ },
+ {
+ id: 2,
+ art_id: Number(id),
+ member_name: "Contributor 2",
+ role: "user2",
+ },
+ ],
+ };
+};
diff --git a/client/src/pages/artwork/[id].tsx b/client/src/pages/artwork/[id].tsx
new file mode 100644
index 00000000..064e8e18
--- /dev/null
+++ b/client/src/pages/artwork/[id].tsx
@@ -0,0 +1,178 @@
+import { GetServerSideProps } from "next";
+import Image from "next/image";
+import { useRouter } from "next/navigation";
+
+import GoBackButton from "@/components/ui/go-back-button";
+import ImagePlaceholder from "@/components/ui/image-placeholder";
+import ErrorModal from "@/components/ui/modal/error-modal";
+import api from "@/lib/api";
+import { Art } from "@/types/art";
+
+interface ArtworkPageProps {
+ artwork?: Art;
+ error?: string;
+}
+
+function displayContributors(artwork: Art) {
+ return (
+
+
+
+
+ {artwork.contributors?.map((contributor) => (
+
+
+ {contributor.member_name}
+
+
+ ))}
+
+
+
+ );
+}
+
+export default function ArtworkPage({ artwork, error }: ArtworkPageProps) {
+ const router = useRouter();
+ if (error) {
+ return router.back()} />;
+ }
+ return (
+
+
+
+
+ {artwork!.media ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {artwork!.name}
+
+
+
+
+ {artwork!.description}
+
+
+
+ {displayContributors(artwork!)}
+
+
+
+
+
+ {artwork!.name}
+
+
+
+
+ {artwork!.description}
+
+
+
+ {displayContributors(artwork!)}
+
+
+
+
+ TODO add footer
+
+
+ );
+}
+
+export const getServerSideProps: GetServerSideProps = async (
+ context,
+) => {
+ const { id } = context.params as { id: string };
+ try {
+ const artResponse = await api.get(`arts/${id}`);
+ const artwork = artResponse.data;
+ return { props: { artwork } };
+ } catch (err: unknown) {
+ return {
+ props: { error: (err as Error).message || "Failed to load artwork." },
+ };
+ }
+};
diff --git a/client/src/pages/artwork/index.tsx b/client/src/pages/artwork/index.tsx
new file mode 100644
index 00000000..3cb76285
--- /dev/null
+++ b/client/src/pages/artwork/index.tsx
@@ -0,0 +1,138 @@
+import { GetServerSideProps } from "next";
+import Image from "next/image";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+
+import ImageCard from "@/components/ui/image-card";
+import ErrorModal from "@/components/ui/modal/error-modal";
+import { generateMockArtworks } from "@/hooks/use-artwork-data";
+import api from "@/lib/api";
+import { Art } from "@/types/art";
+
+export interface PageResult {
+ count: number;
+ next: string;
+ previous: string;
+ results: T[];
+}
+
+interface ArtworksPageProps {
+ artworks?: PageResult;
+ error?: string;
+}
+
+const PLACEHOLDER_ICON = (
+
+
+
+);
+
+function renderArtworkCard(artwork: Art) {
+ return (
+
+
+
+ {artwork.name}
+
+
+ from GAME NAME
+
+
+ {artwork.description || "No description available."}
+
+
+
+ {artwork.contributors.length > 0 && (
+
+
+ Contributors
+
+
+ {artwork.contributors.map((contributor) => (
+
+ {contributor.member_name}
+
+ ))}
+
+
+ )}
+
+ e.stopPropagation()}
+ >
+ VIEW FULL DETAILS
+
+
+ }
+ >
+ {!artwork.media && PLACEHOLDER_ICON}
+
+ );
+}
+
+export default function ArtworksPage({ artworks, error }: ArtworksPageProps) {
+ const router = useRouter();
+ if (error) {
+ return router.back()} />;
+ }
+
+ return (
+
+
+
+ FEATURED
+
+
+
+ {artworks?.results.slice(0, 3).map(renderArtworkCard)}
+
+
+
+ );
+}
+
+export const getServerSideProps: GetServerSideProps<
+ ArtworksPageProps
+> = async () => {
+ try {
+ const res = await api.get>("arts");
+ return { props: { artworks: res.data } };
+ //} catch (err: unknown) {
+ } catch {
+ // return {
+ // props: { error: (err as Error).message || "Failed to load artworks." },
+ // };
+
+ // Fallback to mock data on error
+ const mockArtworks = generateMockArtworks(3);
+ return {
+ props: {
+ artworks: {
+ results: mockArtworks,
+ count: mockArtworks.length,
+ next: "",
+ previous: "",
+ },
+ },
+ };
+ }
+};
diff --git a/client/src/types/art-contributor.ts b/client/src/types/art-contributor.ts
new file mode 100644
index 00000000..8afd03b5
--- /dev/null
+++ b/client/src/types/art-contributor.ts
@@ -0,0 +1,6 @@
+export interface ArtContributor {
+ id: number;
+ art_id: number;
+ member_name: string;
+ role: string;
+}
diff --git a/client/src/types/art.ts b/client/src/types/art.ts
new file mode 100644
index 00000000..ebb22e13
--- /dev/null
+++ b/client/src/types/art.ts
@@ -0,0 +1,10 @@
+import { ArtContributor } from "./art-contributor";
+
+export interface Art {
+ id: number;
+ name: string;
+ description: string;
+ media: string;
+ active: boolean;
+ contributors: ArtContributor[];
+}
diff --git a/client/tailwind.config.ts b/client/tailwind.config.ts
index 52306b8e..156c4005 100644
--- a/client/tailwind.config.ts
+++ b/client/tailwind.config.ts
@@ -70,6 +70,12 @@ const config = {
neutral_4: "var(--neutral-4)",
light_1: "var(--light-1)",
light_2: "var(--light-2)",
+ light_3: "var(--light-3)",
+ light_alt: "var(--light-alt)",
+ light_alt_2: "var(--light-alt-2)",
+ logo_blue_2: "var(--logo-blue-2)",
+ logo_blue_1: "var(--logo-blue-1)",
+ error: "var(--error)",
},
borderRadius: {
lg: "var(--radius)",
diff --git a/server/game_dev/admin.py b/server/game_dev/admin.py
index a50a46a4..cb1d753d 100644
--- a/server/game_dev/admin.py
+++ b/server/game_dev/admin.py
@@ -1,5 +1,5 @@
from django.contrib import admin
-from .models import Member, Game, Event, GameContributor, GameShowcase, Committee, SocialMedia
+from .models import Art, ArtContributor, Member, Game, Event, GameContributor, GameShowcase, Committee, SocialMedia
class SocialMediaInline(admin.TabularInline):
@@ -41,4 +41,6 @@ class CommitteeAdmin(admin.ModelAdmin):
admin.site.register(Game, GamesAdmin)
admin.site.register(GameContributor, GameContributorAdmin)
admin.site.register(GameShowcase, GameShowcaseAdmin)
+admin.site.register(Art)
+admin.site.register(ArtContributor)
admin.site.register(Committee, CommitteeAdmin)
diff --git a/server/game_dev/migrations/0005_art_artcontributor.py b/server/game_dev/migrations/0005_art_artcontributor.py
new file mode 100644
index 00000000..f3d0c905
--- /dev/null
+++ b/server/game_dev/migrations/0005_art_artcontributor.py
@@ -0,0 +1,68 @@
+# Generated by Django 5.1.14 on 2025-11-28 17:32
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("game_dev", "0004_alter_event_date"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="Art",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("name", models.CharField(max_length=200)),
+ ("description", models.CharField(max_length=200)),
+ ("path_to_media", models.CharField(max_length=500)),
+ ("active", models.BooleanField()),
+ ],
+ ),
+ migrations.CreateModel(
+ name="ArtContributor",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("role", models.CharField(max_length=100)),
+ (
+ "art",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="contributors",
+ to="game_dev.art",
+ ),
+ ),
+ (
+ "member",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="art_contributions",
+ to="game_dev.member",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "Art Contributor",
+ "verbose_name_plural": "Art Contributors",
+ "unique_together": {("art", "member")},
+ },
+ ),
+ ]
diff --git a/server/game_dev/migrations/0006_rename_path_to_media_to_media.py b/server/game_dev/migrations/0006_rename_path_to_media_to_media.py
new file mode 100644
index 00000000..95713557
--- /dev/null
+++ b/server/game_dev/migrations/0006_rename_path_to_media_to_media.py
@@ -0,0 +1,23 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("game_dev", "0005_art_artcontributor"),
+ ]
+
+ operations = [
+ # First, rename the field
+ migrations.RenameField(
+ model_name="art",
+ old_name="path_to_media",
+ new_name="media",
+ ),
+ # Then, alter the field to ImageField
+ migrations.AlterField(
+ model_name="art",
+ name="media",
+ field=models.ImageField(upload_to='art_images/'),
+ ),
+ ]
diff --git a/server/game_dev/migrations/0007_alter_artcontributor_unique_together_and_more.py b/server/game_dev/migrations/0007_alter_artcontributor_unique_together_and_more.py
new file mode 100644
index 00000000..3c917f6b
--- /dev/null
+++ b/server/game_dev/migrations/0007_alter_artcontributor_unique_together_and_more.py
@@ -0,0 +1,33 @@
+# Generated by Django 5.1.15 on 2026-01-16 15:09
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("game_dev", "0006_rename_path_to_media_to_media"),
+ ]
+
+ operations = [
+ migrations.AlterUniqueTogether(
+ name="artcontributor",
+ unique_together=set(),
+ ),
+ migrations.AlterField(
+ model_name="art",
+ name="active",
+ field=models.BooleanField(default=True),
+ ),
+ migrations.AlterField(
+ model_name="art",
+ name="media",
+ field=models.ImageField(upload_to="art/"),
+ ),
+ migrations.AddConstraint(
+ model_name="artcontributor",
+ constraint=models.UniqueConstraint(
+ fields=("art", "member"), name="unique_art_member"
+ ),
+ ),
+ ]
diff --git a/server/game_dev/migrations/0008_merge_20260130_2216.py b/server/game_dev/migrations/0008_merge_20260130_2216.py
new file mode 100644
index 00000000..fda3f25b
--- /dev/null
+++ b/server/game_dev/migrations/0008_merge_20260130_2216.py
@@ -0,0 +1,13 @@
+# Generated by Django 6.0 on 2026-01-30 14:16
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("game_dev", "0005_alter_member_profile_picture"),
+ ("game_dev", "0007_alter_artcontributor_unique_together_and_more"),
+ ]
+
+ operations = []
diff --git a/server/game_dev/migrations/0010_merge_20260131_1145.py b/server/game_dev/migrations/0010_merge_20260131_1145.py
new file mode 100644
index 00000000..b998ef79
--- /dev/null
+++ b/server/game_dev/migrations/0010_merge_20260131_1145.py
@@ -0,0 +1,13 @@
+# Generated by Django 5.1.15 on 2026-01-31 03:45
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("game_dev", "0008_merge_20260130_2216"),
+ ("game_dev", "0009_merge_20260131_1044"),
+ ]
+
+ operations = []
diff --git a/server/game_dev/migrations/0011_merge_20260214_1212.py b/server/game_dev/migrations/0011_merge_20260214_1212.py
new file mode 100644
index 00000000..8568c504
--- /dev/null
+++ b/server/game_dev/migrations/0011_merge_20260214_1212.py
@@ -0,0 +1,13 @@
+# Generated by Django 6.0 on 2026-02-14 04:12
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("game_dev", "0010_merge_20260131_1118"),
+ ("game_dev", "0010_merge_20260131_1145"),
+ ]
+
+ operations = []
diff --git a/server/game_dev/models.py b/server/game_dev/models.py
index d626275d..b1f48ca6 100644
--- a/server/game_dev/models.py
+++ b/server/game_dev/models.py
@@ -118,3 +118,30 @@ def get_member(self):
def __str__(self):
return self.id.name
+
+
+class Art(models.Model):
+ name = models.CharField(null=False, max_length=200)
+ description = models.CharField(max_length=200,)
+ source_game = models.ForeignKey(Game, on_delete=models.CASCADE, related_name='art_pieces')
+ media = models.ImageField(upload_to='art/', null=False)
+ active = models.BooleanField(default=True)
+
+ def __str__(self):
+ return str(self.name)
+
+
+class ArtContributor(models.Model):
+ art = models.ForeignKey('Art', on_delete=models.CASCADE, related_name='contributors')
+ member = models.ForeignKey('Member', on_delete=models.CASCADE, related_name='art_contributions')
+ role = models.CharField(max_length=100)
+
+ class Meta:
+ constraints = [
+ models.UniqueConstraint(fields=['art', 'member'], name='unique_art_member')
+ ]
+ verbose_name = 'Art Contributor'
+ verbose_name_plural = 'Art Contributors'
+
+ def __str__(self):
+ return f"{self.member.name} - {self.art.name} ({self.role})"
diff --git a/server/game_dev/serializers.py b/server/game_dev/serializers.py
index c576aaa3..b2640bc3 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, Game, Member, GameShowcase, GameContributor, SocialMedia
+from .models import Event, Game, Art, ArtContributor, Member, GameShowcase, GameContributor, SocialMedia
class EventSerializer(serializers.ModelSerializer):
@@ -82,6 +82,23 @@ def get_contributors(self, obj):
return ShowcaseContributorSerializer(contributors, many=True).data
+class ArtContributorSerializer(serializers.ModelSerializer):
+ member_name = serializers.CharField(source='member.name', read_only=True)
+ art_id = serializers.IntegerField(source='art.id', read_only=True)
+
+ class Meta:
+ model = ArtContributor
+ fields = ['id', 'art_id', 'member', 'member_name', 'role']
+
+
+class ArtSerializer(serializers.ModelSerializer):
+ contributors = ArtContributorSerializer(many=True, read_only=True)
+
+ class Meta:
+ model = Art
+ fields = ['id', 'name', 'description', 'media', 'active', 'contributors']
+
+
class SocialMediaSerializer(serializers.ModelSerializer):
class Meta:
model = SocialMedia
diff --git a/server/game_dev/views.py b/server/game_dev/views.py
index 086119cf..7ca7efd2 100644
--- a/server/game_dev/views.py
+++ b/server/game_dev/views.py
@@ -1,6 +1,6 @@
from rest_framework import generics
-from .serializers import GamesSerializer, GameshowcaseSerializer, EventSerializer, MemberSerializer
-from .models import Game, GameShowcase, Event, Member, Committee
+from .serializers import GamesSerializer, GameshowcaseSerializer, EventSerializer, MemberSerializer, ArtSerializer
+from .models import Game, GameShowcase, Event, Member, Committee, Art
from django.utils import timezone
from rest_framework.views import APIView
from rest_framework.response import Response
@@ -96,3 +96,14 @@ def get_queryset(self):
except Committee.DoesNotExist:
outputList.append(placeholderMember)
return outputList
+
+
+class ArtDetailAPIView(generics.RetrieveAPIView):
+ """
+ GET /api/artworks//
+ """
+ serializer_class = ArtSerializer
+ lookup_field = "id"
+
+ def get_queryset(self):
+ return Art.objects.all()