diff --git a/client/next.config.mjs b/client/next.config.mjs
index 8b6bf03..c8a0f71 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,
@@ -14,14 +8,15 @@ const config = {
outputFileTracingRoot: import.meta.dirname,
images: {
domains: ["localhost"],
+ remotePatterns: [
+ {
+ protocol: 'http',
+ hostname: 'localhost',
+ port: '8000',
+ pathname: '/media/**',
+ },
+ ],
},
- // 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.
- // watchOptions: isWindowsDevContainer()
- // ? {
- // pollIntervalMs: 1000
- // }
- // : undefined,
};
-export default config;
+export default nextConfig;
diff --git a/client/public/placeholder1293x405.svg b/client/public/placeholder1293x405.svg
new file mode 100644
index 0000000..34f928c
--- /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 0000000..5f1ebce
--- /dev/null
+++ b/client/src/components/ui/go-back-button.tsx
@@ -0,0 +1,38 @@
+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 0000000..32fba7e
--- /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 0000000..b7e25e5
--- /dev/null
+++ b/client/src/components/ui/image-placeholder.tsx
@@ -0,0 +1,26 @@
+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 0000000..ba1c240
--- /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 0000000..60a65ad
--- /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 0000000..064e8e1
--- /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 0000000..d8d0b38
--- /dev/null
+++ b/client/src/pages/artwork/index.tsx
@@ -0,0 +1,143 @@
+import { GetServerSideProps } from "next";
+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 0000000..8afd03b
--- /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 0000000..ebb22e1
--- /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 52306b8..7698c77 100644
--- a/client/tailwind.config.ts
+++ b/client/tailwind.config.ts
@@ -21,7 +21,7 @@ const config = {
extend: {
fontFamily: {
sans: ["var(--font-sans)", ...fontFamily.sans],
- jersey10: ["var(--font-jersey10)", ...fontFamily.sans],
+ jersey10: ["Jersey 10", ...fontFamily.sans],
firaCode: ["var(--font-firaCode)", ...fontFamily.sans],
},
@@ -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 46d358c..c2d8a5e 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, Event
+from .models import Art, ArtContributor, Member, Event
class MemberAdmin(admin.ModelAdmin):
@@ -12,3 +12,5 @@ class EventAdmin(admin.ModelAdmin):
admin.site.register(Member, MemberAdmin)
admin.site.register(Event, EventAdmin)
+admin.site.register(Art)
+admin.site.register(ArtContributor)
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 0000000..f3d0c90
--- /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 0000000..9571355
--- /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 0000000..3c917f6
--- /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/models.py b/server/game_dev/models.py
index 6398070..58c2e1f 100644
--- a/server/game_dev/models.py
+++ b/server/game_dev/models.py
@@ -22,3 +22,30 @@ class Event(models.Model):
def __str__(self):
return self.name
+
+
+class Art(models.Model):
+ name = models.CharField(null=False, max_length=200)
+ description = models.CharField(max_length=200,)
+ # source_game = models.ForeignKey(Games, on_delete=models.CASCADE, related_name='art_pieces') #Need implement Games model
+ 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 b50638d..d4715fa 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, Art, ArtContributor, Member
class EventSerializer(serializers.ModelSerializer):
@@ -16,6 +16,23 @@ class Meta:
]
+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 MemberSerializer(serializers.ModelSerializer):
class Meta:
model = Member
diff --git a/server/game_dev/views.py b/server/game_dev/views.py
index 89ce3a9..b470e87 100644
--- a/server/game_dev/views.py
+++ b/server/game_dev/views.py
@@ -2,8 +2,8 @@
# Create your views here.
from rest_framework import generics
-from .models import Event
-from .serializers import EventSerializer
+from .models import Event, Art
+from .serializers import EventSerializer, ArtSerializer
from django.utils import timezone
from rest_framework.exceptions import ValidationError
from rest_framework.pagination import PageNumberPagination
@@ -51,3 +51,14 @@ class EventDetailAPIView(generics.RetrieveAPIView):
def get_queryset(self):
return Event.objects.filter(id=self.kwargs["id"])
+
+
+class ArtDetailAPIView(generics.RetrieveAPIView):
+ """
+ GET /api/artworks//
+ """
+ serializer_class = ArtSerializer
+ lookup_url_kwarg = "id"
+
+ def get_queryset(self):
+ return Art.objects.filter(id=self.kwargs["id"])
diff --git a/server/poetry.lock b/server/poetry.lock
index 9e7859f..3c9c870 100644
--- a/server/poetry.lock
+++ b/server/poetry.lock
@@ -603,4 +603,4 @@ files = [
[metadata]
lock-version = "2.1"
python-versions = "^3.12"
-content-hash = "f804c2f3998772b91e34ad214e5fcafe900bec97675f73046d3bcc79aba0f7db"
+content-hash = "f804c2f3998772b91e34ad214e5fcafe900bec97675f73046d3bcc79aba0f7db"
\ No newline at end of file