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} + + {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/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 (
- Working Title + + Working Title + -
+ setValue(event.target.value)} /> -
- + My Ratings - +
); } 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 + +
+ +
+
+ Genre +
+ {TAG_OPTIONS.map((tag) => ( + + setDraft((d) => ({ ...d, tags: toggleValue(d.tags, tag) })) + } + /> + ))} +
+
+ +
+ Platform +
+ {PLATFORM_OPTIONS.map((platform) => ( + + setDraft((d) => ({ + ...d, + platforms: toggleValue(d.platforms, platform), + })) + } + /> + ))} +
+
+
+ +
+ + 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

-
- Game Image -
-
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}

+ + + {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 + ); +}