diff --git a/client/documentation/admin-dashboard/members.md b/client/documentation/admin-dashboard/members.md
new file mode 100644
index 0000000..f8fde6a
--- /dev/null
+++ b/client/documentation/admin-dashboard/members.md
@@ -0,0 +1,17 @@
+## Member Profiles
+
+Profiles of club members can be added and edited at the row 'Member' of the GAME_DEV section on the main admin page.
+
+### Fields
+
+**Name:** Required field for the member's name. A character field (includes letters, numbers and symbols) of maximum length 200 characters.
+
+**Active:** Checkbox to represent whether a member is an active participant in the club. If the checkbox is not ticked then the member's profile will not be displayed on the website.
+
+**Profile Picture:** Optional field to upload a profile picture. Must be an image file, and will display best if the image is at least 128 by 128 px in size. If no profile picture is provided then the member's initials will be displayed instead.
+
+**About:** Optional field for a bio. A character field of maximum length 256 characters.
+
+**Pronouns:** Optional field for the member's pronouns. A character field of maximum length 20 characters.
+
+**Social media links:** Optional section to display links to the member's social media profiles. Requires a link to the profile (character field of maximum length 2083) and, optionally, the profile username (character field of maximum length 200). If a username is not supplied then only a social media icon will be displayed with the link attached, otherwise the username will be placed next to the relevant icon. The type of icon to be displayed (e.g. instagram, linkedin, generic link) is inferred from the social media link provided.
diff --git a/client/next.config.mjs b/client/next.config.mjs
index 950b3e6..abff464 100644
--- a/client/next.config.mjs
+++ b/client/next.config.mjs
@@ -1,12 +1,6 @@
-// import os from "node:os";
-// import isInsideContainer from "is-inside-container";
-
-// const isWindowsDevContainer = () =>
-// os.release().toLowerCase().includes("microsoft") && isInsideContainer();
-
/** @type {import('next').NextConfig} */
-const config = {
+const nextConfig = {
reactStrictMode: true,
turbopack: {
root: import.meta.dirname,
@@ -27,4 +21,4 @@ const config = {
// : undefined,
};
-export default config;
+export default nextConfig;
diff --git a/client/public/go-back-icon.svg b/client/public/go-back-icon.svg
new file mode 100644
index 0000000..e920f5a
--- /dev/null
+++ b/client/public/go-back-icon.svg
@@ -0,0 +1,10 @@
+
diff --git a/client/public/placeholder-icon.svg b/client/public/placeholder-icon.svg
new file mode 100644
index 0000000..6879e78
--- /dev/null
+++ b/client/public/placeholder-icon.svg
@@ -0,0 +1,6 @@
+
diff --git a/client/src/components/ui/ContributorsList.tsx b/client/src/components/ui/ContributorsList.tsx
new file mode 100644
index 0000000..64e3b89
--- /dev/null
+++ b/client/src/components/ui/ContributorsList.tsx
@@ -0,0 +1,59 @@
+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 (
+
-
+
-
+
diff --git a/client/src/trivia.json b/client/src/trivia.json
new file mode 100644
index 0000000..865c30e
--- /dev/null
+++ b/client/src/trivia.json
@@ -0,0 +1,398 @@
+[
+ {
+ "question": "What 1997 N64 video game, widely cited as one of the greatest of all time, features James Bond up against a criminal syndicate and is named after the 1995 film in the Bond franchise?",
+ "answer": "GoldenEye"
+ },
+ {
+ "question": "Originally given the Japanese title \"Puckman,\" what 1980s arcade game was inducted into the Guinness Book of Records as the \"Most Successful Coin-Operated Game\" in 2005?",
+ "answer": "Pac-Man"
+ },
+ {
+ "question": "In July 2023, video game company EA announced that players will be able to explore Wakanda in an upcoming open-world video game based on the adventures of what Marvel superhero?",
+ "answer": "Black Panther"
+ },
+ {
+ "question": "Which spooky 2001 GameCube game starring Mario's brother got a reboot for Nintendo Switch in 2019?",
+ "answer": "Luigi's Mansion"
+ },
+ {
+ "question": "In May 2022, the government of what European nation banned its employees from using American gaming terms such as \"e-sports,\" instead using their domestic language counterparts?",
+ "answer": "France"
+ },
+ {
+ "question": "In 2011, the World Health Organization included VGA, an addiction to what activity, among its mental health disorders for the first time?",
+ "answer": "Video Games"
+ },
+ {
+ "question": "What Will Wright created video game series, released in 2000 (with sequels in 2004, 2009, and 2014), saw players watching and directing characters to mundane things like eating, sleeping, and cleaning their houses?",
+ "answer": "The Sims"
+ },
+ {
+ "question": "Regarded as one of the greatest video games of all time, what 1981 arcade game features the titular amphibian trying to cross a road and a river?",
+ "answer": "Frogger"
+ },
+ {
+ "question": "What is thought to be the first video game, created in 1958 and becoming popular in the 1970s?",
+ "answer": "Pong"
+ },
+ {
+ "question": "Which video game console released in 2006 pioneered the use of motion controls in its gameplay?",
+ "answer": "Nintendo Wii"
+ },
+ {
+ "question": "Making his debut in 1990s \"Super Mario World,\" what is the name of the enemy-eating, egg-throwing green dinosaur who serves as a sidekick to Mario and Luigi?",
+ "answer": "Yoshi"
+ },
+ {
+ "question": "In 2014, Google partnered with Game Freak and Nintendo as part of an April Fool's Day prank to create a new version of Google Maps. This prank inspired what massively popular 2016 video game?",
+ "answer": "Pokemon Go"
+ },
+ {
+ "question": "What video game came with the Nintendo Entertainment System when it was released in the late 1980s, and was meant to be played with the NES Zapper gun?",
+ "answer": "Duck Hunt"
+ },
+ {
+ "question": "Introduced on Wii consoles, Nintendo gamers can make their own in-game character by creating an avatar known by what three-letter name that sounds similar to a pronoun?",
+ "answer": "Mii"
+ },
+ {
+ "question": "What word completes the title of the 2017 game \"Super Mario\" what, for the Nintendo Switch? The word in question is also a vehicle manufactured by Honda.",
+ "answer": "Odyssey"
+ },
+ {
+ "question": "What Konami game from September 1998 was initially released to the European arcade audience under the name \"Dancing Stage?\"",
+ "answer": "Dance Dance Revolution"
+ },
+ {
+ "question": "In Mario Kart, the power-up that seeks out the player in first position and explodes on impact is a shell that is what color?",
+ "answer": "Blue"
+ },
+ {
+ "question": "The company that created Fortnite, EPIC, also created a game engine that is licensed to other game creators, named what?",
+ "answer": "Unreal"
+ },
+ {
+ "question": "Smoke on the Water is a fictional medical-marijuana shop that can be purchased by Franklin with money in what heist-y video game franchise?",
+ "answer": "Grand Theft Auto"
+ },
+ {
+ "question": "Which PlayStation platformer released in 1996 has you play as the titular character—a marsupial prone to mayhem who was captured by Dr. Neo Cortex?",
+ "answer": "Crash Bandicoot"
+ },
+ {
+ "question": "The Warthog is the nickname of the M12 Force Application Light Reconnaissance Vehicle, a fictional armored vehicle that appears in what video game series?",
+ "answer": "Halo"
+ },
+ {
+ "question": "Pocket, Light, Color, and Advance were all styles or variants of what video game hardware system?",
+ "answer": "Game Boy"
+ },
+ {
+ "question": "Released in 2004 by Blizzard Entertainment and set in the fictional universe of Azeroth, what is the name of the computer game that became the world's most popular MMORPG?",
+ "answer": "World of Warcraft"
+ },
+ {
+ "question": "What object does Mario typically leap onto after completing a level in the earliest iterations of his franchise?",
+ "answer": "Flag pole"
+ },
+ {
+ "question": "What third-person shooter video game developed by Nintendo was first released in 2015 and features characters known as inklings?",
+ "answer": "Splatoon"
+ },
+ {
+ "question": "In Super Mario Kart, the first game in Nintendo's racing franchise, which of the playable characters has the shortest name?",
+ "answer": "Toad"
+ },
+ {
+ "question": "When the kids online say \"LoL,\" they're either laughing or referencing what online battle arena game that's been sponsored by Mastercard since 2018?",
+ "answer": "League of Legends"
+ },
+ {
+ "question": "What 2009 game, developed by Mojang, is an open sandbox in which players often build structures and battle creepers and zombies?",
+ "answer": "Minecraft"
+ },
+ {
+ "question": "A 2017 Guerrilla Games game, published on the PlayStation 4, that features Aloy battling giant machines with her bow, is what \"Zero Dawn\"?",
+ "answer": "Horizon Zero Dawn"
+ },
+ {
+ "question": "Harry must collect treasures including gold and diamonds without landing in quicksand in what classic 1982 Atari game with an exclamation point in the title?",
+ "answer": "Pitfall!"
+ },
+ {
+ "question": "What word—which shares its name with a popular soda brand—describes a 2D bitmap image, such as a video game character, that's integrated into a larger scene?",
+ "answer": "Sprite"
+ },
+ {
+ "question": "In what franchise-launching 1985 educational video game was a user required to have a warrant for each arrest while traveling to locales like Oslo and Cairo?",
+ "answer": "Where in the World Is Carmen San Diego?"
+ },
+ {
+ "question": "An egg-shaped wind instrument dating back to ancient times appears in the title of what 1998 installment in the \"Legend of Zelda\" franchise?",
+ "answer": "Ocarina of Time"
+ },
+ {
+ "question": "2021 saw the release of what sixth game in the Halo franchise, continuing the adventures of Master Chief? Its name sounds as if the game's story will continue in perpetuity.",
+ "answer": "Halo Infinite"
+ },
+ {
+ "question": "Mendicant Bias and Offensive Bias are fictional AIs in what \"holy\" video game franchise that shares its name with a Beyonce song?",
+ "answer": "Halo"
+ },
+ {
+ "question": "What 2018 video games are set in 1899 and follow the story of outlaws Arthur Morgan and John Marston?",
+ "answer": "Red Dead Redemption 2"
+ },
+ {
+ "question": "Used while playing \"Contra,\" the original Konami code gave you 30 extra of what video game things?",
+ "answer": "Extra Lives"
+ },
+ {
+ "question": "What card game related to the \"Warcraft\" universe did Blizzard release in 2014?",
+ "answer": "Hearthstone"
+ },
+ {
+ "question": "A sleek black convertible known as the Regalia is the car Noctis and his friends use to travel across Eos in the 15th installment of what alliterative video game franchise?",
+ "answer": "Final Fantasy"
+ },
+ {
+ "question": "Dressed in purple and black with an upside-down \"L\" on his cap, what skinny and mustachioed character made his debut in the 2000 Nintendo 64 game, \"Mario Tennis?\"",
+ "answer": "Waluigi"
+ },
+ {
+ "question": "Although early versions of the game featured a character named \"Ivan the Space Biker,\" the game's maker (Valve) eventually settled on \"Gordon Freeman\" as the hero. What was the game?",
+ "answer": "Half-Life"
+ },
+ {
+ "question": "What video game character is described as a young, energetic, violet creature with orange medium-sized wings, large curved horns, and a spiral-shaped spike on his tail?",
+ "answer": "Spyro the Dragon"
+ },
+ {
+ "question": "What name is shared by a sci-fi video game franchise, a Beyonce song, and the tiara worn by Kate Middleton on her wedding day?",
+ "answer": "Halo"
+ },
+ {
+ "question": "In the timeless Oregon Trail video games, you were often given three options to get across rivers: caulk and float, take a ferry, and what four-letter third choice?",
+ "answer": "Ford"
+ },
+ {
+ "question": "What Star Wars console video game released at the end of 2020 focuses on space combat inspired by the movie franchise?",
+ "answer": "Star Wars: Squadrons"
+ },
+ {
+ "question": "What 2021 installment in the Call of Duty video game franchise shares its name with one of America's largest investment management firms?",
+ "answer": "Vanguard"
+ },
+ {
+ "question": "What fantasy kingdom is the main setting for the \"Legend of Zelda\" video game series?",
+ "answer": "Hyrule"
+ },
+ {
+ "question": "Chuck E. Cheese was originally founded by Nolan Bushnell, who also co-founded what video game company known for its 2600?",
+ "answer": "Atari"
+ },
+ {
+ "question": "The third entry in an extremely popular post-apocalyptic video game franchise was set in an area known as \"Capital Wasteland,\" the ruins of Washington, DC. What is the name of this franchise?",
+ "answer": "Fallout"
+ },
+ {
+ "question": "In 2008, the open world racing game was pioneered with the release of what \"Paradise\"?",
+ "answer": "Burnout Paradise"
+ },
+ {
+ "question": "Imane Anys, whose millions of followers love to watch her play League of Legends and Fortnite, is better known by what name?",
+ "answer": "Pokimane"
+ },
+ {
+ "question": "Sun, Moon, Diamond, Pearl, and SoulSilver have all been names of games in what iconic video game franchise?",
+ "answer": "Pokemon"
+ },
+ {
+ "question": "Crash is a video game character who is a genetically mutated type of what marsupial?",
+ "answer": "Bandicoot"
+ },
+ {
+ "question": "What first-person shooter video game developed by Valve and published for Microsoft Windows in 1998 launched a globally successful franchise?",
+ "answer": "Half-Life"
+ },
+ {
+ "question": "The first Star Wars video game, made for the Atari 2600, was based on which film in the original trilogy?",
+ "answer": "The Empire Strikes Back"
+ },
+ {
+ "question": "What is the name of the best-selling video game franchise to come out of Disney's home-grown intellectual property?",
+ "answer": "Kingdom Hearts"
+ },
+ {
+ "question": "In the original 1980 edition of Pac-Man, the four ghosts were named Blinky, Inky, Pinky, and what name that doesn't rhyme with the rest?",
+ "answer": "Clyde"
+ },
+ {
+ "question": "What simulation video game franchise was originally developed by Will Wright and launched in 1989 for the Macintosh computer?",
+ "answer": "SimCity"
+ },
+ {
+ "question": "Tingle is a \"short, paunchy 35-year-old\" obsessed with \"forest fairies.\" In what video game franchise did Tingle debut?",
+ "answer": "The Legend of Zelda"
+ },
+ {
+ "question": "\"Ultimate\" and \"Melee\" are two of the iterations in the Super Smash Bros. franchise. What is the one additional word that follows the name of a title in the series?",
+ "answer": "Brawl"
+ },
+ {
+ "question": "Tony Hawk is one of the world's most skilled skateboarders. He is also the face of one of the best-selling video games of the late '90s entitled \"Tony Hawk's\" what, released September 29, 1999?",
+ "answer": "Pro Skater"
+ },
+ {
+ "question": "The first game in the Final Fantasy video game franchise was released for what console?",
+ "answer": "Nintendo Entertainment System (NES)"
+ },
+ {
+ "question": "What is the name of the twin brother of Solid Snake, the protagonist of the Metal Gear franchise?",
+ "answer": "Liquid Snake"
+ },
+ {
+ "question": "In January 2021, a short squeeze orchestrated by Reddit users caused a skyrocketing of the price of what retail chain that sells video games and consumer electronics?",
+ "answer": "GameStop"
+ },
+ {
+ "question": "The 1995 point-and-click adventure game \"I Have No Mouth, and I Must Scream\" is based on the short story of the same name by what sci-fi author?",
+ "answer": "Harlan Ellison"
+ },
+ {
+ "question": "A 2022 Lego Star Wars game that lets players reenact all 9 mainline Star Wars films is \"Lego Star Wars:\" The what Saga?",
+ "answer": "Skywalker"
+ },
+ {
+ "question": "Skyrim is the fifth installment of what epic open-world videogame series by Bethesda Softworks?",
+ "answer": "The Elder Scrolls"
+ },
+ {
+ "question": "What \"S\" videogame series co-created and published by Electronic Arts allows users to create and customize virtual human beings?",
+ "answer": "The Sims"
+ },
+ {
+ "question": "Which Legend of Zelda game that picks up after Ocarina of Time was released for N64 in 2000 and remade for Nintendo 3DS in 2015?",
+ "answer": "Majora's Mask"
+ },
+ {
+ "question": "What video game franchise debuted in 2001 as a hybrid real-time strategy and puzzle video game centered on part-collecting for a crashed rocket ship?",
+ "answer": "Pikmin"
+ },
+ {
+ "question": "What was the name of the franchise of educational video games from the 1990s that featured a green protagonist and titles like \"In Search of Spot\"?",
+ "answer": "Math Blaster!"
+ },
+ {
+ "question": "What video game franchise technically included \"Dr. Kawashima\" in the title? The first installment debuted in 2005 on the Nintendo DS.",
+ "answer": "Brain Age"
+ },
+ {
+ "question": "What is the name of the series of Star Wars video games that began on the Nintendo 64 console in 1998?",
+ "answer": "Rogue Squadron"
+ },
+ {
+ "question": "What classic open-ended PC game of 1993 may have been inspired by a Jules Verne novel whose characters were marooned on an island?",
+ "answer": "Myst"
+ },
+ {
+ "question": "According to Apple, the second most popular free game downloaded on iPhones in 2018 was an \"endless play style\" game where you try to get a ball down platforms. What is the game?",
+ "answer": "Helix Jump"
+ },
+ {
+ "question": "What is the name of the largest body of water on the Fortnite Battle Royale map?",
+ "answer": "Loot Lake"
+ },
+ {
+ "question": "What Pokémon holds the title as the first listed creature in the Pokédex and is considered a hybrid grass-poison type?",
+ "answer": "Bulbasaur"
+ },
+ {
+ "question": "What regulatory group assigns content ratings and suggested age ratings for video games? (4-letter initialism)",
+ "answer": "ESRB"
+ },
+ {
+ "question": "Blathers is the name of the nocturnal, museum-curating owl in what series of Nintendo video games?",
+ "answer": "Animal Crossing"
+ },
+ {
+ "question": "In what video game universe, created by Capcom, would you find a character named Jill Valentine?",
+ "answer": "Resident Evil"
+ },
+ {
+ "question": "What 2021 game in the Metroid franchise, released on the Nintendo Switch, features Samus Aran investigating a mysterious transmission on the planet ZDR?",
+ "answer": "Metroid Dread"
+ },
+ {
+ "question": "The website Ranker named GLaDOS, a fictional artificially intelligent computer system, the greatest video game villain of all time. GLaDOS was introduced in what groundbreaking computer game?",
+ "answer": "Portal"
+ },
+ {
+ "question": "\"Korobeiniki,\" a folk song about a peddler and a girl haggling, is best known outside Russia as the theme music for what video game?",
+ "answer": "Tetris"
+ },
+ {
+ "question": "Air Man, Cut Man, Ring Man, and Drill Man are all villains in what \"M.M.\" video game franchise?",
+ "answer": "Mega Man"
+ },
+ {
+ "question": "Larry, Morton, Wendy, Iggy, Roy, Lemmy, and Ludwig are all video game villains that report to which young commander?",
+ "answer": "Bowser Jr"
+ },
+ {
+ "question": "A reference to its popular Angry Birds franchise, what Finnish video game company sometimes uses the slogan \"Angry since 2009?\"",
+ "answer": "Rovio"
+ },
+ {
+ "question": "It's one of the longest-running series in video game history. The four ghosts in Pac-Man are called Inky, Blinky, Pinky, and what name that breaks the pattern?",
+ "answer": "Clyde"
+ },
+ {
+ "question": "The very first game in the Madden NFL video game franchise was named John Madden Football and was released June 1st in what year?",
+ "answer": "1988"
+ },
+ {
+ "question": "What gothic video game franchise debuted in 1986 with Simon Belmont as protagonist, a member of the Belmont clan of vampire hunters?",
+ "answer": "Castlevania"
+ },
+ {
+ "question": "What was the \"metallic\" golf video game played with a trackball that was popularized in bars across America?",
+ "answer": "Golden Tee"
+ },
+ {
+ "question": "What beat-em-up video game franchise, featuring twin brother martial artists Billy and Jimmy, was later turned into a poorly received 1994 movie?",
+ "answer": "Double Dragon"
+ },
+ {
+ "question": "According to market research company NPD Group, which video game console sold the most units in the United States in 2008?",
+ "answer": "Nintendo Wii"
+ },
+ {
+ "question": "What popular mobile puzzle game involves the collection of characters who are described as \"friends without the R\"?",
+ "answer": "Best Fiends"
+ },
+ {
+ "question": "Which side-scrolling platformer by Ubisoft debuted in 1995 and tasked players with navigating levels like The Dream Forest?",
+ "answer": "Rayman"
+ },
+ {
+ "question": "In Mario's first appearance in the video game \"Donkey Kong\", what J-word was his official name before later transitioning to Mario?",
+ "answer": "Jumpman"
+ },
+ {
+ "question": "What is the name of the talking animatronic toy that resembled a bear and reached peak popularity in the mid-1980s?",
+ "answer": "Teddy Ruxpin"
+ },
+ {
+ "question": "The sci-fi novel \"Ready Player One\" features what 1979 Atari 2600 game in the book's final challenge?",
+ "answer": "Adventure"
+ },
+ {
+ "question": "What was the name of the princess Mario rescues in Nintendo Gameboy's \"Super Mario Land\" (1989)?",
+ "answer": "Daisy"
+ },
+ {
+ "question": "\"Dachshund & Friends,\" \"Lab & Friends,\" and \"Chihuahua & Friends\" are the three versions of the 2005 U.S. release of what video game?",
+ "answer": "Nintendogs"
+ }
+]
diff --git a/client/src/types/art-contributor.ts b/client/src/types/art-contributor.ts
new file mode 100644
index 0000000..27e97b4
--- /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 0000000..38436e8
--- /dev/null
+++ b/client/src/types/art.ts
@@ -0,0 +1,14 @@
+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;
+ isMock?: boolean;
+}
diff --git a/server/game_dev/admin.py b/server/game_dev/admin.py
index a50a46a..d61b00a 100644
--- a/server/game_dev/admin.py
+++ b/server/game_dev/admin.py
@@ -1,5 +1,5 @@
from django.contrib import admin
-from .models import Member, Game, Event, GameContributor, GameShowcase, Committee, SocialMedia
+from .models import Art, ArtContributor, ArtShowcase, Member, Game, Event, GameContributor, GameShowcase, Committee, SocialMedia
class SocialMediaInline(admin.TabularInline):
@@ -36,9 +36,20 @@ class CommitteeAdmin(admin.ModelAdmin):
raw_id_fields = ["id"]
+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/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/migrations/0008_art_showcase.py b/server/game_dev/migrations/0008_art_showcase.py
new file mode 100644
index 0000000..a27bd6f
--- /dev/null
+++ b/server/game_dev/migrations/0008_art_showcase.py
@@ -0,0 +1,44 @@
+# Create Art showcase model
+
+import django
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("game_dev", "0007_alter_artcontributor_unique_together_and_more"),
+ ]
+
+ operations = [
+ 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",
+ ),
+ )
+ ],
+ ),
+ migrations.AddConstraint(
+ model_name='artshowcase',
+ constraint=models.UniqueConstraint(
+ fields=['art'],
+ name='unique_artshowcase_per_art'
+ )
+ )
+ ]
diff --git a/server/game_dev/migrations/0008_merge_20260130_2216.py b/server/game_dev/migrations/0008_merge_20260130_2216.py
new file mode 100644
index 0000000..fda3f25
--- /dev/null
+++ b/server/game_dev/migrations/0008_merge_20260130_2216.py
@@ -0,0 +1,13 @@
+# Generated by Django 6.0 on 2026-01-30 14:16
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("game_dev", "0005_alter_member_profile_picture"),
+ ("game_dev", "0007_alter_artcontributor_unique_together_and_more"),
+ ]
+
+ operations = []
diff --git a/server/game_dev/migrations/0010_merge_20260131_1145.py b/server/game_dev/migrations/0010_merge_20260131_1145.py
new file mode 100644
index 0000000..b998ef7
--- /dev/null
+++ b/server/game_dev/migrations/0010_merge_20260131_1145.py
@@ -0,0 +1,13 @@
+# Generated by Django 5.1.15 on 2026-01-31 03:45
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("game_dev", "0008_merge_20260130_2216"),
+ ("game_dev", "0009_merge_20260131_1044"),
+ ]
+
+ operations = []
diff --git a/server/game_dev/migrations/0011_merge_0008_art_showcase_0010_merge_20260131_1145.py b/server/game_dev/migrations/0011_merge_0008_art_showcase_0010_merge_20260131_1145.py
new file mode 100644
index 0000000..2d09bb1
--- /dev/null
+++ b/server/game_dev/migrations/0011_merge_0008_art_showcase_0010_merge_20260131_1145.py
@@ -0,0 +1,13 @@
+# Generated by Django 5.2.9 on 2026-02-04 05:07
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("game_dev", "0008_art_showcase"),
+ ("game_dev", "0010_merge_20260131_1145"),
+ ]
+
+ operations = []
diff --git a/server/game_dev/migrations/0012_art_source_game_and_more.py b/server/game_dev/migrations/0012_art_source_game_and_more.py
new file mode 100644
index 0000000..03ee5d7
--- /dev/null
+++ b/server/game_dev/migrations/0012_art_source_game_and_more.py
@@ -0,0 +1,33 @@
+# Generated by Django 5.2.9 on 2026-02-04 05:13
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("game_dev", "0011_merge_0008_art_showcase_0010_merge_20260131_1145"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="art",
+ name="source_game",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="art_pieces",
+ to="game_dev.game",
+ ),
+ ),
+ migrations.AddConstraint(
+ model_name="artshowcase",
+ constraint=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/migrations/0013_merge_20260204_1441.py b/server/game_dev/migrations/0013_merge_20260204_1441.py
new file mode 100644
index 0000000..1578b04
--- /dev/null
+++ b/server/game_dev/migrations/0013_merge_20260204_1441.py
@@ -0,0 +1,13 @@
+# Generated by Django 5.2.9 on 2026-02-04 06:41
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("game_dev", "0010_merge_20260131_1118"),
+ ("game_dev", "0012_art_source_game_and_more"),
+ ]
+
+ operations = []
diff --git a/server/game_dev/migrations/0015_merge_20260218_1922.py b/server/game_dev/migrations/0015_merge_20260218_1922.py
new file mode 100644
index 0000000..1e1c8ab
--- /dev/null
+++ b/server/game_dev/migrations/0015_merge_20260218_1922.py
@@ -0,0 +1,13 @@
+# Generated by Django 5.2.9 on 2026-02-18 11:22
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("game_dev", "0013_merge_20260204_1441"),
+ ("game_dev", "0014_merge_20260214_1420"),
+ ]
+
+ operations = []
diff --git a/server/game_dev/models.py b/server/game_dev/models.py
index d626275..113bd01 100644
--- a/server/game_dev/models.py
+++ b/server/game_dev/models.py
@@ -90,6 +90,49 @@ 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')
diff --git a/server/game_dev/serializers.py b/server/game_dev/serializers.py
index c576aaa..7b82a2d 100644
--- a/server/game_dev/serializers.py
+++ b/server/game_dev/serializers.py
@@ -1,5 +1,5 @@
from rest_framework import serializers
-from .models import Event, Game, Member, GameShowcase, GameContributor, SocialMedia
+from .models import ArtShowcase, Event, Game, Art, ArtContributor, Member, GameShowcase, GameContributor, SocialMedia
class EventSerializer(serializers.ModelSerializer):
@@ -105,3 +105,36 @@ class Meta:
"social_media",
"pk"
]
+
+
+class ArtContributorSerializer(serializers.ModelSerializer):
+ 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', 'member_id', 'member_name', 'role']
+
+
+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
+
+
+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 96bdc43..7b4b6bf 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
@@ -193,3 +193,201 @@ 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"
+ )
+
+ # 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,
+ )
+
+ # 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):
+ # 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,
+ )
+
+ 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,
+ )
+
+ # 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 45a1e36..f00e471 100644
--- a/server/game_dev/urls.py
+++ b/server/game_dev/urls.py
@@ -1,9 +1,15 @@
from django.urls import path
-from .views import EventListAPIView, EventDetailAPIView, GamesDetailAPIView, GameshowcaseAPIView, MemberAPIView, CommitteeAPIView
+from .views import (
+ EventListAPIView, EventDetailAPIView, GamesDetailAPIView,
+ GameshowcaseAPIView, MemberAPIView, CommitteeAPIView,
+ 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("gameshowcase/", GameshowcaseAPIView.as_view(), name="gameshowcase-api"), # Updated line for GameShowcase endpoint
path('members//', MemberAPIView.as_view()),
diff --git a/server/game_dev/views.py b/server/game_dev/views.py
index 086119c..30a575b 100644
--- a/server/game_dev/views.py
+++ b/server/game_dev/views.py
@@ -1,6 +1,6 @@
from rest_framework import generics
-from .serializers import GamesSerializer, GameshowcaseSerializer, EventSerializer, MemberSerializer
-from .models import Game, GameShowcase, Event, Member, Committee
+from .serializers import GamesSerializer, GameshowcaseSerializer, EventSerializer, MemberSerializer, ArtSerializer
+from .models import Game, GameShowcase, Event, Member, Committee, Art
from django.utils import timezone
from rest_framework.views import APIView
from rest_framework.response import Response
@@ -96,3 +96,24 @@ 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)
diff --git a/server/poetry.lock b/server/poetry.lock
index 34f6c92..1dc664f 100644
--- a/server/poetry.lock
+++ b/server/poetry.lock
@@ -1,4 +1,4 @@
-# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand.
+# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand.
[[package]]
name = "asgiref"
@@ -586,5 +586,5 @@ files = [
[metadata]
lock-version = "2.1"
-python-versions = "^3.12"
-content-hash = "9576347c536499de99b323235e5722ecff72a250598b689f042441da6d57411c"
+python-versions = "^3.12 <3.14"
+content-hash = "34c827f5703228d41f7b807ccc2b5445c3a1dfc907729ba4c288a345201709b2"
diff --git a/server/pyproject.toml b/server/pyproject.toml
index 356eb1e..25d0d82 100644
--- a/server/pyproject.toml
+++ b/server/pyproject.toml
@@ -7,7 +7,7 @@ readme = "README.md"
package-mode = false
[tool.poetry.dependencies]
-python = "^3.12"
+python = "^3.12 <3.14"
Django = "^5.1"
djangorestframework = "^3.15.1"
django-cors-headers = "^4.3.1"