From 76e8c0019932865604afa0491573757f752a59e4 Mon Sep 17 00:00:00 2001 From: joelte Date: Sat, 10 Oct 2026 19:47:05 +0200 Subject: [PATCH 1/6] feat: Setup gamedetail site with static example data A simple details page for games for now with only one example that can be accessed by clicking the games in the list on the frontpage. --- frontend/package-lock.json | 58 ++++++++++++++ frontend/package.json | 1 + frontend/src/App.tsx | 13 +++- .../GameListItem/GameListItem.module.css | 2 + .../components/GameListItem/GameListItem.tsx | 10 ++- .../src/components/Header/Header.module.css | 3 - frontend/src/components/Header/Header.tsx | 9 ++- .../pages/GameDetails/GameDetails.module.css | 71 +++++++++++++++++ .../src/pages/GameDetails/GameDetails.tsx | 78 ++++++++++++++----- frontend/src/pages/GamesGrid/GamesGrid.tsx | 11 +-- 10 files changed, 221 insertions(+), 35 deletions(-) create mode 100644 frontend/src/pages/GameDetails/GameDetails.module.css diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0263763..70eef54 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,6 +15,7 @@ "react-bootstrap": "^2.10.10", "react-bootstrap-icons": "^1.11.6", "react-dom": "^19.2.8", + "react-router-dom": "^7.18.4", "rxjs": "^7.8.2" }, "devDependencies": { @@ -1717,6 +1718,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3036,6 +3050,44 @@ "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==", "license": "MIT" }, + "node_modules/react-router": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz", + "integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.4.tgz", + "integrity": "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.4" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/react-stately": { "version": "3.50.0", "resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.50.0.tgz", @@ -3128,6 +3180,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 99447be..aa70fdf 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,7 @@ "react-bootstrap": "^2.10.10", "react-bootstrap-icons": "^1.11.6", "react-dom": "^19.2.8", + "react-router-dom": "^7.18.4", "rxjs": "^7.8.2" }, "devDependencies": { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 235f3d9..02ad8fa 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,8 +1,19 @@ import "bootstrap/dist/css/bootstrap.min.css"; +import { BrowserRouter, Route, Routes } from "react-router-dom"; import GamesGrid from "./pages/GamesGrid/GamesGrid"; +import GameDetails from "./pages/GameDetails/GameDetails"; +import MyRatings from "./pages/MyRatings/MyRatings"; function App() { - return ; + return ( + + + } /> + } /> + } /> + + + ); } export default App; diff --git a/frontend/src/components/GameListItem/GameListItem.module.css b/frontend/src/components/GameListItem/GameListItem.module.css index 10da3c9..7c5a6c5 100644 --- a/frontend/src/components/GameListItem/GameListItem.module.css +++ b/frontend/src/components/GameListItem/GameListItem.module.css @@ -5,6 +5,8 @@ min-height: var(--row-min-height); gap: 1.5rem; padding: 0 1.5rem; + color: inherit; + text-decoration: none; } .odd { diff --git a/frontend/src/components/GameListItem/GameListItem.tsx b/frontend/src/components/GameListItem/GameListItem.tsx index e682427..a519556 100644 --- a/frontend/src/components/GameListItem/GameListItem.tsx +++ b/frontend/src/components/GameListItem/GameListItem.tsx @@ -1,6 +1,8 @@ +import { Link } from "react-router-dom"; import styles from "./GameListItem.module.css"; interface GameListItemProps { + id: string; title: string; year: number; tags: string; @@ -10,6 +12,7 @@ interface GameListItemProps { } export default function GameListItem({ + id, title, year, tags, @@ -18,7 +21,10 @@ export default function GameListItem({ striped, }: GameListItemProps) { return ( -
+ {title}

{title}

@@ -26,6 +32,6 @@ export default function GameListItem({

{tags}

{rating.toFixed(1)}/10

-
+ ); } diff --git a/frontend/src/components/Header/Header.module.css b/frontend/src/components/Header/Header.module.css index 7fde7a7..30c4aeb 100644 --- a/frontend/src/components/Header/Header.module.css +++ b/frontend/src/components/Header/Header.module.css @@ -13,9 +13,6 @@ font-weight: 700; color: var(--color-text); white-space: nowrap; -} - -.link { text-decoration: none; } diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx index b846e7f..881b33f 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -1,11 +1,14 @@ import { Button, Form, InputGroup } from "react-bootstrap"; import { Search } from "react-bootstrap-icons"; +import { Link } from "react-router-dom"; import styles from "./Header.module.css"; export default function Header() { return (
- Working Title + + Working Title +
@@ -19,9 +22,9 @@ export default function Header() {
- + My Ratings - +
); } diff --git a/frontend/src/pages/GameDetails/GameDetails.module.css b/frontend/src/pages/GameDetails/GameDetails.module.css new file mode 100644 index 0000000..b5d132f --- /dev/null +++ b/frontend/src/pages/GameDetails/GameDetails.module.css @@ -0,0 +1,71 @@ +.page { + padding: 1.5rem 0 3rem; +} + +.meta { + margin: 0; + color: var(--color-muted); +} + +.title { + margin: 0.25rem 0 1.25rem; +} + +.main { + display: flex; + gap: 2rem; + margin-bottom: 1.5rem; +} + +.cover { + width: 220px; + height: 220px; + object-fit: cover; + background-color: var(--color-stripe-odd); + flex-shrink: 0; +} + +.ratings { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.ratingRow { + margin: 0; +} + +.ratingLabel { + font-weight: 700; +} + +.genre { + margin: 0 0 1.5rem; + font-weight: 600; +} + +.infoRow { + display: flex; + gap: 0.5rem; + margin: 0; + padding: 0.75rem 0; + border-top: 1px solid var(--color-border); +} + +.infoLabel { + font-weight: 700; + white-space: nowrap; +} + +.description { + margin: 0; + padding: 0.75rem 0; + border-top: 1px solid var(--color-border); + line-height: 1.5; +} + +.descriptionLabel { + display: block; + font-weight: 700; + margin-bottom: 0.5rem; +} diff --git a/frontend/src/pages/GameDetails/GameDetails.tsx b/frontend/src/pages/GameDetails/GameDetails.tsx index 3415488..0b8e933 100644 --- a/frontend/src/pages/GameDetails/GameDetails.tsx +++ b/frontend/src/pages/GameDetails/GameDetails.tsx @@ -1,32 +1,68 @@ -import { Container, Image } from "react-bootstrap"; import Header from "../../components/Header/Header"; +import Layout from "../../components/Layout/Layout"; +import styles from "./GameDetails.module.css"; + +const game = { + title: "Heroes of Might and Magic: Olden Era", + year: 2026, + ageRating: "T", + genre: "Turn-based strategy", + image: "", + ntnuRating: 9.3, + yourRating: 8.9, + developer: "Unfrozen", + publishers: "Hooded Horse, Ubisoft", + platform: "Windows", + description: + "Olden Era is set on the same world as the first three games of the franchise, Enroth, happening before the events of Heroes of Might and Magic: A Strategic Quest on the continent of Jadame, which was the setting of Might and Magic VIII: Day of the Destroyer but has not been featured in any previous games of the spin-off series Heroes of Might and Magic before. The game contains a playable campaign mode, divided into several acts, telling a story about a minotaur Gunnar who uncovers a new threat to the world - the faction of Hive, which corrupts and destroys the lands of Jadame. In order to prevent future consequences, former rival factions across the continent must unite to oppose the leader of Hive - the Dragonfly King.", +}; export default function GameDetails() { return ( <>
- - Release year | Age rating -

Game Title

-
- Game Image -
-
NTNU Rating:
-

Your Rating:

+ +
+

+ {game.year} | Age Rating: {game.ageRating} +

+

{game.title}

+ +
+ {game.title} +
+

+ NTNU Rating:{" "} + {game.ntnuRating.toFixed(1)}/10 +

+

+ Your Rating:{" "} + {game.yourRating.toFixed(1)}/10 +

+
+ +

{game.genre}

+ +

+ Developer: + {game.developer} +

+

+ Publishers: + {game.publishers} +

+

+ Platform: + {game.platform} +

+ +

+ Description + {game.description} +

-
Tags:
-
Developer(s):
-
Publisher(s):
-
Platform(s):
-

Description

- +
); } diff --git a/frontend/src/pages/GamesGrid/GamesGrid.tsx b/frontend/src/pages/GamesGrid/GamesGrid.tsx index 93c6aff..f849d0a 100644 --- a/frontend/src/pages/GamesGrid/GamesGrid.tsx +++ b/frontend/src/pages/GamesGrid/GamesGrid.tsx @@ -6,6 +6,7 @@ import styles from "./GamesGrid.module.css"; const games = [ { + id: "heroes-of-might-and-magic-olden-era", title: "Heroes of Might and Magic: Olden Era", year: 2026, tags: "Turn-based strategy", @@ -13,6 +14,7 @@ const games = [ image: "", }, { + id: "minecraft", title: "Minecraft", year: 2009, tags: "Indie, Survival, Adventure, Sandbox", @@ -20,6 +22,7 @@ const games = [ image: "", }, { + id: "ori-and-the-blind-forest", title: "Ori and the Blind Forest", year: 2015, tags: "Metroidvania", @@ -27,6 +30,7 @@ const games = [ image: "", }, { + id: "pokemon-legends-z-a", title: "Pokémon Legends: Z-A", year: 2025, tags: "Action RPG", @@ -34,6 +38,7 @@ const games = [ image: "", }, { + id: "hades", title: "Hades", year: 2020, tags: "Roguelike", @@ -50,11 +55,7 @@ export default function GamesGrid() {
{games.map((game, index) => ( - + ))}
From d5cde4f80229e81551749fb78bb7df65ff412a6b Mon Sep 17 00:00:00 2001 From: joelte Date: Sat, 10 Oct 2026 20:05:17 +0200 Subject: [PATCH 2/6] feat: Wire frontend to backend Made frontend fetch data from the backend to fill in the grid and details page (only fetching the necessary games at a time) --- frontend/src/apolloClient.ts | 9 + .../components/GameListItem/GameListItem.tsx | 10 +- .../src/components/Toolbar/Toolbar.module.css | 15 ++ frontend/src/components/Toolbar/Toolbar.tsx | 38 ++++- frontend/src/main.tsx | 6 +- .../pages/GameDetails/GameDetails.module.css | 4 + .../src/pages/GameDetails/GameDetails.tsx | 140 ++++++++++------ .../src/pages/GamesGrid/GamesGrid.module.css | 5 + frontend/src/pages/GamesGrid/GamesGrid.tsx | 155 +++++++++++++----- 9 files changed, 281 insertions(+), 101 deletions(-) create mode 100644 frontend/src/apolloClient.ts diff --git a/frontend/src/apolloClient.ts b/frontend/src/apolloClient.ts new file mode 100644 index 0000000..a5f5653 --- /dev/null +++ b/frontend/src/apolloClient.ts @@ -0,0 +1,9 @@ +import { ApolloClient, InMemoryCache } from "@apollo/client"; +import { HttpLink } from "@apollo/client/link/http"; + +const uri = import.meta.env.VITE_GRAPHQL_URL ?? "http://localhost:3001"; + +export const apolloClient = new ApolloClient({ + link: new HttpLink({ uri }), + cache: new InMemoryCache(), +}); diff --git a/frontend/src/components/GameListItem/GameListItem.tsx b/frontend/src/components/GameListItem/GameListItem.tsx index a519556..a3801e6 100644 --- a/frontend/src/components/GameListItem/GameListItem.tsx +++ b/frontend/src/components/GameListItem/GameListItem.tsx @@ -4,9 +4,10 @@ import styles from "./GameListItem.module.css"; interface GameListItemProps { id: string; title: string; - year: number; + year: number | null; tags: string; rating: number; + reviewCount: number; image: string; striped: boolean; } @@ -17,6 +18,7 @@ export default function GameListItem({ year, tags, rating, + reviewCount, image, striped, }: GameListItemProps) { @@ -28,9 +30,9 @@ export default function GameListItem({ {title}

{title}

-

{year}

-

{tags}

-

{rating.toFixed(1)}/10

+

{year ?? "—"}

+

{tags || "—"}

+

{reviewCount > 0 ? `${rating.toFixed(1)}/10` : "Not yet rated"}

); diff --git a/frontend/src/components/Toolbar/Toolbar.module.css b/frontend/src/components/Toolbar/Toolbar.module.css index 51f876a..26f771f 100644 --- a/frontend/src/components/Toolbar/Toolbar.module.css +++ b/frontend/src/components/Toolbar/Toolbar.module.css @@ -26,6 +26,21 @@ gap: 0.75rem; } +.pageButton { + display: inline-flex; + align-items: center; + border: none; + background: none; + color: inherit; + font: inherit; + cursor: pointer; +} + +.pageButton:disabled { + opacity: 0.35; + cursor: default; +} + .sort { display: inline-flex; align-items: center; diff --git a/frontend/src/components/Toolbar/Toolbar.tsx b/frontend/src/components/Toolbar/Toolbar.tsx index e2eadbb..b3184cf 100644 --- a/frontend/src/components/Toolbar/Toolbar.tsx +++ b/frontend/src/components/Toolbar/Toolbar.tsx @@ -7,7 +7,19 @@ import { } from "react-bootstrap-icons"; import styles from "./Toolbar.module.css"; -export default function Toolbar() { +interface ToolbarProps { + page: number; + totalPages: number; + onPrevPage: () => void; + onNextPage: () => void; +} + +export default function Toolbar({ + page, + totalPages, + onPrevPage, + onNextPage, +}: ToolbarProps) { return (
- - Page 1 - + + + Page {page} of {totalPages} + +
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 15753af..43070dd 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,10 +1,14 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; +import { ApolloProvider } from "@apollo/client/react"; import "./index.css"; import App from "./App"; +import { apolloClient } from "./apolloClient"; createRoot(document.getElementById("root")!).render( - + + + , ); diff --git a/frontend/src/pages/GameDetails/GameDetails.module.css b/frontend/src/pages/GameDetails/GameDetails.module.css index b5d132f..b4d9580 100644 --- a/frontend/src/pages/GameDetails/GameDetails.module.css +++ b/frontend/src/pages/GameDetails/GameDetails.module.css @@ -2,6 +2,10 @@ padding: 1.5rem 0 3rem; } +.message { + color: var(--color-muted); +} + .meta { margin: 0; color: var(--color-muted); diff --git a/frontend/src/pages/GameDetails/GameDetails.tsx b/frontend/src/pages/GameDetails/GameDetails.tsx index 0b8e933..6c38341 100644 --- a/frontend/src/pages/GameDetails/GameDetails.tsx +++ b/frontend/src/pages/GameDetails/GameDetails.tsx @@ -1,66 +1,112 @@ +import { useParams } from "react-router-dom"; +import { gql } from "@apollo/client"; +import { useQuery } from "@apollo/client/react"; import Header from "../../components/Header/Header"; import Layout from "../../components/Layout/Layout"; import styles from "./GameDetails.module.css"; -const game = { - title: "Heroes of Might and Magic: Olden Era", - year: 2026, - ageRating: "T", - genre: "Turn-based strategy", - image: "", - ntnuRating: 9.3, - yourRating: 8.9, - developer: "Unfrozen", - publishers: "Hooded Horse, Ubisoft", - platform: "Windows", - description: - "Olden Era is set on the same world as the first three games of the franchise, Enroth, happening before the events of Heroes of Might and Magic: A Strategic Quest on the continent of Jadame, which was the setting of Might and Magic VIII: Day of the Destroyer but has not been featured in any previous games of the spin-off series Heroes of Might and Magic before. The game contains a playable campaign mode, divided into several acts, telling a story about a minotaur Gunnar who uncovers a new threat to the world - the faction of Hive, which corrupts and destroys the lands of Jadame. In order to prevent future consequences, former rival factions across the continent must unite to oppose the leader of Hive - the Dragonfly King.", -}; +const GET_GAME = gql` + query GetGame($id: ID!) { + game(id: $id) { + id + name + imageUrl + releaseDate + esrbRating + tags + developers + publishers + platforms + averageScore + reviewCount + description + } + } +`; + +interface GameData { + game: { + id: string; + name: string; + imageUrl: string | null; + releaseDate: string | null; + esrbRating: string | null; + tags: string[]; + developers: string[]; + publishers: string[]; + platforms: string[]; + averageScore: number; + reviewCount: number; + description: string | null; + } | null; +} export default function GameDetails() { + const { id } = useParams<{ id: string }>(); + const { data, loading, error } = useQuery(GET_GAME, { + variables: { id }, + skip: !id, + }); + + const game = data?.game; + return ( <>
-

- {game.year} | Age Rating: {game.ageRating} -

-

{game.title}

+ {loading && !data &&

Loading…

} + {!loading && (error || !game) && ( +

Game not found.

+ )} -
- {game.title} -
-

- NTNU Rating:{" "} - {game.ntnuRating.toFixed(1)}/10 -

-

- Your Rating:{" "} - {game.yourRating.toFixed(1)}/10 + {game && ( + <> +

+ {game.releaseDate + ? new Date(game.releaseDate).getFullYear() + : "Unknown year"}{" "} + | Age Rating: {game.esrbRating || "Not rated"}

-
-
+

{game.name}

-

{game.genre}

+
+ {game.name} +
+

+ NTNU Rating:{" "} + {game.reviewCount > 0 + ? `${game.averageScore.toFixed(1)}/10` + : "Not yet rated"} +

+
+
-

- Developer: - {game.developer} -

-

- Publishers: - {game.publishers} -

-

- Platform: - {game.platform} -

+

{game.tags.join(", ") || "—"}

-

- Description - {game.description} -

+

+ Developer: + {game.developers.join(", ") || "—"} +

+

+ Publishers: + {game.publishers.join(", ") || "—"} +

+

+ Platform: + {game.platforms.join(", ") || "—"} +

+ +

+ Description + {game.description || "No description available."} +

+ + )}
diff --git a/frontend/src/pages/GamesGrid/GamesGrid.module.css b/frontend/src/pages/GamesGrid/GamesGrid.module.css index 4dd75cc..04fb368 100644 --- a/frontend/src/pages/GamesGrid/GamesGrid.module.css +++ b/frontend/src/pages/GamesGrid/GamesGrid.module.css @@ -4,3 +4,8 @@ height: calc(100vh - var(--header-height) - var(--toolbar-height)); overflow: hidden; } + +.message { + margin: 1.5rem; + color: var(--color-muted); +} diff --git a/frontend/src/pages/GamesGrid/GamesGrid.tsx b/frontend/src/pages/GamesGrid/GamesGrid.tsx index f849d0a..1f71455 100644 --- a/frontend/src/pages/GamesGrid/GamesGrid.tsx +++ b/frontend/src/pages/GamesGrid/GamesGrid.tsx @@ -1,61 +1,126 @@ +import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { gql } from "@apollo/client"; +import { useQuery } from "@apollo/client/react"; import Header from "../../components/Header/Header"; import Layout from "../../components/Layout/Layout"; import Toolbar from "../../components/Toolbar/Toolbar"; import GameListItem from "../../components/GameListItem/GameListItem"; import styles from "./GamesGrid.module.css"; -const games = [ - { - id: "heroes-of-might-and-magic-olden-era", - title: "Heroes of Might and Magic: Olden Era", - year: 2026, - tags: "Turn-based strategy", - rating: 9.3, - image: "", - }, - { - id: "minecraft", - title: "Minecraft", - year: 2009, - tags: "Indie, Survival, Adventure, Sandbox", - rating: 9.1, - image: "", - }, - { - id: "ori-and-the-blind-forest", - title: "Ori and the Blind Forest", - year: 2015, - tags: "Metroidvania", - rating: 8.8, - image: "", - }, - { - id: "pokemon-legends-z-a", - title: "Pokémon Legends: Z-A", - year: 2025, - tags: "Action RPG", - rating: 0.1, - image: "", - }, - { - id: "hades", - title: "Hades", - year: 2020, - tags: "Roguelike", - rating: 9.4, - image: "", - }, -]; +const GET_GAMES = gql` + query GetGames($offset: Int, $limit: Int) { + games(offset: $offset, limit: $limit) { + totalCount + games { + id + name + imageUrl + releaseDate + tags + averageScore + reviewCount + } + } + } +`; + +interface GamesData { + games: { + totalCount: number; + games: { + id: string; + name: string; + imageUrl: string | null; + releaseDate: string | null; + tags: string[]; + averageScore: number; + reviewCount: number; + }[]; + }; +} + +const PAGE_STORAGE_KEY = "games-grid-page"; + +function getStoredPage() { + const stored = Number(sessionStorage.getItem(PAGE_STORAGE_KEY)); + return Number.isInteger(stored) && stored > 0 ? stored : 1; +} export default function GamesGrid() { + const listRef = useRef(null); + const [rawPage, setPage] = useState(getStoredPage); + // Unknown until measured, so we don't fetch a page size before the list + // area has actually been laid out. + const [pageSize, setPageSize] = useState(null); + + // Pages are sized to however many rows actually fit in the visible list + // area, so we only ever need to fetch that many games from the backend. + useLayoutEffect(() => { + const listEl = listRef.current; + if (!listEl) return; + + const rowMinHeight = + parseFloat( + getComputedStyle(listEl).getPropertyValue("--row-min-height"), + ) || 1; + + const updatePageSize = () => { + setPageSize(Math.max(1, Math.floor(listEl.clientHeight / rowMinHeight))); + }; + + updatePageSize(); + const observer = new ResizeObserver(updatePageSize); + observer.observe(listEl); + return () => observer.disconnect(); + }, []); + + const { data, loading, error } = useQuery(GET_GAMES, { + variables: { offset: ((rawPage - 1) * (pageSize ?? 0)), limit: pageSize }, + skip: pageSize === null, + }); + + const totalCount = data?.games.totalCount ?? 0; + const totalPages = pageSize ? Math.max(1, Math.ceil(totalCount / pageSize)) : 1; + // Clamp at render time (e.g. after the viewport grows and more rows fit) + // instead of in an effect, so a shrinking page count never needs a second render. + const page = Math.min(rawPage, totalPages); + const games = data?.games.games ?? []; + + useEffect(() => { + sessionStorage.setItem(PAGE_STORAGE_KEY, String(page)); + }, [page]); + return ( <>
- -
+ setPage((p) => Math.max(1, p - 1))} + onNextPage={() => setPage((p) => Math.min(totalPages, p + 1))} + /> +
+ {error &&

Failed to load games.

} + {!error && loading && !data && ( +

Loading games…

+ )} {games.map((game, index) => ( - + ))}
From 9b4db1ecd4386bb03eb49b9cd8e4928e657d93a6 Mon Sep 17 00:00:00 2001 From: joelte Date: Sat, 10 Oct 2026 20:46:57 +0200 Subject: [PATCH 3/6] feat: Add filter functionality Added filter functionality to search sort and filter games on the mainpage in accordance with the described functionality in README. --- frontend/src/components/Header/Header.tsx | 50 ++- .../src/components/Toolbar/Toolbar.module.css | 160 +++++++++ frontend/src/components/Toolbar/Toolbar.tsx | 311 +++++++++++++++++- frontend/src/constants/filterOptions.ts | 82 +++++ frontend/src/constants/storageKeys.ts | 2 + .../src/pages/GamesGrid/GamesGrid.module.css | 9 + frontend/src/pages/GamesGrid/GamesGrid.tsx | 181 +++++++++- frontend/src/types/gameQuery.ts | 46 +++ 8 files changed, 820 insertions(+), 21 deletions(-) create mode 100644 frontend/src/constants/filterOptions.ts create mode 100644 frontend/src/constants/storageKeys.ts create mode 100644 frontend/src/types/gameQuery.ts diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx index 881b33f..1112f60 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -1,22 +1,64 @@ +import { useState } from "react"; +import type { FormEvent } from "react"; import { Button, Form, InputGroup } from "react-bootstrap"; import { Search } from "react-bootstrap-icons"; -import { Link } from "react-router-dom"; +import { + Link, + useLocation, + useNavigate, + useSearchParams, +} from "react-router-dom"; +import { GRID_QUERY_STORAGE_KEY } from "../../constants/storageKeys"; import styles from "./Header.module.css"; export default function Header() { + const navigate = useNavigate(); + const location = useLocation(); + const [searchParams] = useSearchParams(); + const committedSearch = searchParams.get("search") ?? ""; + const [value, setValue] = useState(committedSearch); + + // From other pages, fall back to the grid's last persisted query. + const homeQuery = + location.pathname === "/" + ? searchParams.toString() + : sessionStorage.getItem(GRID_QUERY_STORAGE_KEY) ?? ""; + const homeHref = homeQuery ? `/?${homeQuery}` : "/"; + + // Adjusted during render, not an effect, so in-progress typing isn't clobbered. + const [prevCommittedSearch, setPrevCommittedSearch] = useState(committedSearch); + if (committedSearch !== prevCommittedSearch) { + setPrevCommittedSearch(committedSearch); + setValue(committedSearch); + } + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + const params = new URLSearchParams(searchParams); + const trimmed = value.trim(); + if (trimmed) { + params.set("search", trimmed); + } else { + params.delete("search"); + } + navigate(`/?${params.toString()}`); + } + return (
- + Working Title -
+ setValue(event.target.value)} /> - diff --git a/frontend/src/components/Toolbar/Toolbar.module.css b/frontend/src/components/Toolbar/Toolbar.module.css index 26f771f..a6c7c4e 100644 --- a/frontend/src/components/Toolbar/Toolbar.module.css +++ b/frontend/src/components/Toolbar/Toolbar.module.css @@ -7,6 +7,11 @@ background-color: #fff; border-bottom: 1px solid var(--color-border); color: var(--color-muted); + position: relative; +} + +.menu { + position: relative; } .action { @@ -20,6 +25,13 @@ cursor: pointer; } +.badge { + width: 0.4rem; + height: 0.4rem; + border-radius: 50%; + background-color: var(--color-brand); +} + .pagination { display: inline-flex; align-items: center; @@ -46,3 +58,151 @@ align-items: center; gap: 1rem; } + +.panel { + position: absolute; + top: calc(100% + 0.5rem); + z-index: 10; + background-color: #fff; + border: 1px solid var(--color-border); + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); + color: var(--color-text); + font-size: 0.9rem; +} + +.sort .panel { + right: 0; +} + +.panelHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--color-border); + font-weight: 700; +} + +.iconButton { + display: inline-flex; + border: none; + background: none; + color: inherit; + cursor: pointer; +} + +.panelColumns { + display: flex; + gap: 1.5rem; + padding: 1rem; +} + +.panelGroup { + border: none; + margin: 0; + padding: 0; + min-width: 180px; +} + +.panelGroup legend { + font-weight: 700; + margin-bottom: 0.5rem; + padding: 0; +} + +.checkList { + display: flex; + flex-direction: column; + gap: 0.35rem; + max-height: 220px; + overflow-y: auto; + padding-right: 0.5rem; +} + +.checkOption { + display: flex; + align-items: center; + gap: 0.5rem; + font-weight: 400; + white-space: nowrap; +} + +.panelRow { + display: flex; + flex-wrap: wrap; + gap: 1rem; + padding: 0 1rem 1rem; + border-top: 1px solid var(--color-border); + padding-top: 1rem; +} + +.field { + display: flex; + flex-direction: column; + gap: 0.35rem; + font-weight: 700; + font-size: 0.85rem; +} + +.field select, +.field input[type="number"] { + font: inherit; + padding: 0.3rem 0.5rem; + border: 1px solid var(--color-border); + border-radius: 4px; + width: 110px; +} + +.panelActions { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + padding: 1rem; + border-top: 1px solid var(--color-border); +} + +.primaryButton, +.secondaryButton { + font: inherit; + font-weight: 700; + padding: 0.4rem 1rem; + border-radius: 999px; + cursor: pointer; +} + +.primaryButton { + border: none; + background-color: var(--color-brand); + color: #fff; +} + +.secondaryButton { + border: 1px solid var(--color-border); + background: none; + color: inherit; +} + +.menuItem { + display: block; + width: 100%; + text-align: left; + white-space: nowrap; + border: none; + background: none; + font: inherit; + color: inherit; + padding: 0.5rem 1rem; + cursor: pointer; +} + +.menuItem:hover, +.menuItem:focus-visible { + background-color: var(--color-stripe-even); +} + +.menuItem[aria-checked="true"] { + font-weight: 700; + color: var(--color-brand); +} diff --git a/frontend/src/components/Toolbar/Toolbar.tsx b/frontend/src/components/Toolbar/Toolbar.tsx index b3184cf..5857b57 100644 --- a/frontend/src/components/Toolbar/Toolbar.tsx +++ b/frontend/src/components/Toolbar/Toolbar.tsx @@ -1,10 +1,21 @@ +import { useEffect, useRef, useState } from "react"; +import type { ChangeEvent } from "react"; import { ArrowDownUp, ChevronDown, ChevronLeft, ChevronRight, Funnel, + X, } from "react-bootstrap-icons"; +import { + ESRB_OPTIONS, + PLATFORM_OPTIONS, + SORT_FIELD_OPTIONS, + TAG_OPTIONS, +} from "../../constants/filterOptions"; +import type { GameFilters, SortField, SortState } from "../../types/gameQuery"; +import { EMPTY_FILTERS, hasActiveFilters } from "../../types/gameQuery"; import styles from "./Toolbar.module.css"; interface ToolbarProps { @@ -12,20 +23,80 @@ interface ToolbarProps { totalPages: number; onPrevPage: () => void; onNextPage: () => void; + filters: GameFilters; + onFiltersChange: (filters: GameFilters) => void; + sort: SortState; + onSortChange: (sort: SortState) => void; } +type OpenPanel = "filter" | "sort" | null; + export default function Toolbar({ page, totalPages, onPrevPage, onNextPage, + filters, + onFiltersChange, + sort, + onSortChange, }: ToolbarProps) { + const [openPanel, setOpenPanel] = useState(null); + const containerRef = useRef(null); + + useEffect(() => { + if (!openPanel) return; + + function handlePointerDown(event: MouseEvent) { + if (!containerRef.current?.contains(event.target as Node)) { + setOpenPanel(null); + } + } + + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") setOpenPanel(null); + } + + document.addEventListener("mousedown", handlePointerDown); + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("mousedown", handlePointerDown); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [openPanel]); + + const sortLabel = + SORT_FIELD_OPTIONS.find((option) => option.value === sort.field)?.label ?? + "Name"; + return ( -
- +
+
+ + {openPanel === "filter" && ( + { + onFiltersChange(next); + setOpenPanel(null); + }} + onClose={() => setOpenPanel(null)} + /> + )} +
-
- + +
+ {openPanel === "sort" && ( + { + onSortChange({ ...sort, field }); + setOpenPanel(null); + }} + /> + )} +
+
+ ); +} + +function toggleValue(list: string[], value: string): string[] { + return list.includes(value) + ? list.filter((item) => item !== value) + : [...list, value]; +} + +interface FilterPanelProps { + filters: GameFilters; + onApply: (filters: GameFilters) => void; + onClose: () => void; +} + +function FilterPanel({ filters, onApply, onClose }: FilterPanelProps) { + const [draft, setDraft] = useState(filters); + + return ( +
+
+ Filters + +
+ +
+
+ Genre +
+ {TAG_OPTIONS.map((tag) => ( + + ))} +
+
+ +
+ Platform +
+ {PLATFORM_OPTIONS.map((platform) => ( + + ))} +
+
+
+ +
+ + + + + + + +
+ +
+ + -
); } + +interface SortMenuProps { + sort: SortState; + onSelect: (field: SortField) => void; +} + +function SortMenu({ sort, onSelect }: SortMenuProps) { + return ( +
+ {SORT_FIELD_OPTIONS.map((option) => ( + + ))} +
+ ); +} diff --git a/frontend/src/constants/filterOptions.ts b/frontend/src/constants/filterOptions.ts new file mode 100644 index 0000000..e876034 --- /dev/null +++ b/frontend/src/constants/filterOptions.ts @@ -0,0 +1,82 @@ +// Genres, platforms and ESRB ratings are a fixed vocabulary in the dataset, +// so the filter panel offers closed lists instead of deriving options from +// (sometimes malformed) raw rows. +export const TAG_OPTIONS = [ + "Action", + "Adventure", + "Arcade", + "Board Games", + "Card", + "Casual", + "Educational", + "Family", + "Fighting", + "Indie", + "Massively Multiplayer", + "Platformer", + "Puzzle", + "RPG", + "Racing", + "Shooter", + "Simulation", + "Sports", + "Strategy", +] as const; + +export const PLATFORM_OPTIONS = [ + "PC", + "PlayStation", + "PlayStation 2", + "PlayStation 3", + "PlayStation 4", + "PlayStation 5", + "Xbox", + "Xbox 360", + "Xbox One", + "Nintendo 64", + "Nintendo DS", + "Nintendo DSi", + "Nintendo 3DS", + "Nintendo Switch", + "GameCube", + "Wii", + "Wii U", + "SNES", + "NES", + "Game Boy", + "Game Boy Color", + "Game Boy Advance", + "Game Gear", + "Genesis", + "SEGA CD", + "SEGA Master System", + "Neo Geo", + "3DO", + "Dreamcast", + "PSP", + "PS Vita", + "Atari ST", + "Commodore / Amiga", + "Classic Macintosh", + "macOS", + "Linux", + "Android", + "iOS", + "Web", +] as const; + +export const ESRB_OPTIONS = [ + "Everyone", + "Everyone 10+", + "Teen", + "Mature", + "Adults Only", + "Rating Pending", +] as const; + +export const SORT_FIELD_OPTIONS = [ + { value: "NAME", label: "Name" }, + { value: "RELEASE_DATE", label: "Release date" }, + { value: "AVERAGE_SCORE", label: "Average score" }, + { value: "REVIEW_COUNT", label: "Number of reviews" }, +] as const; diff --git a/frontend/src/constants/storageKeys.ts b/frontend/src/constants/storageKeys.ts new file mode 100644 index 0000000..dfb5e28 --- /dev/null +++ b/frontend/src/constants/storageKeys.ts @@ -0,0 +1,2 @@ +export const GRID_PAGE_STORAGE_KEY = "games-grid-page"; +export const GRID_QUERY_STORAGE_KEY = "games-grid-query"; diff --git a/frontend/src/pages/GamesGrid/GamesGrid.module.css b/frontend/src/pages/GamesGrid/GamesGrid.module.css index 04fb368..d4f6a48 100644 --- a/frontend/src/pages/GamesGrid/GamesGrid.module.css +++ b/frontend/src/pages/GamesGrid/GamesGrid.module.css @@ -9,3 +9,12 @@ margin: 1.5rem; color: var(--color-muted); } + +.srOnly { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; +} diff --git a/frontend/src/pages/GamesGrid/GamesGrid.tsx b/frontend/src/pages/GamesGrid/GamesGrid.tsx index 1f71455..e960ec6 100644 --- a/frontend/src/pages/GamesGrid/GamesGrid.tsx +++ b/frontend/src/pages/GamesGrid/GamesGrid.tsx @@ -1,15 +1,41 @@ -import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { gql } from "@apollo/client"; import { useQuery } from "@apollo/client/react"; +import { useSearchParams } from "react-router-dom"; import Header from "../../components/Header/Header"; import Layout from "../../components/Layout/Layout"; import Toolbar from "../../components/Toolbar/Toolbar"; import GameListItem from "../../components/GameListItem/GameListItem"; +import { + GRID_PAGE_STORAGE_KEY, + GRID_QUERY_STORAGE_KEY, +} from "../../constants/storageKeys"; +import { + DEFAULT_SORT, + hasActiveFilters, + type GameFilters, + type SortField, + type SortState, +} from "../../types/gameQuery"; import styles from "./GamesGrid.module.css"; const GET_GAMES = gql` - query GetGames($offset: Int, $limit: Int) { - games(offset: $offset, limit: $limit) { + query GetGames( + $search: String + $filter: GameFilter + $sortBy: SortField + $sortOrder: SortOrder + $offset: Int + $limit: Int + ) { + games( + search: $search + filter: $filter + sortBy: $sortBy + sortOrder: $sortOrder + offset: $offset + limit: $limit + ) { totalCount games { id @@ -39,19 +65,130 @@ interface GamesData { }; } -const PAGE_STORAGE_KEY = "games-grid-page"; +const SORT_FIELDS: readonly SortField[] = [ + "NAME", + "RELEASE_DATE", + "AVERAGE_SCORE", + "REVIEW_COUNT", +]; function getStoredPage() { - const stored = Number(sessionStorage.getItem(PAGE_STORAGE_KEY)); + const stored = Number(sessionStorage.getItem(GRID_PAGE_STORAGE_KEY)); return Number.isInteger(stored) && stored > 0 ? stored : 1; } +function parseIntParam(value: string | null): number | null { + if (!value) return null; + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : null; +} + +function parseFloatParam(value: string | null): number | null { + if (!value) return null; + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function parseListParam(value: string | null): string[] { + if (!value) return []; + return value.split(",").filter(Boolean); +} + +function parseFiltersFromParams(params: URLSearchParams): GameFilters { + return { + tags: parseListParam(params.get("tags")), + platforms: parseListParam(params.get("platforms")), + esrbRating: params.get("esrb") || null, + yearFrom: parseIntParam(params.get("yearFrom")), + yearTo: parseIntParam(params.get("yearTo")), + minScore: parseFloatParam(params.get("minScore")), + }; +} + +function parseSortFromParams(params: URLSearchParams): SortState { + const field = params.get("sortBy"); + const order = params.get("sortOrder"); + return { + field: SORT_FIELDS.includes(field as SortField) + ? (field as SortField) + : DEFAULT_SORT.field, + order: order === "DESC" ? "DESC" : DEFAULT_SORT.order, + }; +} + export default function GamesGrid() { const listRef = useRef(null); const [rawPage, setPage] = useState(getStoredPage); // Unknown until measured, so we don't fetch a page size before the list // area has actually been laid out. const [pageSize, setPageSize] = useState(null); + const [searchParams, setSearchParams] = useSearchParams(); + + const search = searchParams.get("search") ?? ""; + const filters = useMemo( + () => parseFiltersFromParams(searchParams), + [searchParams], + ); + const sort = useMemo(() => parseSortFromParams(searchParams), [searchParams]); + + // Reset to page 1 whenever search, filters or sort change. + const queryKey = searchParams.toString(); + const prevQueryKeyRef = useRef(queryKey); + useEffect(() => { + if (prevQueryKeyRef.current !== queryKey) { + prevQueryKeyRef.current = queryKey; + setPage(1); + } + }, [queryKey]); + + // Persisted so the header's title link can restore it from other routes. + useEffect(() => { + sessionStorage.setItem(GRID_QUERY_STORAGE_KEY, queryKey); + }, [queryKey]); + + function updateFilters(nextFilters: GameFilters) { + const params = new URLSearchParams(searchParams); + + if (nextFilters.tags.length) { + params.set("tags", nextFilters.tags.join(",")); + } else { + params.delete("tags"); + } + if (nextFilters.platforms.length) { + params.set("platforms", nextFilters.platforms.join(",")); + } else { + params.delete("platforms"); + } + if (nextFilters.esrbRating) { + params.set("esrb", nextFilters.esrbRating); + } else { + params.delete("esrb"); + } + if (nextFilters.yearFrom != null) { + params.set("yearFrom", String(nextFilters.yearFrom)); + } else { + params.delete("yearFrom"); + } + if (nextFilters.yearTo != null) { + params.set("yearTo", String(nextFilters.yearTo)); + } else { + params.delete("yearTo"); + } + if (nextFilters.minScore != null) { + params.set("minScore", String(nextFilters.minScore)); + } else { + params.delete("minScore"); + } + + setSearchParams(params); + } + + function updateSort(nextSort: SortState) { + const params = new URLSearchParams(searchParams); + params.set("sortBy", nextSort.field); + params.set("sortOrder", nextSort.order); + setSearchParams(params); + } // Pages are sized to however many rows actually fit in the visible list // area, so we only ever need to fetch that many games from the backend. @@ -75,7 +212,21 @@ export default function GamesGrid() { }, []); const { data, loading, error } = useQuery(GET_GAMES, { - variables: { offset: ((rawPage - 1) * (pageSize ?? 0)), limit: pageSize }, + variables: { + search: search || undefined, + filter: { + tags: filters.tags.length ? filters.tags : undefined, + platforms: filters.platforms.length ? filters.platforms : undefined, + esrbRating: filters.esrbRating ?? undefined, + releaseYearFrom: filters.yearFrom ?? undefined, + releaseYearTo: filters.yearTo ?? undefined, + minScore: filters.minScore ?? undefined, + }, + sortBy: sort.field, + sortOrder: sort.order, + offset: (rawPage - 1) * (pageSize ?? 0), + limit: pageSize, + }, skip: pageSize === null, }); @@ -87,7 +238,7 @@ export default function GamesGrid() { const games = data?.games.games ?? []; useEffect(() => { - sessionStorage.setItem(PAGE_STORAGE_KEY, String(page)); + sessionStorage.setItem(GRID_PAGE_STORAGE_KEY, String(page)); }, [page]); return ( @@ -99,12 +250,28 @@ export default function GamesGrid() { totalPages={totalPages} onPrevPage={() => setPage((p) => Math.max(1, p - 1))} onNextPage={() => setPage((p) => Math.min(totalPages, p + 1))} + filters={filters} + onFiltersChange={updateFilters} + sort={sort} + onSortChange={updateSort} />
+

+ {!loading && `${totalCount} game${totalCount === 1 ? "" : "s"} found`} +

{error &&

Failed to load games.

} {!error && loading && !data && (

Loading games…

)} + {!error && !loading && games.length === 0 && ( +

+ No games match{" "} + {hasActiveFilters(filters) || search + ? "these filters" + : "the catalog"} + . +

+ )} {games.map((game, index) => ( 0 || + filters.platforms.length > 0 || + filters.esrbRating !== null || + filters.yearFrom !== null || + filters.yearTo !== null || + filters.minScore !== null + ); +} From 6c694ded108613acb4dc687c32fb403369a4f2dc Mon Sep 17 00:00:00 2001 From: joelte Date: Sat, 10 Oct 2026 20:59:14 +0200 Subject: [PATCH 4/6] feat: Rework the filters to be responsive Ensured the filters are usabel and responisve for smaller screen sizes. --- .../src/components/Toolbar/Toolbar.module.css | 68 ++++++++++++++++++- frontend/src/components/Toolbar/Toolbar.tsx | 5 +- frontend/src/index.css | 10 ++- 3 files changed, 79 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/Toolbar/Toolbar.module.css b/frontend/src/components/Toolbar/Toolbar.module.css index a6c7c4e..0511170 100644 --- a/frontend/src/components/Toolbar/Toolbar.module.css +++ b/frontend/src/components/Toolbar/Toolbar.module.css @@ -1,7 +1,7 @@ .toolbar { - display: flex; + display: grid; + grid-template-columns: 1fr auto 1fr; align-items: center; - justify-content: space-between; height: var(--toolbar-height); padding: 0 1.5rem; background-color: #fff; @@ -14,6 +14,14 @@ position: relative; } +.toolbar > .menu:first-child { + justify-self: start; +} + +.toolbar > .menu:last-child { + justify-self: end; +} + .action { display: inline-flex; align-items: center; @@ -35,6 +43,7 @@ .pagination { display: inline-flex; align-items: center; + justify-self: center; gap: 0.75rem; } @@ -59,6 +68,12 @@ gap: 1rem; } +.sortText { + display: inline-flex; + align-items: center; + gap: 0.3rem; +} + .panel { position: absolute; top: calc(100% + 0.5rem); @@ -206,3 +221,52 @@ font-weight: 700; color: var(--color-brand); } + +@media (max-width: 600px) { + .toolbar { + padding: 0 0.75rem; + } + + .sortText { + flex-direction: column; + align-items: flex-start; + gap: 0; + line-height: 1.15; + } + + .sortPrefix { + font-size: 0.7rem; + text-transform: uppercase; + opacity: 0.75; + } + + .panel { + width: min(280px, calc(100vw - 2rem)); + max-height: 70vh; + overflow-y: auto; + } + + .panelColumns { + flex-direction: column; + gap: 1rem; + } + + .panelGroup { + min-width: 0; + width: 100%; + } + + .checkList { + max-height: 160px; + } + + .panelRow { + flex-direction: column; + } + + .field, + .field select, + .field input[type="number"] { + width: 100%; + } +} diff --git a/frontend/src/components/Toolbar/Toolbar.tsx b/frontend/src/components/Toolbar/Toolbar.tsx index 5857b57..0d74ea9 100644 --- a/frontend/src/components/Toolbar/Toolbar.tsx +++ b/frontend/src/components/Toolbar/Toolbar.tsx @@ -130,7 +130,10 @@ export default function Toolbar({ aria-expanded={openPanel === "sort"} onClick={() => setOpenPanel((p) => (p === "sort" ? null : "sort"))} > - Sort by: {sortLabel} + + Sort by + {sortLabel} + + {openPanel === "filter" && (
- + Page {page} of {totalPages} - +
- - +
{openPanel === "sort" && (
Filters - +
@@ -202,16 +209,17 @@ function FilterPanel({ filters, onApply, onClose }: FilterPanelProps) { Genre
{TAG_OPTIONS.map((tag) => ( - + + setDraft((d) => ({ ...d, tags: toggleValue(d.tags, tag) })) + } + /> ))}
@@ -220,28 +228,29 @@ function FilterPanel({ filters, onApply, onClose }: FilterPanelProps) { Platform
{PLATFORM_OPTIONS.map((platform) => ( - + + setDraft((d) => ({ + ...d, + platforms: toggleValue(d.platforms, platform), + })) + } + /> ))}
- + + - + - + - +
- - +
); @@ -341,16 +351,17 @@ function SortMenu({ sort, onSelect }: SortMenuProps) { return (
{SORT_FIELD_OPTIONS.map((option) => ( - + ))}
); diff --git a/frontend/src/pages/GameDetails/GameDetails.module.css b/frontend/src/pages/GameDetails/GameDetails.module.css index b4d9580..776d737 100644 --- a/frontend/src/pages/GameDetails/GameDetails.module.css +++ b/frontend/src/pages/GameDetails/GameDetails.module.css @@ -2,13 +2,8 @@ padding: 1.5rem 0 3rem; } -.message { - color: var(--color-muted); -} - .meta { margin: 0; - color: var(--color-muted); } .title { @@ -16,7 +11,6 @@ } .main { - display: flex; gap: 2rem; margin-bottom: 1.5rem; } @@ -29,12 +23,6 @@ flex-shrink: 0; } -.ratings { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - .ratingRow { margin: 0; } diff --git a/frontend/src/pages/GameDetails/GameDetails.tsx b/frontend/src/pages/GameDetails/GameDetails.tsx index 6c38341..779ece1 100644 --- a/frontend/src/pages/GameDetails/GameDetails.tsx +++ b/frontend/src/pages/GameDetails/GameDetails.tsx @@ -1,3 +1,4 @@ +import { Image, Stack } from "react-bootstrap"; import { useParams } from "react-router-dom"; import { gql } from "@apollo/client"; import { useQuery } from "@apollo/client/react"; @@ -55,14 +56,14 @@ export default function GameDetails() {
- {loading && !data &&

Loading…

} + {loading && !data &&

Loading…

} {!loading && (error || !game) && ( -

Game not found.

+

Game not found.

)} {game && ( <> -

+

{game.releaseDate ? new Date(game.releaseDate).getFullYear() : "Unknown year"}{" "} @@ -70,21 +71,21 @@ export default function GameDetails() {

{game.name}

-
- + {game.name} -
+

NTNU Rating:{" "} {game.reviewCount > 0 ? `${game.averageScore.toFixed(1)}/10` : "Not yet rated"}

-
-
+ +

{game.tags.join(", ") || "—"}

diff --git a/frontend/src/pages/GamesGrid/GamesGrid.module.css b/frontend/src/pages/GamesGrid/GamesGrid.module.css index d4f6a48..2daf25e 100644 --- a/frontend/src/pages/GamesGrid/GamesGrid.module.css +++ b/frontend/src/pages/GamesGrid/GamesGrid.module.css @@ -5,16 +5,3 @@ overflow: hidden; } -.message { - margin: 1.5rem; - color: var(--color-muted); -} - -.srOnly { - position: absolute; - width: 1px; - height: 1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; -} diff --git a/frontend/src/pages/GamesGrid/GamesGrid.tsx b/frontend/src/pages/GamesGrid/GamesGrid.tsx index e960ec6..d1a5cb0 100644 --- a/frontend/src/pages/GamesGrid/GamesGrid.tsx +++ b/frontend/src/pages/GamesGrid/GamesGrid.tsx @@ -256,15 +256,15 @@ export default function GamesGrid() { onSortChange={updateSort} />
-

+

{!loading && `${totalCount} game${totalCount === 1 ? "" : "s"} found`}

- {error &&

Failed to load games.

} + {error &&

Failed to load games.

} {!error && loading && !data && ( -

Loading games…

+

Loading games…

)} {!error && !loading && games.length === 0 && ( -

+

No games match{" "} {hasActiveFilters(filters) || search ? "these filters" From e22ac2a6764232c1894384ecd98a07def1cf2d9d Mon Sep 17 00:00:00 2001 From: Ayse Zeynep Aydin Date: Sun, 11 Oct 2026 20:01:59 +0200 Subject: [PATCH 6/6] refactor: Cleaned up GameDetails classes that had single lines --- .../pages/GameDetails/GameDetails.module.css | 20 ------------------- .../src/pages/GameDetails/GameDetails.tsx | 10 +++++----- 2 files changed, 5 insertions(+), 25 deletions(-) diff --git a/frontend/src/pages/GameDetails/GameDetails.module.css b/frontend/src/pages/GameDetails/GameDetails.module.css index 776d737..3c50eaf 100644 --- a/frontend/src/pages/GameDetails/GameDetails.module.css +++ b/frontend/src/pages/GameDetails/GameDetails.module.css @@ -1,15 +1,3 @@ -.page { - padding: 1.5rem 0 3rem; -} - -.meta { - margin: 0; -} - -.title { - margin: 0.25rem 0 1.25rem; -} - .main { gap: 2rem; margin-bottom: 1.5rem; @@ -23,14 +11,6 @@ flex-shrink: 0; } -.ratingRow { - margin: 0; -} - -.ratingLabel { - font-weight: 700; -} - .genre { margin: 0 0 1.5rem; font-weight: 600; diff --git a/frontend/src/pages/GameDetails/GameDetails.tsx b/frontend/src/pages/GameDetails/GameDetails.tsx index 779ece1..2985644 100644 --- a/frontend/src/pages/GameDetails/GameDetails.tsx +++ b/frontend/src/pages/GameDetails/GameDetails.tsx @@ -55,7 +55,7 @@ export default function GameDetails() { <>

-
+
{loading && !data &&

Loading…

} {!loading && (error || !game) && (

Game not found.

@@ -63,13 +63,13 @@ export default function GameDetails() { {game && ( <> -

+

{game.releaseDate ? new Date(game.releaseDate).getFullYear() : "Unknown year"}{" "} | Age Rating: {game.esrbRating || "Not rated"}

-

{game.name}

+

{game.name}

{game.name} -

- NTNU Rating:{" "} +

+ NTNU Rating:{" "} {game.reviewCount > 0 ? `${game.averageScore.toFixed(1)}/10` : "Not yet rated"}