From 99c87ef0fd0a1d452ef7ddde878929171d588f5e Mon Sep 17 00:00:00 2001 From: Clemens Beese Date: Thu, 22 Oct 2026 11:56:52 +0200 Subject: [PATCH 1/5] =?UTF-8?q?feat(frontend):=20hent=20pageInfo=20og=20st?= =?UTF-8?q?=C3=B8tt=20first/after=20og=20last/before?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/graphql/games.ts | 35 ++++++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/frontend/src/graphql/games.ts b/frontend/src/graphql/games.ts index 2dc446b..a2d06b3 100644 --- a/frontend/src/graphql/games.ts +++ b/frontend/src/graphql/games.ts @@ -14,6 +14,12 @@ interface GameNode { interface GamesData { games: { totalCount: number; + pageInfo: { + hasNextPage: boolean; + hasPrevPage: boolean; + startCursor: string | null; + endCursor: string | null; + }; edges: { node: GameNode }[]; }; } @@ -29,7 +35,10 @@ export interface GameFilterInput { interface GamesVars { search: string | null; filter: GameFilterInput; - first: number; + first?: number; + after?: string; + last?: number; + before?: string; } /** @@ -37,9 +46,29 @@ interface GamesVars { * Bare feltene spillkortet viser hentes, så svaret holdes lite. */ export const GAMES: TypedDocumentNode = gql` - query Games($search: String, $filter: GameFilter, $first: Int) { - games(search: $search, filter: $filter, first: $first) { + query Games( + $search: String + $filter: GameFilter + $first: Int + $after: String + $last: Int + $before: String + ) { + games( + search: $search + filter: $filter + first: $first + after: $after + last: $last + before: $before + ) { totalCount + pageInfo { + hasNextPage + hasPrevPage + startCursor + endCursor + } edges { node { id From d8b4e5d65dce590a1aa7884778d496fb23f7850b Mon Sep 17 00:00:00 2001 From: Clemens Beese Date: Thu, 22 Oct 2026 11:56:52 +0200 Subject: [PATCH 2/5] style(frontend): paginering i trykk-stil --- .../src/components/Pagination/Pagination.css | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 frontend/src/components/Pagination/Pagination.css diff --git a/frontend/src/components/Pagination/Pagination.css b/frontend/src/components/Pagination/Pagination.css new file mode 100644 index 0000000..37120c6 --- /dev/null +++ b/frontend/src/components/Pagination/Pagination.css @@ -0,0 +1,22 @@ +/* Linje over, tekstknapper til hver side og sidetallet i midten */ +.pagination { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 24px; + padding-top: 16px; + border-top: 1px solid var(--border); + font-family: var(--mono); + font-size: 14px; +} + +.pagination__info { + color: var(--text); +} + +/* Utilgjengelig knapp: svakere, uten strek og uten håndpeker */ +.pagination .text-button:disabled { + opacity: 0.4; + text-decoration: none; + cursor: default; +} From 96c5005a00dc5258702cff3512d67aaffcc9f990 Mon Sep 17 00:00:00 2001 From: Clemens Beese Date: Thu, 22 Oct 2026 11:56:52 +0200 Subject: [PATCH 3/5] feat(frontend): Pagination-komponent med forrige/neste og sidetall --- .../src/components/Pagination/Pagination.tsx | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 frontend/src/components/Pagination/Pagination.tsx diff --git a/frontend/src/components/Pagination/Pagination.tsx b/frontend/src/components/Pagination/Pagination.tsx new file mode 100644 index 0000000..244def7 --- /dev/null +++ b/frontend/src/components/Pagination/Pagination.tsx @@ -0,0 +1,39 @@ +import './Pagination.css'; + +interface PaginationProps { + page: number; + totalPages: number; + hasPrev: boolean; + hasNext: boolean; + onPrev: () => void; + onNext: () => void; +} + +/** + * "← forrige side 3 av 6 004 neste →" under resultatlista. + * Backend bruker cursor-paginering, så man blar én side om gangen i stedet for å + * hoppe til et sidetall. Det er raskt uansett hvor langt ut i lista man er. + */ + +export function Pagination({ + page, + totalPages, + hasPrev, + hasNext, + onPrev, + onNext, +}: PaginationProps) { + return ( + + ); +} From b026f1d5499675ae8017be693db27f9ba4ee2995 Mon Sep 17 00:00:00 2001 From: Clemens Beese Date: Thu, 22 Oct 2026 11:56:52 +0200 Subject: [PATCH 4/5] =?UTF-8?q?feat(frontend):=20paginering=20av=20resulta?= =?UTF-8?q?tlista,=20tilbake=20til=20side=201=20ved=20nytt=20s=C3=B8k/filt?= =?UTF-8?q?er?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.tsx | 79 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 71 insertions(+), 8 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2570c53..65f770e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,10 +1,11 @@ -import { useState } from 'react'; +import { useRef, useState } from 'react'; import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router'; import { useQuery } from '@apollo/client/react'; import { GameList } from './components/GameList/GameList'; import { Header } from './components/Header/Header'; import { GameDetail } from './pages/GameDetail/GameDetail'; import { GAMES } from './graphql/games'; +import { Pagination } from './components/Pagination/Pagination'; import type { Suggestion } from './graphql/searchSuggestions'; import type { GameSummary, GameView } from './types/game'; import './App.css'; @@ -14,12 +15,16 @@ import { FILTER_OPTIONS } from './graphql/filterOptions'; import { useDebounce } from './hooks/useDebounce'; import { defaultFilters, toFilterInput, type GameFilters } from './types/filters'; +//dette er antall spill per side +const PAGE_SIZE = 20; + /** * Rammen rundt hele appen: toppfeltet og sidene (forsiden med filtre og resultatliste, * og detaljsiden for ett spill). Søk, filtre, visning og favoritter ligger her, så de * huskes når man går fra detaljsiden tilbake til lista. * Favoritter og valgt visning ligger bare i minnet her og skal senere inn i state management. */ + function App() { const [favorites, setFavorites] = useState>(new Set()); const [view, setView] = useState('list'); @@ -34,6 +39,15 @@ function App() { const [filters, setFilters] = useState(defaultFilters); const debouncedFilters = useDebounce(filters, 300); + const [page, setPage] = useState(1); + const [cursor, setCursor] = useState<{ after?: string; before?: string }>({}); + const resetPage = () => { + setPage(1); + setCursor({}); + }; + + const resultsHeadingRef = useRef(null); + // Øker hver gang man trykker på logoen. Brukes som key på søkefeltet, så det tømmes const [searchResetKey, setSearchResetKey] = useState(0); @@ -43,6 +57,7 @@ function App() { setQuery(''); setFilters(defaultFilters); setShowFavorites(false); + resetPage(); setSearchResetKey((k) => k + 1); window.scrollTo({ top: 0 }); }; @@ -57,8 +72,10 @@ function App() { // Søkeresultater og favoritter vises på forsiden, så fra detaljsiden går vi dit const handleSearch = (term: string) => { setQuery(term); + resetPage(); // nytt søk starter på side 1 if (!onHome) navigate('/'); }; + const handleToggleFavorites = () => { if (onHome) { setShowFavorites((v) => !v); @@ -68,13 +85,24 @@ function App() { } }; - // Henter første side fra databasen. Kjøres på nytt bare når `query` endres (ved Enter). - // Tomt søk (null) gir de mest populære spillene. Hoppes over på detaljsiden, der lista - // ikke vises. Tilbake på forsiden kommer lista fra Apollo-cachen uten ny forespørsel. + // Nytt filter starter også på side 1. En gammel cursor passer ikke til en ny liste + const handleFiltersChange = (next: GameFilters) => { + setFilters(next); + resetPage(); + }; + + // Forrige side: siste 20 før startCursor. Ellers: første 20 etter endCursor (eller fra start) + const pageVars = cursor.before + ? { last: PAGE_SIZE, before: cursor.before } + : { first: PAGE_SIZE, after: cursor.after }; + + // Henter én side fra databasen. Kjøres på nytt når søk, filtre eller side endres. + // Hver side caches av Apollo, så å bla tilbake går ofte uten ny forespørsel. const { data, previousData, loading, error } = useQuery(GAMES, { - variables: { search: query || null, filter: toFilterInput(debouncedFilters), first: 20 }, + variables: { search: query || null, filter: toFilterInput(debouncedFilters), ...pageVars }, skip: !onHome, }); + const result = data ?? previousData; // forrige liste vises mens den nye lastes // Backend gir sjangre som objekter ({ name }), spillkortet forventer bare navnene @@ -84,6 +112,28 @@ function App() { genres: node.genres.map((g) => g.name), })) ?? []; + const pageInfo = result?.games.pageInfo; + const totalPages = Math.max(1, Math.ceil((result?.games.totalCount ?? 0) / PAGE_SIZE)); + + // Bytter side: scroller til toppen og flytter fokus til overskriften, + // så tastatur- og skjermleserbrukere vet at det er en ny liste + + const goToPage = (nextPage: number, nextCursor: { after?: string; before?: string }) => { + setPage(nextPage); + setCursor(nextCursor); + window.scrollTo({ top: 0 }); + resultsHeadingRef.current?.focus(); + }; + + const goToNext = () => { + if (pageInfo?.endCursor) goToPage(page + 1, { after: pageInfo.endCursor }); + }; + const goToPrev = () => { + //tilabke til side 1: bruk samme variabler som første gang, så svaret kommer fra cachen + if (page === 2) goToPage(1, {}); + else if (pageInfo?.startCursor) goToPage(page - 1, { before: pageInfo.startCursor }); + }; + // MIDLERTIDIG: favoritter filtreres blant spillene som er lastet inn. // Når favoritter lagres i databasen, bør de hentes derfra i stedet. const games = showFavorites ? allGames.filter((g) => favorites.has(g.id)) : allGames; @@ -122,15 +172,16 @@ function App() { <>
-

{showFavorites ? 'Favoritter' : 'Søkeresultater'}

- +

+ {showFavorites ? 'Favoritter' : 'Søkeresultater'} +

} From e4752c9780fde789c07d1d1fc7a7e474469763ea Mon Sep 17 00:00:00 2001 From: Clemens Beese Date: Thu, 22 Oct 2026 11:56:52 +0200 Subject: [PATCH 5/5] docs: beskriv paginering i README --- README.md | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 913b9f0..5d8fb92 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,21 @@ For databasens layout (dvs. tabeller, kolonner, etc.) se [database-layout](db/da For bruk av databasen se [database-usage](db/database-usage.md). +## Paginering + +Resultatlista viser 20 spill per side, med "forrige" og "neste" og "side X av Y" under lista. + +**Cursor-paginering i stedet for sidetall.** Backend bruker cursor-paginering (`first`/`after` og `last`/`before` i `games`-queryen). I stedet for "gi meg side 300" ber frontend om "de 20 neste etter dette spillet". Databasen kan da bruke indeksen direkte og slipper å telle seg forbi alle radene foran, så det er like raskt uansett hvor langt ut i lista man er. Det er viktig når datasettet er stort. Ulempen er at man blar side for side i stedet for å hoppe rett til et sidetall, noe vi mente er en unødvendig feature uansett. + +**Hvordan det henger sammen i frontend:** +- `pageInfo` fra backend gir `startCursor` og `endCursor` (bokmerker for første og siste spill på siden) og om det finnes en neste side. +- "Neste" sender `first: 20, after: endCursor`, "forrige" sender `last: 20, before: startCursor`. +- Sidetallet holdes i frontend, og totalt antall sider regnes ut fra `totalCount`. +- Nytt søk, nytt filter eller klikk på logoen starter på side 1, siden en gammel cursor ikke passer til en ny liste. +- Apollo cacher hver side, så å bla tilbake gir som regel ingen ny forespørsel. + +**Tilgjengelighet:** Når man bytter side, scrolles siden til toppen og fokus flyttes til overskriften over lista, så tastatur- og skjermleserbrukere vet at innholdet er nytt. Knappene er deaktivert på første og siste side, og pagineringen ligger i en `