diff --git a/client/next.config.mjs b/client/next.config.mjs index 8b6bf03..2873984 100644 --- a/client/next.config.mjs +++ b/client/next.config.mjs @@ -22,6 +22,16 @@ const config = { // pollIntervalMs: 1000 // } // : undefined, + images: { + remotePatterns: [ + { + protocol: 'https', + hostname: '**', + port: '', + pathname: '**' + } + ] + } }; export default config; 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/main/Navbar.tsx b/client/src/components/main/Navbar.tsx index b25a62b..67a0dbe 100644 --- a/client/src/components/main/Navbar.tsx +++ b/client/src/components/main/Navbar.tsx @@ -18,7 +18,7 @@ export default function Navbar() { return ( <> -
+
{ + 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..f3ca51b --- /dev/null +++ b/client/src/components/ui/image-card.tsx @@ -0,0 +1,30 @@ +import Image from "next/image"; +import React from "react"; + +interface ImageCard { + imageSrc?: string; + imageAlt?: string; + children?: React.ReactNode; +} + +const ImageCard = ({ imageSrc, imageAlt = "Image", children }: ImageCard) => { + return ( +
+
+ {imageSrc ? ( + {imageAlt} + ) : ( + children || No Image + )} +
+
+ ); +}; + +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..d5ff49d --- /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/pages/artwork/[id].tsx b/client/src/pages/artwork/[id].tsx new file mode 100644 index 0000000..f61b3ed --- /dev/null +++ b/client/src/pages/artwork/[id].tsx @@ -0,0 +1,219 @@ +import { GetServerSideProps } from "next"; +import Image from "next/image"; +import { useRouter } from "next/navigation"; +import { JSX } from "react"; + +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; +} + +const DISCORD_ICON = ( +
+ + + +
+); +const INSTAGRAM_ICON = ( +
+ + + +
+); + +function iconWithUrl(icon: JSX.Element, url: string) { + return {icon}; +} + +function displayContributors(artwork: Art) { + return ( +
+
+
+
+ Contributors +
+
+
+ {artwork.contributors?.map((contributor) => ( +
+
+ {contributor.member_name} +
+
+ {contributor.discord_url && + iconWithUrl(DISCORD_ICON, contributor.discord_url)} + {contributor.instagram_url && + iconWithUrl(INSTAGRAM_ICON, contributor.instagram_url)} +
+
+ ))} +
+
+
+ ); +} + +export default function ArtworkPage({ artwork, error }: ArtworkPageProps) { + const router = useRouter(); + if (error) { + return router.back()} />; + } + return ( +
+
+
+ +
+
+
+
+ {artwork!.path_to_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..4211949 --- /dev/null +++ b/client/src/pages/artwork/index.tsx @@ -0,0 +1,118 @@ +import { GetServerSideProps } from "next"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; + +import { Button } from "@/components/ui/button"; +import ImageCard from "@/components/ui/image-card"; +import ErrorModal from "@/components/ui/modal/error-modal"; +import api from "@/lib/api"; +import { Art } from "@/types/art"; +import { PageResult } from "@/types/page-response"; + +interface ArtworksPageProps { + artworks?: PageResult; + error?: string; +} + +const PLACEHOLDER_ICON = ( +
+ + + +
+); + +function renderArtworkCard(artwork: Art) { + return ( + + + {!artwork.path_to_media && PLACEHOLDER_ICON} + + + ); +} + +export default function ArtworksPage({ artworks, error }: ArtworksPageProps) { + const router = useRouter(); + if (error) { + return router.back()} />; + } + return ( +
+
+
+ FEATURED: +
+ SOME GAME +
+
+ {PLACEHOLDER_ICON} +
+
+ +
+
+ +
+
+ {artworks!.results.map((artwork) => renderArtworkCard(artwork))} +
+
+
+ TODO add footer +
+
+ ); +} + +export const getServerSideProps: GetServerSideProps< + ArtworksPageProps +> = async () => { + try { + const res = await api.get>("arts"); + return { props: { artworks: res.data } }; + } catch (err: unknown) { + return { + props: { error: (err as Error).message || "Failed to load artworks." }, + }; + } +}; diff --git a/client/src/styles/globals.css b/client/src/styles/globals.css index 6111245..8cf2f38 100644 --- a/client/src/styles/globals.css +++ b/client/src/styles/globals.css @@ -52,6 +52,13 @@ --input: 235 47% 20%; --ring: 236 47% 7%; --radius: 0.5rem; + + --dark-2: #090a19; + --neutral-1: #1b1f4c; + --light-1: #ffffff; + --light-2: #ced1fe; + --light-3: #9ca4fd; + --error: #fa5c5c; } } @@ -60,3 +67,28 @@ @apply bg-background text-foreground; } } + +.bg-neutral-1 { + background-color: var(--neutral-1); +} +.bg-dark-2 { + background-color: var(--dark-2); +} +.bg-light-2 { + background-color: var(--light-2); +} +.text-light-1 { + color: var(--light-1); +} +.outline-neutral-1 { + outline-color: var(--neutral-1); +} +.text-light-3 { + color: var(--light-3); +} +.border-light-2 { + border-color: var(--light-2); +} +.bg-error { + background-color: var(--error); +} diff --git a/client/src/types/art-contributor.ts b/client/src/types/art-contributor.ts new file mode 100644 index 0000000..ed38150 --- /dev/null +++ b/client/src/types/art-contributor.ts @@ -0,0 +1,9 @@ +import { BaseDto } from "./base-dto"; + +export interface ArtContributor extends BaseDto { + art_id: number; + member_name: string; + role: string; + instagram_url?: string; // TODO [HanMinh] to refine where to get these info + discord_url?: string; +} diff --git a/client/src/types/art.ts b/client/src/types/art.ts new file mode 100644 index 0000000..5a5397c --- /dev/null +++ b/client/src/types/art.ts @@ -0,0 +1,10 @@ +import { ArtContributor } from "./art-contributor"; +import { BaseDto } from "./base-dto"; + +export interface Art extends BaseDto { + name: string; + description: string; + path_to_media: string; + active: boolean; + contributors: ArtContributor[]; +} diff --git a/client/src/types/base-dto.ts b/client/src/types/base-dto.ts new file mode 100644 index 0000000..9e3b687 --- /dev/null +++ b/client/src/types/base-dto.ts @@ -0,0 +1,3 @@ +export interface BaseDto { + id: number; +} diff --git a/client/src/types/page-response.ts b/client/src/types/page-response.ts new file mode 100644 index 0000000..e5fa692 --- /dev/null +++ b/client/src/types/page-response.ts @@ -0,0 +1,6 @@ +export interface PageResult { + count: number; + next: string; + previous: string; + results: T[]; +} diff --git a/server/api/settings.py b/server/api/settings.py index 424f34e..4690275 100644 --- a/server/api/settings.py +++ b/server/api/settings.py @@ -49,6 +49,7 @@ "django.contrib.messages", "django.contrib.staticfiles", "django_extensions", + "django_filters", "rest_framework", "corsheaders", "api.healthcheck", @@ -153,3 +154,8 @@ MEDIA_URL = "/media/" DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +REST_FRAMEWORK = { + 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', + 'PAGE_SIZE': 100, +} 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/models.py b/server/game_dev/models.py index 6398070..abf0ff8 100644 --- a/server/game_dev/models.py +++ b/server/game_dev/models.py @@ -22,3 +22,28 @@ 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 + path_to_media = models.CharField(null=False, max_length=500) + active = models.BooleanField(null=False) + + 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: + unique_together = ('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 c7f6b77..d917397 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 +from .models import Event, Art, ArtContributor, Member class EventSerializer(serializers.ModelSerializer): @@ -14,3 +14,25 @@ class Meta: "cover_image", "location", ] + + +class ArtContributorSerializer(serializers.ModelSerializer): + member_name = serializers.CharField(source='member.name', read_only=True) + + class Meta: + model = ArtContributor + fields = ['id', 'art', 'member', 'member_name', 'role'] + + +class ArtSerializer(serializers.ModelSerializer): + contributors = ArtContributorSerializer(many=True, read_only=True) + + class Meta: + model = Art + fields = ['id', 'name', 'description', 'path_to_media', 'active', 'contributors'] + + +class MemberSerializer(serializers.ModelSerializer): + class Meta: + model = Member + fields = ['name'] diff --git a/server/game_dev/urls.py b/server/game_dev/urls.py index e387e58..5855969 100644 --- a/server/game_dev/urls.py +++ b/server/game_dev/urls.py @@ -1,6 +1,13 @@ -from django.urls import path -from .views import EventDetailAPIView +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .views import EventDetailAPIView, ArtContributorViewSet, ArtViewSet, MemberViewSet + +router = DefaultRouter() +router.register(r'art-contributors', ArtContributorViewSet, basename='artcontributor') +router.register(r'arts', ArtViewSet, basename="art") +router.register(r'members', MemberViewSet, basename="member") urlpatterns = [ path("events//", EventDetailAPIView.as_view()), + path('', include(router.urls)), ] diff --git a/server/game_dev/views.py b/server/game_dev/views.py index 71a747c..313093f 100644 --- a/server/game_dev/views.py +++ b/server/game_dev/views.py @@ -1,9 +1,10 @@ # from django.shortcuts import render # Create your views here. -from rest_framework import generics -from .models import Event -from .serializers import EventSerializer +from rest_framework import generics, viewsets +from .models import Event, Art, ArtContributor, Member +from .serializers import EventSerializer, ArtContributorSerializer, ArtSerializer, MemberSerializer +from django_filters.rest_framework import DjangoFilterBackend class EventDetailAPIView(generics.RetrieveAPIView): @@ -15,3 +16,20 @@ class EventDetailAPIView(generics.RetrieveAPIView): def get_queryset(self): return Event.objects.filter(id=self.kwargs["id"]) + + +class ArtContributorViewSet(viewsets.ModelViewSet): + queryset = ArtContributor.objects.all() + serializer_class = ArtContributorSerializer + filter_backends = [DjangoFilterBackend] + filterset_fields = ['art'] + + +class ArtViewSet(viewsets.ModelViewSet): + queryset = Art.objects.all() + serializer_class = ArtSerializer + + +class MemberViewSet(viewsets.ModelViewSet): + queryset = Member.objects.all() + serializer_class = MemberSerializer diff --git a/server/poetry.lock b/server/poetry.lock index 9e7859f..202b5e9 100644 --- a/server/poetry.lock +++ b/server/poetry.lock @@ -96,6 +96,21 @@ files = [ [package.dependencies] Django = ">=3.2" +[[package]] +name = "django-filter" +version = "24.3" +description = "Django-filter is a reusable Django application for allowing users to filter querysets dynamically." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "django_filter-24.3-py3-none-any.whl", hash = "sha256:c4852822928ce17fb699bcfccd644b3574f1a2d80aeb2b4ff4f16b02dd49dc64"}, + {file = "django_filter-24.3.tar.gz", hash = "sha256:d8ccaf6732afd21ca0542f6733b11591030fa98669f8d15599b358e24a2cd9c3"}, +] + +[package.dependencies] +Django = ">=4.2" + [[package]] name = "djangorestframework" version = "3.15.2" @@ -603,4 +618,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = "^3.12" -content-hash = "f804c2f3998772b91e34ad214e5fcafe900bec97675f73046d3bcc79aba0f7db" +content-hash = "3056497732593ecab7864ba1cc8d96295741aeeafedf53d57c757311f6a7e009" diff --git a/server/pyproject.toml b/server/pyproject.toml index 5ec547c..fb713c8 100644 --- a/server/pyproject.toml +++ b/server/pyproject.toml @@ -16,6 +16,7 @@ gunicorn = "^23.0.0" python-dotenv = "^1.0.1" django-extensions = "^3.2.3" pillow = "^11.3.0" +django-filter = "^24.3" [tool.poetry.group.dev.dependencies]