diff --git a/client/documentation/admin-dashboard/committee.md b/client/documentation/admin-dashboard/committee.md index 0dc62436..ffd30ac8 100644 --- a/client/documentation/admin-dashboard/committee.md +++ b/client/documentation/admin-dashboard/committee.md @@ -1,6 +1,6 @@ ## Committee Members -URL: `/admin/game_dev/committee/add/` +URL: `/admin/game_dev/committee/` Profiles of the Committee Members of the club that are displayed on the about page `/about`. diff --git a/client/documentation/admin-dashboard/contributors.md b/client/documentation/admin-dashboard/contributors.md index 161c8f75..0f49a42a 100644 --- a/client/documentation/admin-dashboard/contributors.md +++ b/client/documentation/admin-dashboard/contributors.md @@ -4,6 +4,7 @@ Models that associate a member object with another certain object as a contribut ## Game Contributor +URL: `/admin/game_dev/gamecontributor/` A model that will associate a certain member with having contributed to a certain game. A Game Contributor object will be represented under the 'Contributors' section of a game's page as the name of the member who contributed. ## Fields @@ -18,19 +19,15 @@ A model that will associate a certain member with having contributed to a certai You will see a little magnifying glass when selecting a Game and Member, click on it to see their respective table and be able choose the integer id's using the desired details. Though none of the individual fields are a primary key, the actual primary key is the unique (Game, Member) pair that each object forms. - ## Art Contributor +- This can be found inline inside of an art entry, e.g. `/admin/game_dev/art/1/change/`. + A model that will associate a certain member with having contributed to a certain artwork. An Art Contributor object will be represented under the 'Contributors' section of an artworks's page as the name of the member who contributed. ## Fields -**Art:** Required field for the artwork that the contributor is for. It is an integer field that corresponds to the raw integer id of an Art object in the Art table, known as a Foreign Key. - **Member:** Required field for the member that is the contributor to the specified artwork. It is an integer field that corresponds to the raw integer id of a Member object in the Member table, known as a Foreign Key. **Role:** Required character field for the description of the role that the person played in contributing to the artwork. Maximum length of 100 characters -## Other Notes - -You will see a little magnifying glass when selecting an Art and a Member, click on it to see their respective table and be able choose the integer id's using the desired details. Though none of the individual fields are a primary key, the actual primary key is the unique (Art, Member) pair that each object forms. \ No newline at end of file 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/package-lock.json b/client/package-lock.json index 37ad83cd..8e49f943 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -22,6 +22,7 @@ "react": "19.1.0", "react-dom": "19.1.0", "react-social-icons": "^6.25.0", + "react-use-measure": "^2.1.7", "tailwind-merge": "^3.3.1", "tailwindcss-animate": "^1.0.7" }, @@ -5849,6 +5850,21 @@ "react-dom": "16.x.x || 17.x.x || 18.x.x || 19.x.x" } }, + "node_modules/react-use-measure": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz", + "integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.13", + "react-dom": ">=16.13" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", diff --git a/client/package.json b/client/package.json index ad2abd60..56933ded 100644 --- a/client/package.json +++ b/client/package.json @@ -32,6 +32,7 @@ "react": "19.1.0", "react-dom": "19.1.0", "react-social-icons": "^6.25.0", + "react-use-measure": "^2.1.7", "tailwind-merge": "^3.3.1", "tailwindcss-animate": "^1.0.7" }, 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/scripts/open-when-ready.js b/client/scripts/open-when-ready.js deleted file mode 100644 index e69de29b..00000000 diff --git a/client/src/components/main/Footer.tsx b/client/src/components/main/Footer.tsx index 3eeef5c1..645388d7 100644 --- a/client/src/components/main/Footer.tsx +++ b/client/src/components/main/Footer.tsx @@ -7,7 +7,6 @@ import { Home, Map, Palette, - Sparkles, Users, } from "lucide-react"; import Image from "next/image"; @@ -120,7 +119,7 @@ export default function Footer() { ease: "easeInOut", }} > - +
diff --git a/client/src/components/main/MemberProfile.tsx b/client/src/components/main/MemberProfile.tsx deleted file mode 100644 index c66780b3..00000000 --- a/client/src/components/main/MemberProfile.tsx +++ /dev/null @@ -1,117 +0,0 @@ -"use client"; - -import { Palette, Sparkles } from "lucide-react"; -import Image from "next/image"; -import { SocialIcon } from "react-social-icons"; - -import MemberProjectSection from "../ui/MemberProjectSection"; - -export type MemberProfileData = { - name: string; - about: string; - pronouns?: string; - profile_picture?: string; - social_media?: { - link: string; - socialMediaUserName: string; - }[]; - pk: number; -}; - -type MemberProfileProps = { - member: MemberProfileData; -}; - -function initialsFromName(name: string) { - return name - .trim() - .split(/\s+/) - .slice(0, 2) - .map((part) => part[0]?.toUpperCase()) - .join(""); -} - -export function MemberProfile({ member }: MemberProfileProps) { - const initials = initialsFromName(member.name); - - return ( - <> -
-
-
-
- {member.profile_picture ? ( - {`${member.name} - ) : ( -
-

{initials}

-
- )} -
- golden pixel art frame around profile picture -
-
-
-

{member.name}

-
-
-
- {member.social_media && member.social_media.length > 0 && ( -
-
- {member.social_media.map((sm) => ( - - - - {sm.socialMediaUserName} - - - ))} -
-
- )} -
-
-

{member.pronouns}

-
- -

{member.about}

-
-
-
-
-

- Games - -

- -

- Artwork - -

-
- - ); -} diff --git a/client/src/components/ui/ContinuousCarousel.tsx b/client/src/components/ui/ContinuousCarousel.tsx new file mode 100644 index 00000000..0903a0ab --- /dev/null +++ b/client/src/components/ui/ContinuousCarousel.tsx @@ -0,0 +1,116 @@ +import { + animate, + AnimationPlaybackControls, + motion, + useMotionValue, +} from "framer-motion"; +import { useCallback, useEffect, useRef } from "react"; +import useMeasure from "react-use-measure"; + +import ImageCard from "@/components/ui/ImageCard/ImageCard"; +import { Art } from "@/types/art"; + +import ImageCardBack from "./ImageCard/ImageCardBack"; + +const GAP = 16; +const DURATION = 90; + +// modifying this to accept anything with a name, src, and id should be trivial. +export default function ContinuousCarousel(artworks: Art[], reverse = false) { + const [ref, { width }] = useMeasure(); + const xTranslation = useMotionValue(0); + + const controlsRef = useRef(null); + + const startAnimation = useCallback( + (from: number) => { + const finalPosition = -(width + GAP) / 3; + const rangeSize = -finalPosition; + + controlsRef.current?.stop(); + + // Normalize `from` into [finalPosition, 0) so the loop restarts cleanly + const normalizedFrom = -(((-from % rangeSize) + rangeSize) % rangeSize); + xTranslation.set(normalizedFrom); + + const target = reverse ? 0 : finalPosition; + const loopStart = reverse ? finalPosition : 0; + + const startFullLoop = () => { + const controls = animate(xTranslation, [loopStart, target], { + ease: "linear", + duration: DURATION, + repeat: Infinity, + repeatType: "loop", + repeatDelay: 0, + }); + controlsRef.current = controls; + }; + + // if we're already at the target end, jump to loop start and begin + const remaining = Math.abs(target - normalizedFrom); + if (remaining < 0.5) { + xTranslation.set(loopStart); + startFullLoop(); + return; + } + + // animate the rest of this cycle, then resume full loops + const partialDuration = DURATION * (remaining / rangeSize); + const controls = animate(xTranslation, target, { + ease: "linear", + duration: partialDuration, + onComplete: startFullLoop, + }); + controlsRef.current = controls; + }, + [xTranslation, width, reverse], + ); + + useEffect(() => { + startAnimation(0); + return () => { + controlsRef.current?.stop(); + }; + }, [startAnimation]); + + const items = artworks ?? []; + return ( +
+ controlsRef.current?.stop()} + onPan={(_, info) => { + const rangeSize = (width + GAP) / 3; + const next = xTranslation.get() + info.delta.x; + const normalized = -(((-next % rangeSize) + rangeSize) % rangeSize); + xTranslation.set(normalized); + }} + onHoverEnd={() => startAnimation(xTranslation.get())} + > + {/* we need three copies to make sure it doesn't randomly snap incorrectly... issues may arise on huge display resolutions */} + {[...items, ...items, ...items].map((item: Art, i: number) => ( + + } + /> + + ))} + +
+ ); +} diff --git a/client/src/components/ui/ContributorsList.tsx b/client/src/components/ui/ContributorsList.tsx new file mode 100644 index 00000000..70be6fe9 --- /dev/null +++ b/client/src/components/ui/ContributorsList.tsx @@ -0,0 +1,36 @@ +import Link from "next/link"; + +import { ArtContributor } from "@/types/art-contributor"; + +interface ContributorsListProps { + contributors: ArtContributor[]; +} + +export default function ContributorsList({ + contributors, +}: ContributorsListProps) { + if (contributors.length === 0) { + return null; + } + + return ( + <> +
Contributors
+
+ {contributors.map((contributor) => ( +
+ e.stopPropagation()} + > + {contributor.member_name} + + {" - "} + {contributor.role} +
+ ))} +
+ + ); +} diff --git a/client/src/components/ui/GameArtCarousel.tsx b/client/src/components/ui/GameArtCarousel.tsx new file mode 100644 index 00000000..f507e56e --- /dev/null +++ b/client/src/components/ui/GameArtCarousel.tsx @@ -0,0 +1,122 @@ +// This carousel is for Artworks to be displayed in the Gameshowcase + +import { ChevronLeft, ChevronRight } from "lucide-react"; +import Image from "next/image"; +import Link from "next/link"; +import { useEffect, useRef, useState } from "react"; + +import type { UiArtwork } from "@/hooks/useGames"; + +type GameArtCarouselProps = { + items: UiArtwork[]; +}; + +const GAP = 20; +const maxItemsPerPage = 4; + +export default function GameArtCarousel({ items }: GameArtCarouselProps) { + const firstItemRef = useRef(null); + + const [currentIndex, setCurrentIndex] = useState(0); + const [itemWidth, setItemWidth] = useState(0); + const [visibleCount, setVisibleCount] = useState(maxItemsPerPage); + + const maxIndex = Math.max(items.length - visibleCount, 0); + + const slideLeft = () => { + setCurrentIndex((prev) => Math.max(prev - 1, 0)); + }; + + const slideRight = () => { + setCurrentIndex((prev) => Math.min(prev + 1, maxIndex)); + }; + + const translateX = -(currentIndex * (itemWidth + GAP)); + + useEffect(() => { + if (!firstItemRef.current) return; + + const observer = new ResizeObserver(() => { + const width = firstItemRef.current?.clientWidth ?? 0; + setItemWidth(width); + }); + + observer.observe(firstItemRef.current); + + return () => observer.disconnect(); + }, []); + + useEffect(() => { + const updateVisibleCount = () => { + if (window.innerWidth < 768) { + setVisibleCount(1); + } else { + setVisibleCount(maxItemsPerPage); + } + }; + + updateVisibleCount(); + window.addEventListener("resize", updateVisibleCount); + + return () => window.removeEventListener("resize", updateVisibleCount); + }, []); + + return ( +
+
+ {/* LEFT ARROW */} + + + {/* VIEWPORT */} +
+
+ {items.map((art, index) => ( +
+ +
+ {art.name} +
+ + +

{art.name}

+
+ ))} +
+
+ + {/* RIGHT ARROW */} + +
+
+ ); +} diff --git a/client/src/components/ui/ImageCard/ImageCard.tsx b/client/src/components/ui/ImageCard/ImageCard.tsx new file mode 100644 index 00000000..cc0e0d4c --- /dev/null +++ b/client/src/components/ui/ImageCard/ImageCard.tsx @@ -0,0 +1,120 @@ +import Image from "next/image"; +import { useRouter } from "next/router"; +import React from "react"; + +interface ImageCardProps { + imageSrc?: string; + imageAlt?: string; + children?: React.ReactNode; + backContent?: React.ReactNode; + href?: string; + disableFlip?: boolean; + placeholder?: React.ReactNode; +} + +const ImageCard = ({ + imageSrc, + imageAlt = "Game Artwork", + children, + backContent, + href, + disableFlip = false, + placeholder, +}: ImageCardProps) => { + const router = useRouter(); + const [isFlipped, setIsFlipped] = React.useState(false); + const [isMobile, setIsMobile] = React.useState(false); + const [hasImageError, setHasImageError] = 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 && !disableFlip && !hasImageError) { + // On desktop, toggle flip state + setIsFlipped(!isFlipped); + } + }; + + return ( +
+
+
+
+ {imageSrc && !hasImageError ? ( + <> + {imageAlt} { + setHasImageError(true); + setIsFlipped(false); + }} + /> + {children && ( +
+ {children} +
+ )} + + ) : ( +
+ {placeholder || children || ( + No Image + )} +
+ )} +
+
+ + {backContent && ( +
+ {backContent} +
+ )} +
+
+ ); +}; + +export default ImageCard; diff --git a/client/src/components/ui/ImageCard/ImageCardBack.tsx b/client/src/components/ui/ImageCard/ImageCardBack.tsx new file mode 100644 index 00000000..ff2658e2 --- /dev/null +++ b/client/src/components/ui/ImageCard/ImageCardBack.tsx @@ -0,0 +1,66 @@ +import Link from "next/link"; + +import { Art } from "@/types/art"; + +export default function ImageCardBack({ artwork }: { artwork: Art }) { + return ( +
+
+

+ {artwork.name} +

+

+ {artwork.source_game_name ? ( + <> + from{" "} + e.stopPropagation()} + > + {artwork.source_game_name} + + + ) : ( + "No associated game" + )} +

+ {/*

*/} + {/* {artwork.description || "No description available."} */} + {/*

*/} +
+ + {artwork.contributors.length > 0 && ( +
+

+ Contributed by: +

+
+ {artwork.contributors.map((contributor) => ( +
+ e.stopPropagation()} + > + {contributor.member_name} + +
+ ))} +
+
+ )} + + e.stopPropagation()} + > + View full details + +
+ ); +} diff --git a/client/src/components/ui/MemberProjectSection.tsx b/client/src/components/ui/MemberProjectSection.tsx index 046ca556..fad1ca31 100644 --- a/client/src/components/ui/MemberProjectSection.tsx +++ b/client/src/components/ui/MemberProjectSection.tsx @@ -1,75 +1,103 @@ -import { ArrowUpRight } from "lucide-react"; +import { ArrowUpRight, Gamepad2, Palette } from "lucide-react"; import Image from "next/image"; import Link from "next/link"; import React from "react"; -import { useContributor } from "@/hooks/useContributor"; - -type MemberProjectSectionProps = { - id: string; -}; - -export default function MemberProjectSection(props: MemberProjectSectionProps) { - const { data: games, isError, error } = useContributor(props.id); - - { - /* Error handling from Games Showcase page */ - } - if (isError) { - const errorMessage = - error?.response?.status === 404 - ? "Games not found." - : "Failed to Load Games"; - return ( -
-

- {errorMessage} -

-
- ); - } +import type { ArtData, GameData } from "@/hooks/useContributor"; +export default function MemberProjectSection(props: { + games: GameData[]; + art: ArtData[]; +}) { + const { games, art } = props; return ( -
- {!games || games.length === 0 ? ( -

- No games available. -

- ) : ( -
- {games.map((game) => ( - -
-
- {`${game.game_data.name} - window.open(`/games/${game.game_id}`)} - > - Visit Game - + <> +

+ Games + +

+
+ {!games || games.length === 0 ? ( +

+ No games available. +

+ ) : ( +
+ {games.map((game) => ( + +
+
+ {`${game.game_data.name} + window.open(`/games/${game.game_id}`)} + > + Visit work + +
+

+ {game.game_data.name} +

+

+ {game.game_data.description} +

-

- {game.game_data.name} -

-

- {game.game_data.description} -

-
- - ))} -
- )} -
+ + ))} +
+ )} +
+

+ Artwork + +

+
+ {!art || art.length === 0 ? ( +

+ No games available. +

+ ) : ( +
+ {art.map((artwork) => ( + +
+
+ {`${artwork.artwork_data.name} + window.open(`/artwork/${artwork.art_id}`)} + > + Visit work + +
+

+ {artwork.artwork_data.name} +

+

+ {artwork.artwork_data.description} +

+
+
+ ))} +
+ )} +
+ ); } diff --git a/client/src/components/ui/button.tsx b/client/src/components/ui/button.tsx index 2e263a18..74eb69d8 100644 --- a/client/src/components/ui/button.tsx +++ b/client/src/components/ui/button.tsx @@ -24,6 +24,7 @@ const buttonVariants = cva( sm: "h-9 rounded-md px-3", lg: "h-11 rounded-md px-8", icon: "h-10 w-10", + leftIcon: "h-10 ps-2 pe-4 py-2 flex gap-2", }, }, defaultVariants: { 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..69d7a741 --- /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/useCommittee.ts b/client/src/hooks/useCommittee.ts index 5759947d..74a545f2 100644 --- a/client/src/hooks/useCommittee.ts +++ b/client/src/hooks/useCommittee.ts @@ -20,7 +20,6 @@ export function useCommittee() { queryKey: ["role"], queryFn: async () => { const response = await api.get("/about/"); - console.log(response.data); return response.data; }, retry: (failureCount, error) => { diff --git a/client/src/hooks/useContributor.ts b/client/src/hooks/useContributor.ts index d29e7843..fe4a44b5 100644 --- a/client/src/hooks/useContributor.ts +++ b/client/src/hooks/useContributor.ts @@ -1,27 +1,57 @@ -import { useQuery } from "@tanstack/react-query"; +import { useQueries, UseQueryResult } from "@tanstack/react-query"; import { AxiosError } from "axios"; import api from "@/lib/api"; -type ApiContributorGameData = { - name: string; - thumbnail: string; - description: string; -}; - -type ApiContributorGamesList = { +// unused fields are ommitted because I'm lazy +export type GameData = { game_id: number; role: string; - game_data: ApiContributorGameData; + game_data: { + name: string; + description: string; + thumbnail: string | null; + }; +}; + +export type ArtData = { + art_id: number; + artwork_data: { + name: string; + description: string; + media: string | null; + }; +}; + +type UseContributorResult = { + gamesRes: UseQueryResult; + artRes: UseQueryResult; }; -export const useContributor = (member: string | string[] | undefined) => { - return useQuery({ - queryKey: ["contributor", member], - queryFn: async () => { - const response = await api.get(`/games/contributor/${member}/`); - return response.data; - }, - enabled: !!member, - }); +export const useContributor = ( + member: number | string[] | undefined, +): UseContributorResult => { + const [gamesRes, artRes] = useQueries({ + queries: [ + { + queryKey: ["gamecontributor", member], + queryFn: async () => { + const response = await api.get(`/games/contributor/${member}/`); + return response.data; + }, + enabled: !!member, + }, + { + queryKey: ["artcontributor", member], + queryFn: async () => { + const response = await api.get(`/arts/contributor/${member}/`); + return response.data; + }, + }, + ], + }) as [ + UseQueryResult, + UseQueryResult, + ]; + return { gamesRes, artRes }; }; diff --git a/client/src/hooks/useGames.ts b/client/src/hooks/useGames.ts index 758e9c76..33ff849f 100644 --- a/client/src/hooks/useGames.ts +++ b/client/src/hooks/useGames.ts @@ -14,6 +14,21 @@ type Contributor = { }>; }; +export type UiArtwork = { + id: number; + name: string; + image: string; + sourceGameId: number; +}; + +export type ApiArtworks = { + art_id: number; + name: string; + media: string; + active: boolean; + source_game_id: number; +}; + type ApiGame = { name: string; description: string; @@ -28,10 +43,12 @@ type ApiGame = { itchGameWidth: number; itchGameHeight: number; contributors: Contributor[]; + artworks: ApiArtworks[]; }; -type UiGame = Omit & { +type UiGame = Omit & { gameCover: string; + artworks: UiArtwork[]; }; /** @@ -49,6 +66,12 @@ function transformApiGameToUiGame(data: ApiGame): UiGame { return { ...data, gameCover: data.thumbnail ?? "/game_dev_club_logo.svg", + artworks: data.artworks.map((a) => ({ + id: a.art_id, + name: a.name, + image: a.media, + sourceGameId: a.source_game_id, + })), }; } diff --git a/client/src/hooks/useGameshowcase.ts b/client/src/hooks/useGameshowcase.ts index fc515a97..a879dcae 100644 --- a/client/src/hooks/useGameshowcase.ts +++ b/client/src/hooks/useGameshowcase.ts @@ -1,6 +1,7 @@ import { useQuery } from "@tanstack/react-query"; import { AxiosError } from "axios"; +import type { ApiArtworks, UiArtwork } from "@/hooks/useGames"; import api from "@/lib/api"; type Contributor = { @@ -20,16 +21,27 @@ type ApiShowcaseGame = { game_description: string; contributors: Contributor[]; game_cover_thumbnail?: string | null; + artworks: ApiArtworks[]; }; -type UiShowcaseGame = Omit & { +type UiShowcaseGame = Omit< + ApiShowcaseGame, + "game_cover_thumbnail" | "artworks" +> & { gameCover: string; + artworks: UiArtwork[]; }; function transformApiShowcaseGameToUi(data: ApiShowcaseGame): UiShowcaseGame { return { ...data, gameCover: data.game_cover_thumbnail ?? "/game_dev_club_logo.svg", + artworks: data.artworks.map((a) => ({ + id: a.art_id, + name: a.name, + image: a.media, + sourceGameId: a.source_game_id, + })), }; } diff --git a/client/src/hooks/useMember.ts b/client/src/hooks/useMember.ts index 0d1f0581..53a48f34 100644 --- a/client/src/hooks/useMember.ts +++ b/client/src/hooks/useMember.ts @@ -13,9 +13,8 @@ type ApiMember = { }[]; pk: number; }; - -// return api member, import id number from router, is not enabled if not a number type -export const useMember = (id?: number) => { +// should be called strictly with member's integer uuid. +export function useMember(id: number | undefined) { return useQuery({ queryKey: ["member", id], queryFn: async () => { @@ -24,4 +23,4 @@ export const useMember = (id?: number) => { }, enabled: Number.isFinite(id), }); -}; +} diff --git a/client/src/hooks/useRedirectOn404.ts b/client/src/hooks/useRedirectOn404.ts new file mode 100644 index 00000000..44a0abb3 --- /dev/null +++ b/client/src/hooks/useRedirectOn404.ts @@ -0,0 +1,10 @@ +import { AxiosError } from "axios"; +import { useRouter } from "next/router"; +import { useEffect } from "react"; + +export function useRedirectOn404(error?: AxiosError | null) { + const router = useRouter(); + useEffect(() => { + if (error?.response?.status == 404) router.push("/404"); + }, [error, router]); +} diff --git a/client/src/pages/artwork/[id].tsx b/client/src/pages/artwork/[id].tsx new file mode 100644 index 00000000..d92927c9 --- /dev/null +++ b/client/src/pages/artwork/[id].tsx @@ -0,0 +1,78 @@ +import { useQuery } from "@tanstack/react-query"; +import { AxiosError } from "axios"; +import { ArrowLeft, Loader2 } from "lucide-react"; +import Image from "next/image"; +import Link from "next/link"; +import { useRouter } from "next/router"; + +import { Button } from "@/components/ui/button"; +import ContributorsList from "@/components/ui/ContributorsList"; +import { useRedirectOn404 } from "@/hooks/useRedirectOn404"; +import api from "@/lib/api"; +import { Art } from "@/types/art"; + +export default function ArtworkPage() { + const router = useRouter(); + const { id } = router.query; + + const { + data: artwork, + error, + isLoading, + } = useQuery({ + queryKey: ["art", id], + queryFn: async () => { + const response = await api.get(`/arts/${id}/`); + return response.data; + }, + enabled: !!id, + retry: (failureCount, error) => { + if (error?.response?.status === 404) { + return false; + } + return failureCount < 3; + }, + }); + + useRedirectOn404(error); + + if (isLoading || !artwork) + return ( +
+ + Fetching artwork..... If you get stuck here, please send us a message! + :) +
+ ); + + return ( +
+ + + +
+
+ Artwork image +
+ +
+

+ {artwork.name} +

+

{artwork.description}

+ + +
+
+
+ ); +} diff --git a/client/src/pages/artwork/index.tsx b/client/src/pages/artwork/index.tsx new file mode 100644 index 00000000..eaeaf563 --- /dev/null +++ b/client/src/pages/artwork/index.tsx @@ -0,0 +1,88 @@ +import { GetServerSideProps } from "next"; + +import ContinuousCarousel from "@/components/ui/ContinuousCarousel"; +import api from "@/lib/api"; +import { Art } from "@/types/art"; + +export interface PageResult { + count: number; + next: string; + previous: string; + results: T[]; +} +interface ArtworksPageProps { + carousels?: Art[][]; + error?: string; +} + +function hasResultsArray(value: unknown): value is { results: T[] } { + if (typeof value !== "object" || value === null) return false; + + const v = value as Record; + return Array.isArray(v.results); +} + +// Durstenfeld shuffle +function shuffleArray(arr: T[]) { + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + const temp = arr[i]; + arr[i] = arr[j]; + arr[j] = temp; + } +} + +export default function FeaturedArtwork({ carousels = [] }: ArtworksPageProps) { + return ( +
+
+

+ Featured Artwork +

+

+ Some of our favourite art from our members' games! +
+ Click on an artwork to see more... +

+
+ +
+ {carousels.map((items, i) => ContinuousCarousel(items, i % 2 === 0))} +
+
+ ); +} + +export const getServerSideProps: GetServerSideProps< + ArtworksPageProps +> = async () => { + try { + const res = await api.get("arts/featured"); + const data = res.data as unknown; + + // Accept either: PageResult OR Art[] + const results: Art[] | null = Array.isArray(data) + ? (data as Art[]) + : hasResultsArray(data) + ? data.results + : null; + + // If API didn't throw but returned an unexpected shape, trigger fallback + if (!results) throw new Error("Invalid arts/featured response shape"); + + const carousels = [0, 1, 2].map(() => { + const copy = structuredClone(results); + shuffleArray(copy); + return copy; + }); + + return { props: { carousels } }; + } catch (err) { + return { + props: { + carousels: [], + error: err instanceof Error ? err.message : undefined, + }, + }; + } +}; diff --git a/client/src/pages/events/[id].tsx b/client/src/pages/events/[id].tsx index ec53f25a..80e8513b 100644 --- a/client/src/pages/events/[id].tsx +++ b/client/src/pages/events/[id].tsx @@ -3,6 +3,7 @@ import { useRouter } from "next/router"; import { EventDateDisplay } from "@/components/ui/EventDateDisplay"; import { useEvent } from "@/hooks/useEvent"; +import { useRedirectOn404 } from "@/hooks/useRedirectOn404"; export default function EventPage() { const router = useRouter(); @@ -15,6 +16,8 @@ export default function EventPage() { isError, } = useEvent(router.isReady ? id : undefined); + useRedirectOn404(error); + if (isPending) { return (
diff --git a/client/src/pages/games/[id].tsx b/client/src/pages/games/[id].tsx index 69dd1c8d..91271a75 100644 --- a/client/src/pages/games/[id].tsx +++ b/client/src/pages/games/[id].tsx @@ -4,10 +4,12 @@ import { useRouter } from "next/router"; import React from "react"; import { SocialIcon } from "react-social-icons"; +import GameArtCarousel from "@/components/ui/GameArtCarousel"; import { GameEmbed } from "@/components/ui/GameEmbed"; import { ItchEmbed } from "@/components/ui/ItchEmbed"; import { useEvent } from "@/hooks/useEvent"; import { useGame } from "@/hooks/useGames"; +import { useRedirectOn404 } from "@/hooks/useRedirectOn404"; export default function IndividualGamePage() { const router = useRouter(); @@ -22,7 +24,7 @@ export default function IndividualGamePage() { const { data: eventData } = useEvent( game?.event ? String(game.event) : undefined, ); - + useRedirectOn404(error); if (isPending) { return (
@@ -72,23 +74,6 @@ export default function IndividualGamePage() { const devStage = completionLabels[game.completion] ?? "Stage Unknown"; - // TODO ADD ARTIMAGES - const artImages: { src: string; alt: string }[] = []; - // 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 (
@@ -212,21 +197,7 @@ export default function IndividualGamePage() {

ARTWORK

- {artImages.map((img) => ( -
- {img.alt} -
- ))} +
diff --git a/client/src/pages/games/index.tsx b/client/src/pages/games/index.tsx index 3ee3699c..d80f6406 100644 --- a/client/src/pages/games/index.tsx +++ b/client/src/pages/games/index.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import React from "react"; import { SocialIcon } from "react-social-icons"; +import GameArtCarousel from "@/components/ui/GameArtCarousel"; import { useGameshowcase } from "@/hooks/useGameshowcase"; export default function HomePage() { @@ -47,107 +48,127 @@ export default function HomePage() { Game Showcase
-
+
{!showcases || showcases.length === 0 ? (

No games available.

) : ( -
+
{showcases.map((showcase, idx) => ( -
- {/* Left: Cover Image */} -
- {showcase.gameCover ? ( - {showcase.game_name - ) : ( -
+
+ {/* Game CoverImage + Gameshowcase Detail */} +
+ {/* Left: Cover Image */} +
+ {showcase.gameCover ? ( Default game cover -
- )} -
- {/* Right: Details */} -
-
- {/* Title of the game */} -

- - - {showcase.game_name} - - -

- {/* Comments from committes */} -

- {/* double quotes from comments */} - - {showcase.description} - -

-

- Contributors -

-
    - {showcase.contributors.map((contributor, cidx) => ( -
  • + Default game cover +
+ )} +
+ {/* Right: Details */} +
+
+ {/* Title of the game */} +

+ - - {contributor.name} + + {showcase.game_name} - {/* Social icons from API */} - - {Array.isArray(contributor.social_media) && - contributor.social_media.map((sm) => ( - - ))} - - - - {contributor.role} - - - ))} - + +

+ {/* Comments from committes */} +

+ {/* double quotes from comments */} + + {showcase.description} + +

+
+
+
+

+ Contributors +

+
    + {showcase.contributors.map((contributor, cidx) => ( +
  • +
    + + {contributor.name} + + + {contributor.role} + +
    + {/* Social icons placeholder */} + {/* TODO: Add actual links */} + + {/* Social icons using react-social-icons */} + + + + +
  • + ))} +
+
-
-
- {showcase.game_description} + + {/* Game Art Carousel */} + {showcase.artworks.length ? ( + + ) : null} + + {/* Description */} +
+ {showcase.game_description} +
))} diff --git a/client/src/pages/index.tsx b/client/src/pages/index.tsx index 979246e2..bd1c2d0b 100644 --- a/client/src/pages/index.tsx +++ b/client/src/pages/index.tsx @@ -115,7 +115,7 @@ export default function Landing() { eiusmod tempor incididunt ut labore et dolore magna aliqua.

- + diff --git a/client/src/pages/members/[id].tsx b/client/src/pages/members/[id].tsx index 7b17438f..aa13ded1 100644 --- a/client/src/pages/members/[id].tsx +++ b/client/src/pages/members/[id].tsx @@ -1,8 +1,12 @@ +"use client"; + +import Image from "next/image"; import { useRouter } from "next/router"; +import { SocialIcon } from "react-social-icons"; -import { MemberProfile } from "@/components/main/MemberProfile"; +import MemberProjectSection from "@/components/ui/MemberProjectSection"; +import { useContributor } from "@/hooks/useContributor"; import { useMember } from "@/hooks/useMember"; - // hook assumes correct input, page sanitises to correct type function normaliseId(id: string | string[] | number | undefined) { if (typeof id === "number" && Number.isFinite(id)) { @@ -17,6 +21,27 @@ function normaliseId(id: string | string[] | number | undefined) { return undefined; } +export type MemberProfileData = { + name: string; + about: string; + pronouns?: string; + profile_picture?: string; + social_media?: { + link: string; + socialMediaUserName: string; + }[]; + pk: number; +}; + +function initialsFromName(name: string) { + return name + .trim() + .split(/\s+/) + .slice(0, 2) + .map((part) => part[0]?.toUpperCase()) + .join(""); +} + export default function MemberPage() { const router = useRouter(); const id = normaliseId(router.query.id); @@ -26,6 +51,11 @@ export default function MemberPage() { isPending, isError, } = useMember(router.isReady ? id : undefined); + const { gamesRes, artRes } = useContributor(id); + + if (!router.isReady || id === undefined) { + return null; + } if (isPending) { return null; @@ -34,6 +64,96 @@ export default function MemberPage() { if (isError || !member) { return

Member not found

; } + const games = gamesRes.data; + const art = artRes.data; + if (gamesRes.isError || !games || artRes.isError || !art) { + const errorMessage = + gamesRes.error?.response?.status === 404 + ? "Games not found." + : "Failed to Load Games"; + return ( +
+

+ {errorMessage} +

+
+ ); + } + + const initials = initialsFromName(member.name); + return ( + <> +
+
+
+
+ {member.profile_picture ? ( + {`${member.name} + ) : ( +
+

{initials}

+
+ )} +
+ golden pixel art frame around profile picture +
+
+
+

{member.name}

+
+
+
+ {member.social_media && member.social_media.length > 0 && ( +
+
+ {member.social_media.map((sm) => ( + + + + {sm.socialMediaUserName} + + + ))} +
+
+ )} +
+
+

{member.pronouns}

+
- return ; +

{member.about}

+
+
+
+
+ +
+ + ); } diff --git a/client/src/types/art-contributor.ts b/client/src/types/art-contributor.ts new file mode 100644 index 00000000..27e97b40 --- /dev/null +++ b/client/src/types/art-contributor.ts @@ -0,0 +1,6 @@ +export interface ArtContributor { + id: number; + member_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..79d6ef5e --- /dev/null +++ b/client/src/types/art.ts @@ -0,0 +1,13 @@ +import { ArtContributor } from "./art-contributor"; + +export interface Art { + art_id: number; + name: string; + description: string; + media: string; + active: boolean; + source_game_id: number | null; + source_game_name: string | null; + contributors: ArtContributor[]; + showcase_description: string; +} diff --git a/client/tailwind.config.ts b/client/tailwind.config.ts index 52306b8e..94e04170 100644 --- a/client/tailwind.config.ts +++ b/client/tailwind.config.ts @@ -70,6 +70,7 @@ const config = { neutral_4: "var(--neutral-4)", light_1: "var(--light-1)", light_2: "var(--light-2)", + light_3: "var(--light-3)", }, borderRadius: { lg: "var(--radius)", diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 1aa4fe21..00000000 --- a/package-lock.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "game-dev", - "lockfileVersion": 3, - "requires": true, - "packages": {} -} diff --git a/server/game_dev/admin.py b/server/game_dev/admin.py index 496d1122..01ff4f4b 100644 --- a/server/game_dev/admin.py +++ b/server/game_dev/admin.py @@ -1,5 +1,16 @@ from django.contrib import admin -from .models import Member, Game, Event, GameContributor, GameShowcase, Committee, SocialMedia +from .models import ( + Art, + ArtContributor, + ArtShowcase, + Member, + Game, + Event, + GameContributor, + GameShowcase, + Committee, + SocialMedia, +) class SocialMediaInline(admin.TabularInline): @@ -27,8 +38,19 @@ class GameShowcaseAdmin(admin.ModelAdmin): class GamesAdmin(admin.ModelAdmin): - list_display = ("id", "name", "description", "completion", "active", "hostURL", "itchEmbedID", "itchGamePlayableID", "itchGameWidth", - "itchGameHeight", "thumbnail") + list_display = ( + "id", + "name", + "description", + "completion", + "active", + "hostURL", + "itchEmbedID", + "itchGamePlayableID", + "itchGameWidth", + "itchGameHeight", + "thumbnail", + ) search_fields = ["name", "description"] raw_id_fields = ["event"] @@ -37,9 +59,21 @@ class CommitteeAdmin(admin.ModelAdmin): raw_id_fields = ["id"] +# from issue-8-merge-40 temp need changes +class ArtContributorInline(admin.TabularInline): + model = ArtContributor + extra = 1 + + +class ArtAdmin(admin.ModelAdmin): + inlines = [ArtContributorInline] + + admin.site.register(Member, MemberAdmin) admin.site.register(Event, EventAdmin) admin.site.register(Game, GamesAdmin) admin.site.register(GameContributor, GameContributorAdmin) admin.site.register(GameShowcase, GameShowcaseAdmin) +admin.site.register(Art, ArtAdmin) +admin.site.register(ArtShowcase) admin.site.register(Committee, CommitteeAdmin) diff --git a/server/game_dev/migrations/0031_art_artcontributor_artshowcase.py b/server/game_dev/migrations/0031_art_artcontributor_artshowcase.py new file mode 100644 index 00000000..4a77bfbb --- /dev/null +++ b/server/game_dev/migrations/0031_art_artcontributor_artshowcase.py @@ -0,0 +1,54 @@ +# Generated by Django 5.1.15 on 2026-07-11 08:49 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('game_dev', '0030_alter_game_itchgameheight_alter_game_itchgamewidth'), + ] + + 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)), + ('media', models.ImageField(upload_to='art/')), + ('active', models.BooleanField(default=True)), + ('source_game', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='game_artwork', to='game_dev.game')), + ], + ), + 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', + 'constraints': [models.UniqueConstraint(fields=('art', 'member'), name='unique_art_member')], + }, + ), + migrations.CreateModel( + name='ArtShowcase', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('description', models.CharField(max_length=200)), + ('art', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='showcase', to='game_dev.art')), + ], + options={ + 'constraints': [models.UniqueConstraint( + fields=('art',), + name='unique_artshowcase_per_art', + violation_error_message='Each art piece can only have one showcase.' + )], + }, + ), + ] diff --git a/server/game_dev/models.py b/server/game_dev/models.py index 71d4d5ef..81a75ebd 100644 --- a/server/game_dev/models.py +++ b/server/game_dev/models.py @@ -28,12 +28,16 @@ def __str__(self): # 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') + 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'),) + unique_together = (("game", "member"),) def __str__(self): return f"{self.member.name} ({self.role}) for {self.game.name}" @@ -60,13 +64,13 @@ class CompletionStatus(models.IntegerChoices): default=None, null=True, blank=True, - help_text="If game is stored on itch.io, please enter the itchEmbedID, i.e., 1000200" + help_text="If game is stored on itch.io, please enter the itchEmbedID, i.e., 1000200", ) itchGamePlayableID = models.PositiveBigIntegerField( default=None, null=True, blank=True, - help_text="If a game is playable and has a web demo stored on itch.io, please enter the embed developer ID" + help_text="If a game is playable and has a web demo stored on itch.io, please enter the embed developer ID", ) itchGameWidth = models.PositiveBigIntegerField( default=None, @@ -89,22 +93,86 @@ def clean(self): super().clean() if self.itchGamePlayableID: if not self.itchGameWidth: - raise ValidationError({"itchGameWidth": "Game width is required if itchGamePlayableID is set."}) + raise ValidationError( + { + "itchGameWidth": "Game width is required if itchGamePlayableID is set." + } + ) if not self.itchGameHeight: - raise ValidationError({"itchGameHeight": "Game height is required if itchGamePlayableID is set."}) + raise ValidationError( + { + "itchGameHeight": "Game height is required if itchGamePlayableID is set." + } + ) class GameShowcase(models.Model): - game = models.OneToOneField('Game', on_delete=models.CASCADE, related_name='game_showcases') + game = models.OneToOneField( + "Game", on_delete=models.CASCADE, related_name="game_showcases" + ) description = models.TextField() def __str__(self): return f"{self.game.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="game_artwork" + ) + 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})" + + +class ArtShowcase(models.Model): + description = models.CharField(max_length=200) + art = models.ForeignKey(Art, on_delete=models.CASCADE, related_name="showcase") + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["art"], + name="unique_artshowcase_per_art", + violation_error_message="Each art piece can only have one showcase.", + ) + ] + + def __str__(self): + return f"ArtShowcase[Art={str(self.art.name)}, Description={self.description}]" + + class SocialMedia(models.Model): link = models.URLField(max_length=2083) - member = models.ForeignKey('Member', on_delete=models.CASCADE, related_name='social_media_links') + member = models.ForeignKey( + "Member", on_delete=models.CASCADE, related_name="social_media_links" + ) socialMediaUserName = models.CharField(max_length=200, blank=True) def __str__(self): @@ -121,7 +189,7 @@ class Committee(models.Model): "MARK": "Marketing", "EV": "Events OCM", "PRO": "Projects OCM", - "FRE": "Fresher Rep" + "FRE": "Fresher Rep", } role = models.CharField(max_length=9, choices=roles, default="FRE", unique=True) diff --git a/server/game_dev/serializers.py b/server/game_dev/serializers.py index 97a9295e..d008a143 100644 --- a/server/game_dev/serializers.py +++ b/server/game_dev/serializers.py @@ -1,5 +1,15 @@ from rest_framework import serializers -from .models import Event, Game, Member, GameShowcase, GameContributor, SocialMedia +from .models import ( + ArtShowcase, + Event, + Game, + Art, + ArtContributor, + Member, + GameShowcase, + GameContributor, + SocialMedia, +) class EventSerializer(serializers.ModelSerializer): @@ -17,6 +27,60 @@ class Meta: ] +######################################## + + +class ArtContributorSerializer(serializers.ModelSerializer): + art_id = serializers.IntegerField(source="art.id", read_only=True) + artwork_data = serializers.SerializerMethodField() + member_id = serializers.IntegerField(source="member.id", read_only=True) + member_name = serializers.CharField(source="member.name", read_only=True) + + class Meta: + model = ArtContributor + fields = ["id", "role", "art_id", "member_id", "member_name", "artwork_data"] + + def get_artwork_data(self, obj): + art = obj.art + request = self.context.get("request") + return { + "name": art.name, + "description": art.description, + "media": request.build_absolute_uri(art.media.url) + if art.media and request + else None, + } + + +class ArtSerializer(serializers.ModelSerializer): + art_id = serializers.IntegerField(source="id", read_only=True) + source_game_id = serializers.IntegerField(source="source_game.id", read_only=True) + source_game_name = serializers.CharField(source="source_game.name", read_only=True) + contributors = ArtContributorSerializer(many=True, read_only=True) + showcase_description = serializers.SerializerMethodField() + + class Meta: + model = Art + fields = [ + "art_id", + "name", + "description", + "media", + "active", + "source_game_id", + "source_game_name", + "contributors", + "showcase_description", + ] + + def get_showcase_description(self, obj): + showcase = obj.showcase.first() + return showcase.description if showcase else None + + +######################################## + + # This is child serializer of GameSerializer class GameContributorSerializer(serializers.ModelSerializer): # to link contributors to their member/[id] page @@ -33,10 +97,21 @@ def get_social_media(self, obj): return SocialMediaSerializer(social_links, many=True).data +# Copied ArtSerializer at kept data only needed instead of all of it +class GameArtSerializer(serializers.ModelSerializer): + art_id = serializers.IntegerField(source="id", read_only=True) + source_game_id = serializers.IntegerField(source="source_game.id", read_only=True) + + class Meta: + model = Art + fields = ["art_id", "name", "media", "active", "source_game_id"] + + class GamesSerializer(serializers.ModelSerializer): contributors = GameContributorSerializer( many=True, source="game_contributors", read_only=True ) + artworks = GameArtSerializer(many=True, source="game_artwork", read_only=True) class Meta: model = Game @@ -50,10 +125,11 @@ class Meta: "itchEmbedID", "thumbnail", "event", + "contributors", + "artworks", "itchGamePlayableID", "itchGameWidth", "itchGameHeight", - "contributors", ) @@ -83,6 +159,7 @@ class GameshowcaseSerializer(serializers.ModelSerializer): source="game.thumbnail", read_only=True ) contributors = serializers.SerializerMethodField() + artworks = serializers.SerializerMethodField() class Meta: model = GameShowcase @@ -93,6 +170,7 @@ class Meta: "description", "contributors", "game_cover_thumbnail", + "artworks", ) def get_contributors(self, obj): @@ -100,23 +178,45 @@ def get_contributors(self, obj): contributors = GameContributor.objects.filter(game=obj.game) return ShowcaseContributorSerializer(contributors, many=True).data + def get_artworks(self, obj: GameShowcase): + showcase_art = obj.game.game_artwork.all() + request = self.context.get("request") + return [ + { + "art_id": art.id, + "name": art.name, + "active": art.active, + "source_game_id": art.source_game_id, + "description": art.description, + "media": request.build_absolute_uri(art.media.url) + if art.media and request + else None, + } + for art in showcase_art + ] + + # def get_artworks(self, obj): + # return GameArtSerializer(obj.game.game_artwork.all(), many=True).data + class ContributorGameSerializer(serializers.ModelSerializer): - game_id = serializers.IntegerField(source='game.id', read_only=True) + game_id = serializers.IntegerField(source="game.id", read_only=True) role = serializers.CharField(read_only=True) game_data = serializers.SerializerMethodField() class Meta: model = GameContributor - fields = ['game_id', 'role', 'game_data'] + fields = ["game_id", "role", "game_data"] def get_game_data(self, obj): game = obj.game - request = self.context.get('request') + request = self.context.get("request") return { - 'name': game.name, - 'description': game.description, - 'thumbnail': request.build_absolute_uri(game.thumbnail.url) if game.thumbnail and request else None + "name": game.name, + "description": game.description, + "thumbnail": request.build_absolute_uri(game.thumbnail.url) + if game.thumbnail and request + else None, } @@ -137,3 +237,11 @@ class MemberSerializer(serializers.ModelSerializer): class Meta: model = Member fields = ["name", "profile_picture", "about", "pronouns", "social_media", "pk"] + + +class ArtShowcaseSerializer(serializers.ModelSerializer): + art_name = serializers.CharField(source="art.name", read_only=True) + + class Meta: + model = ArtShowcase + fields = ["id", "description", "art", "art_name"] diff --git a/server/game_dev/tests.py b/server/game_dev/tests.py index 9b30e70a..06b6b219 100644 --- a/server/game_dev/tests.py +++ b/server/game_dev/tests.py @@ -1,5 +1,5 @@ from django.test import TestCase -from .models import Member, Event, Committee +from .models import Member, Event, Committee, Game, Art, ArtContributor, ArtShowcase import datetime from django.core.files.uploadedfile import SimpleUploadedFile from django.utils import timezone @@ -198,3 +198,208 @@ def test_default_is_upcoming(self): def test_invalid_type(self): res = self.client.get(self.url, {"type": "invalid"}) self.assertEqual(res.status_code, 400) + + +class ArtModelTest(TestCase): + def setUp(self): + # Create a game for source_game foreign key + self.game = Game.objects.create( + name="Test Game", + description="A test game", + completion=Game.CompletionStatus.WIP, + hostURL="https://example.com", + ) + + # Create an art piece with media + image_file = SimpleUploadedFile( + "test_art.jpg", + b"dummy art image data", + content_type="image/jpeg", + ) + self.art = Art.objects.create( + name="Test Artwork", + description="A beautiful test artwork", + source_game=self.game, + media=image_file, + ) + + def test_art_creation(self): + try: + Art.objects.get(name="Test Artwork") + except Art.DoesNotExist: + self.fail("Art was not properly created") + + def test_art_is_active_by_default(self): + self.assertTrue(self.art.active) + + def test_media_is_saved_in_correct_folder(self): + self.assertTrue(self.art.media.name.startswith("art/")) + + def test_media_field_not_empty(self): + self.assertIsNotNone(self.art.media) + + def test_source_game_relationship(self): + art = Art.objects.get(pk=self.art.pk) + self.assertEqual(art.source_game, self.game) + + def test_cascade_from_game(self): + # When game is deleted, art should remain (SET_NULL behavior would be ideal, but currently CASCADE) + art_id = self.art.id + self.game.delete() + # Since source_game has CASCADE, the art should be deleted + with self.assertRaises(Art.DoesNotExist): + Art.objects.get(id=art_id) + + +class ArtContributorModelTest(TestCase): + def setUp(self): + # Create member + self.member1 = Member.objects.create( + name="John Artist", + about="A talented artist", + pronouns="He/Him" + ) + self.member2 = Member.objects.create( + name="Jane Designer", + about="A creative designer", + pronouns="She/Her" + ) + + self.game = Game.objects.create(name="Test Game") + + # Create art + image_file = SimpleUploadedFile( + "test_art.jpg", + b"dummy art image data", + content_type="image/jpeg", + ) + self.art = Art.objects.create( + name="Collaborative Artwork", + description="Art with multiple contributors", + media=image_file, + source_game=self.game, + ) + + # Create art contributor + self.art_contributor = ArtContributor.objects.create( + art=self.art, + member=self.member1, + role="Lead Artist" + ) + + def test_art_contributor_creation(self): + try: + ArtContributor.objects.get(art=self.art, member=self.member1) + except ArtContributor.DoesNotExist: + self.fail("ArtContributor was not properly created") + + def test_art_contributor_unique_constraint(self): + # Try to create duplicate art-member pair + with self.assertRaises(IntegrityError): + ArtContributor.objects.create( + art=self.art, + member=self.member1, + role="Another Role" + ) + + def test_multiple_contributors_for_same_art(self): + # Should be able to add different members to same art + ArtContributor.objects.create( + art=self.art, + member=self.member2, + role="Character Designer" + ) + contributors = ArtContributor.objects.filter(art=self.art) + self.assertEqual(contributors.count(), 2) + + def test_cascade_from_art(self): + # When art is deleted, art contributors should be deleted + contributor_id = self.art_contributor.id + self.art.delete() + with self.assertRaises(ArtContributor.DoesNotExist): + ArtContributor.objects.get(id=contributor_id) + + def test_cascade_from_member(self): + # When member is deleted, art contributors should be deleted + contributor_id = self.art_contributor.id + self.member1.delete() + with self.assertRaises(ArtContributor.DoesNotExist): + ArtContributor.objects.get(id=contributor_id) + + def test_art_contributor_role(self): + contributor = ArtContributor.objects.get(pk=self.art_contributor.pk) + self.assertEqual(contributor.role, "Lead Artist") + + +class ArtShowcaseModelTest(TestCase): + def setUp(self): + self.game = Game.objects.create(name="Test Game") + + # Create art pieces + image_file1 = SimpleUploadedFile( + "test_art1.jpg", + b"dummy art image data", + content_type="image/jpeg", + ) + self.art1 = Art.objects.create( + name="Showcased Artwork", + description="This art is showcased", + media=image_file1, + source_game=self.game, + ) + + image_file2 = SimpleUploadedFile( + "test_art2.jpg", + b"dummy art image data 2", + content_type="image/jpeg", + ) + self.art2 = Art.objects.create( + name="Another Artwork", + description="This art is also showcased", + media=image_file2, + source_game=self.game, + ) + + # Create showcase + self.showcase = ArtShowcase.objects.create( + art=self.art1, + description="Featured artwork of the month" + ) + + def test_art_showcase_creation(self): + try: + ArtShowcase.objects.get(art=self.art1) + except ArtShowcase.DoesNotExist: + self.fail("ArtShowcase was not properly created") + + def test_art_showcase_unique_constraint(self): + # Try to create another showcase for the same art + with self.assertRaises(IntegrityError): + ArtShowcase.objects.create( + art=self.art1, + description="Another showcase for same art" + ) + + def test_multiple_showcases_for_different_arts(self): + # Should be able to create showcases for different art pieces + ArtShowcase.objects.create( + art=self.art2, + description="Another featured artwork" + ) + showcases = ArtShowcase.objects.all() + self.assertEqual(showcases.count(), 2) + + def test_cascade_from_art(self): + # When art is deleted, its showcase should be deleted + showcase_id = self.showcase.id + self.art1.delete() + with self.assertRaises(ArtShowcase.DoesNotExist): + ArtShowcase.objects.get(id=showcase_id) + + def test_showcase_description(self): + showcase = ArtShowcase.objects.get(pk=self.showcase.pk) + self.assertEqual(showcase.description, "Featured artwork of the month") + + def test_art_showcase_relationship(self): + showcase = ArtShowcase.objects.get(pk=self.showcase.pk) + self.assertEqual(showcase.art, self.art1) diff --git a/server/game_dev/urls.py b/server/game_dev/urls.py index 7e103bf9..87299ad2 100644 --- a/server/game_dev/urls.py +++ b/server/game_dev/urls.py @@ -1,14 +1,18 @@ from django.urls import path -from .views import ContributorGamesListAPIView, EventListAPIView, EventDetailAPIView -from .views import GamesDetailAPIView, GameshowcaseAPIView, MemberAPIView, CommitteeAPIView +from .views import (ContributorGamesListAPIView, EventListAPIView, EventDetailAPIView, GamesDetailAPIView, + GameshowcaseAPIView, MemberAPIView, CommitteeAPIView, ContributorArtListAPIView, + FeatureArtAPIView, ArtDetailAPIView) + urlpatterns = [ path("events/", EventListAPIView.as_view(), name="events-list"), path("events//", EventDetailAPIView.as_view()), + path('arts/featured/', FeatureArtAPIView.as_view()), + path('arts//', ArtDetailAPIView.as_view(), name='art-detail'), path("games//", GamesDetailAPIView.as_view()), path("games/contributor//", ContributorGamesListAPIView.as_view()), - # Updated line for GameShowcase endpoint + path("arts/contributor//", ContributorArtListAPIView.as_view()), path("gameshowcase/", GameshowcaseAPIView.as_view(), name="gameshowcase-api"), path('members//', MemberAPIView.as_view()), path("about/", CommitteeAPIView.as_view()), diff --git a/server/game_dev/views.py b/server/game_dev/views.py index 26f31bcd..eaba7718 100644 --- a/server/game_dev/views.py +++ b/server/game_dev/views.py @@ -1,6 +1,23 @@ from rest_framework import generics -from .serializers import ContributorGameSerializer, GamesSerializer, GameshowcaseSerializer, EventSerializer, MemberSerializer -from .models import Game, GameContributor, GameShowcase, Event, Member, Committee +from .serializers import ( + ContributorGameSerializer, + GamesSerializer, + GameshowcaseSerializer, + EventSerializer, + MemberSerializer, + ArtSerializer, + ArtContributorSerializer, +) +from .models import ( + Game, + GameContributor, + GameShowcase, + Event, + Member, + Committee, + Art, + ArtContributor, +) from django.utils import timezone from rest_framework.views import APIView from rest_framework.response import Response @@ -12,6 +29,7 @@ class GamesDetailAPIView(generics.RetrieveAPIView): """ GET /api/games// """ + serializer_class = GamesSerializer lookup_url_kwarg = "id" @@ -30,6 +48,7 @@ class EventListAPIView(generics.ListAPIView): GET /api/events/ Returns a paginated list of events (optionally filtered by time) """ + serializer_class = EventSerializer pagination_class = EventPagination @@ -51,14 +70,14 @@ def get_queryset(self): if type_param == "upcoming": return qs.filter(date__gte=now).order_by("date") - raise ValidationError( - {"type": "Invalid value. Use 'past' or 'upcoming'."}) + raise ValidationError({"type": "Invalid value. Use 'past' or 'upcoming'."}) class EventDetailAPIView(generics.RetrieveAPIView): """ GET /api/events// """ + serializer_class = EventSerializer lookup_url_kwarg = "id" @@ -72,13 +91,18 @@ def get_object(self): return queryset.get() except Event.DoesNotExist: from rest_framework.exceptions import NotFound - raise NotFound(detail="The event is not yet published by admin or does not exist.") + + raise NotFound( + detail="The event is not yet published by admin or does not exist." + ) class GameshowcaseAPIView(APIView): def get(self, request): showcases = GameShowcase.objects.all() - serializer = GameshowcaseSerializer(showcases, many=True, context={'request': request}) + serializer = GameshowcaseSerializer( + showcases, many=True, context={"request": request} + ) return Response(serializer.data) @@ -90,6 +114,14 @@ def get_queryset(self): return GameContributor.objects.filter(member=member_id) +class ContributorArtListAPIView(generics.ListAPIView): + serializer_class = ArtContributorSerializer + + def get_queryset(self): + member_id = self.kwargs.get("member") + return ArtContributor.objects.filter(member=member_id) + + class MemberAPIView(generics.RetrieveAPIView): serializer_class = MemberSerializer lookup_field = "id" @@ -104,8 +136,13 @@ class CommitteeAPIView(generics.ListAPIView): def get_queryset(self): outputList = [] roleOrder = ("P", "VP", "SEC", "TRE", "MARK", "EVE", "PRO", "FRE") - placeholderMember = {"name": "Position not filled", "profile_picture": "url('/landing_placeholder.png')", - "about": "", "pronouns": "", "pk": 0} + placeholderMember = { + "name": "Position not filled", + "profile_picture": "url('/landing_placeholder.png')", + "about": "", + "pronouns": "", + "pk": 0, + } for i in roleOrder: try: cur = Committee.objects.get(role=i).id @@ -116,3 +153,26 @@ def get_queryset(self): except Committee.DoesNotExist: outputList.append(placeholderMember) return outputList + + +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"]) + + +class FeatureArtAPIView(generics.ListAPIView): + """ + GET /api/arts/featured/ + """ + + serializer_class = ArtSerializer + + def get_queryset(self): + return Art.objects.filter(showcase__isnull=False)