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..0511170 100644 --- a/frontend/src/components/Toolbar/Toolbar.module.css +++ b/frontend/src/components/Toolbar/Toolbar.module.css @@ -1,12 +1,25 @@ .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 { @@ -20,9 +33,17 @@ 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; } @@ -46,3 +67,206 @@ 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; + 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); +} + +@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 b3184cf..0d74ea9 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/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/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 + ); +}