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/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.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..60f7ef1 100644
--- a/frontend/src/components/GameListItem/GameListItem.tsx
+++ b/frontend/src/components/GameListItem/GameListItem.tsx
@@ -1,31 +1,40 @@
+import { Image } from "react-bootstrap";
+import { Link } from "react-router-dom";
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;
}
export default function GameListItem({
+ id,
title,
year,
tags,
rating,
+ reviewCount,
image,
striped,
}: GameListItemProps) {
return (
-
-

+
+
{title}
-
{year}
-
{tags}
-
{rating.toFixed(1)}/10
+
{year ?? "—"}
+
{tags || "—"}
+
{reviewCount > 0 ? `${rating.toFixed(1)}/10` : "Not yet rated"}
-
+
);
}
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..1112f60 100644
--- a/frontend/src/components/Header/Header.tsx
+++ b/frontend/src/components/Header/Header.tsx
@@ -1,27 +1,72 @@
+import { useState } from "react";
+import type { FormEvent } from "react";
import { Button, Form, InputGroup } from "react-bootstrap";
import { Search } from "react-bootstrap-icons";
+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 (
);
}
diff --git a/frontend/src/components/Toolbar/Toolbar.module.css b/frontend/src/components/Toolbar/Toolbar.module.css
index 51f876a..52d9fb7 100644
--- a/frontend/src/components/Toolbar/Toolbar.module.css
+++ b/frontend/src/components/Toolbar/Toolbar.module.css
@@ -1,33 +1,255 @@
.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;
border-bottom: 1px solid var(--color-border);
color: var(--color-muted);
+ position: relative;
+}
+
+.menu {
+ position: relative;
+}
+
+.toolbar > .menu:first-child {
+ justify-self: start;
+}
+
+.toolbar > .menu:last-child {
+ justify-self: end;
}
.action {
display: inline-flex;
align-items: center;
gap: 0.4rem;
- border: none;
- background: none;
+ padding: 0;
color: inherit;
font: inherit;
- cursor: pointer;
+}
+
+.badge {
+ width: 0.4rem;
+ height: 0.4rem;
+ border-radius: 50%;
+ background-color: var(--color-brand);
}
.pagination {
display: inline-flex;
align-items: center;
+ justify-self: center;
gap: 0.75rem;
}
+.pageButton {
+ display: inline-flex;
+ align-items: center;
+ padding: 0;
+ color: inherit;
+ font: inherit;
+}
+
.sort {
display: inline-flex;
align-items: center;
gap: 1rem;
}
+
+.sortText {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.3rem;
+}
+
+.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;
+ padding: 0;
+ color: inherit;
+}
+
+.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 {
+ white-space: nowrap;
+ font: inherit;
+ color: inherit;
+ padding: 0.5rem 1rem;
+}
+
+.menuItem:hover,
+.menuItem:focus-visible {
+ background-color: var(--color-stripe-even);
+}
+
+.menuItem[aria-checked="true"] {
+ 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 e2eadbb..29d9caa 100644
--- a/frontend/src/components/Toolbar/Toolbar.tsx
+++ b/frontend/src/components/Toolbar/Toolbar.tsx
@@ -1,33 +1,368 @@
+import { useEffect, useRef, useState } from "react";
+import type { ChangeEvent } from "react";
+import { Button, Form } from "react-bootstrap";
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";
-export default function Toolbar() {
+interface ToolbarProps {
+ page: number;
+ 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)}
+ />
+ )}
+
-
- Page 1
-
+
+
+ Page {page} of {totalPages}
+
+
-
-
-
+
+
+
+
+
+ {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
+
+
+
+
+
+
+
+
+
+
+
+ Age rating
+ ) =>
+ setDraft((d) => ({
+ ...d,
+ esrbRating: event.target.value || null,
+ }))
+ }
+ >
+
+ {ESRB_OPTIONS.map((rating) => (
+
+ ))}
+
+
+
+
+ Released from
+ ) =>
+ setDraft((d) => ({
+ ...d,
+ yearFrom: event.target.value ? Number(event.target.value) : null,
+ }))
+ }
+ />
+
+
+
+ Released to
+ ) =>
+ setDraft((d) => ({
+ ...d,
+ yearTo: event.target.value ? Number(event.target.value) : null,
+ }))
+ }
+ />
+
+
+
+ Minimum score: {draft.minScore ?? 0}
+ ) =>
+ setDraft((d) => ({
+ ...d,
+ minScore: Number(event.target.value) || null,
+ }))
+ }
+ />
+
+
+
+
+
+
+
+
+ );
+}
+
+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/index.css b/frontend/src/index.css
index 8082bd9..a8020e6 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -1,5 +1,5 @@
:root {
- /* 50% of a 1920px viewport — the grid stays centered at this width until
+ /* 50% of a 1920px viewport. The grid stays centered at this width until
the viewport can't fit it anymore, then it fills the screen (see Layout). */
--content-width: 960px;
--header-height: 72px;
@@ -17,6 +17,14 @@
--color-border: #e9cfce;
}
+/* The sort control stacks its label onto two lines below this width
+ (see Toolbar.module.css), so the toolbar needs more height to fit it. */
+@media (max-width: 600px) {
+ :root {
+ --toolbar-height: 84px;
+ }
+}
+
* {
box-sizing: border-box;
}
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
new file mode 100644
index 0000000..3c50eaf
--- /dev/null
+++ b/frontend/src/pages/GameDetails/GameDetails.module.css
@@ -0,0 +1,43 @@
+.main {
+ gap: 2rem;
+ margin-bottom: 1.5rem;
+}
+
+.cover {
+ width: 220px;
+ height: 220px;
+ object-fit: cover;
+ background-color: var(--color-stripe-odd);
+ flex-shrink: 0;
+}
+
+.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..2985644 100644
--- a/frontend/src/pages/GameDetails/GameDetails.tsx
+++ b/frontend/src/pages/GameDetails/GameDetails.tsx
@@ -1,32 +1,115 @@
-import { Container, Image } from "react-bootstrap";
+import { Image, Stack } from "react-bootstrap";
+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 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 (
<>
-
- Release year | Age rating
- Game Title
-
-
-
-
NTNU Rating:
-
Your Rating:
-
+
+
+ {loading && !data &&
Loading…
}
+ {!loading && (error || !game) && (
+
Game not found.
+ )}
+
+ {game && (
+ <>
+
+ {game.releaseDate
+ ? new Date(game.releaseDate).getFullYear()
+ : "Unknown year"}{" "}
+ | Age Rating: {game.esrbRating || "Not rated"}
+
+
{game.name}
+
+
+
+
+
+ NTNU Rating:{" "}
+ {game.reviewCount > 0
+ ? `${game.averageScore.toFixed(1)}/10`
+ : "Not yet rated"}
+
+
+
+
+
{game.tags.join(", ") || "—"}
+
+
+ Developer:
+ {game.developers.join(", ") || "—"}
+
+
+ Publishers:
+ {game.publishers.join(", ") || "—"}
+
+
+ Platform:
+ {game.platforms.join(", ") || "—"}
+
+
+
+ Description
+ {game.description || "No description available."}
+
+ >
+ )}
- Tags:
- Developer(s):
- Publisher(s):
- Platform(s):
- Description
-
+
>
);
}
diff --git a/frontend/src/pages/GamesGrid/GamesGrid.module.css b/frontend/src/pages/GamesGrid/GamesGrid.module.css
index 4dd75cc..2daf25e 100644
--- a/frontend/src/pages/GamesGrid/GamesGrid.module.css
+++ b/frontend/src/pages/GamesGrid/GamesGrid.module.css
@@ -4,3 +4,4 @@
height: calc(100vh - var(--header-height) - var(--toolbar-height));
overflow: hidden;
}
+
diff --git a/frontend/src/pages/GamesGrid/GamesGrid.tsx b/frontend/src/pages/GamesGrid/GamesGrid.tsx
index 93c6aff..d1a5cb0 100644
--- a/frontend/src/pages/GamesGrid/GamesGrid.tsx
+++ b/frontend/src/pages/GamesGrid/GamesGrid.tsx
@@ -1,58 +1,291 @@
+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 games = [
- {
- title: "Heroes of Might and Magic: Olden Era",
- year: 2026,
- tags: "Turn-based strategy",
- rating: 9.3,
- image: "",
- },
- {
- title: "Minecraft",
- year: 2009,
- tags: "Indie, Survival, Adventure, Sandbox",
- rating: 9.1,
- image: "",
- },
- {
- title: "Ori and the Blind Forest",
- year: 2015,
- tags: "Metroidvania",
- rating: 8.8,
- image: "",
- },
- {
- title: "Pokémon Legends: Z-A",
- year: 2025,
- tags: "Action RPG",
- rating: 0.1,
- image: "",
- },
- {
- title: "Hades",
- year: 2020,
- tags: "Roguelike",
- rating: 9.4,
- image: "",
- },
+const GET_GAMES = gql`
+ 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
+ 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 SORT_FIELDS: readonly SortField[] = [
+ "NAME",
+ "RELEASE_DATE",
+ "AVERAGE_SCORE",
+ "REVIEW_COUNT",
];
+function getStoredPage() {
+ 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.
+ 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: {
+ 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,
+ });
+
+ 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(GRID_PAGE_STORAGE_KEY, String(page));
+ }, [page]);
+
return (
<>
-
-
+
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) => (
))}
diff --git a/frontend/src/types/gameQuery.ts b/frontend/src/types/gameQuery.ts
new file mode 100644
index 0000000..02c297a
--- /dev/null
+++ b/frontend/src/types/gameQuery.ts
@@ -0,0 +1,46 @@
+export interface GameFilters {
+ tags: string[];
+ platforms: string[];
+ esrbRating: string | null;
+ yearFrom: number | null;
+ yearTo: number | null;
+ minScore: number | null;
+}
+
+export type SortField =
+ | "NAME"
+ | "RELEASE_DATE"
+ | "AVERAGE_SCORE"
+ | "REVIEW_COUNT";
+
+export type SortOrder = "ASC" | "DESC";
+
+export interface SortState {
+ field: SortField;
+ order: SortOrder;
+}
+
+export const EMPTY_FILTERS: GameFilters = {
+ tags: [],
+ platforms: [],
+ esrbRating: null,
+ yearFrom: null,
+ yearTo: null,
+ minScore: null,
+};
+
+export const DEFAULT_SORT: SortState = {
+ field: "NAME",
+ order: "ASC",
+};
+
+export function hasActiveFilters(filters: GameFilters): boolean {
+ return (
+ filters.tags.length > 0 ||
+ filters.platforms.length > 0 ||
+ filters.esrbRating !== null ||
+ filters.yearFrom !== null ||
+ filters.yearTo !== null ||
+ filters.minScore !== null
+ );
+}