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 ? ( + <> + {imageAlt} + {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 ( +
+
+
+
+ Contributors +
+
+
+ {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 image + ) : ( + + )} +
+
+
+
+ {artwork!.name} +
+
+
+ + {artwork!.description} + +
+
+ {displayContributors(artwork!)} +
+
+
+
+
+ {artwork!.name} +
+
+
+ + {artwork!.description} + +
+
+ {displayContributors(artwork!)} +
+ +
+
+ Game Image +
+
+
+ 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