Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<nav aria-label="Sider">`.

## Skisse

![Skisse av applikasjonen](documents/game-showcase.png)
Expand All @@ -79,8 +94,7 @@ For bruk av databasen se [database-usage](db/database-usage.md).

## Funksjonalitet (planlagt)

- Søk etter spill på navn
- Resultatliste som laster inn flere spill når man scroller

- Detaljside for hvert spill
- Filtrering og sortering, f.eks. på sjanger, plattform, utgivelsesår og rating
- Brukere kan gi spill en vurdering, som lagres i databasen
Expand Down
79 changes: 71 additions & 8 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<Set<string>>(new Set());
const [view, setView] = useState<GameView>('list');
Expand All @@ -34,6 +39,15 @@ function App() {
const [filters, setFilters] = useState<GameFilters>(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<HTMLHeadingElement>(null);

// Øker hver gang man trykker på logoen. Brukes som key på søkefeltet, så det tømmes
const [searchResetKey, setSearchResetKey] = useState(0);

Expand All @@ -43,6 +57,7 @@ function App() {
setQuery('');
setFilters(defaultFilters);
setShowFavorites(false);
resetPage();
setSearchResetKey((k) => k + 1);
window.scrollTo({ top: 0 });
};
Expand All @@ -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);
Expand All @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -122,15 +172,16 @@ function App() {
<>
<FilterSidebar
filters={filters}
onChange={setFilters}
onChange={handleFiltersChange}
genres={options?.genres ?? []}
platforms={options?.platforms ?? []}
/>

<main className="main">
<div className="results-bar">
<h2 className="label">{showFavorites ? 'Favoritter' : 'Søkeresultater'}</h2>

<h2 className="label" ref={resultsHeadingRef} tabIndex={-1}>
{showFavorites ? 'Favoritter' : 'Søkeresultater'}
</h2>
<div role="group" aria-label="Visning" className="results-bar__views">
<span aria-hidden="true">visning</span>
<button
Expand Down Expand Up @@ -177,6 +228,18 @@ function App() {
favorites={favorites}
onToggleFavorite={toggleFavorite}
/>

{/* Favorittvisningen filtrerer bare det som er lastet, så den har ingen sider */}
{!showFavorites && totalPages > 1 && (
<Pagination
page={page}
totalPages={totalPages}
hasPrev={page > 1}
hasNext={pageInfo?.hasNextPage ?? false}
onPrev={goToPrev}
onNext={goToNext}
/>
)}
</main>
</>
}
Expand Down
22 changes: 22 additions & 0 deletions frontend/src/components/Pagination/Pagination.css
Original file line number Diff line number Diff line change
@@ -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;
}
39 changes: 39 additions & 0 deletions frontend/src/components/Pagination/Pagination.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<nav className="pagination" aria-label="Sider">
<button type="button" className="text-button" onClick={onPrev} disabled={!hasPrev}>
← forrige
</button>
<span className="pagination__info">
side {page.toLocaleString('nb-NO')} av {totalPages.toLocaleString('nb-NO')}
</span>
<button type="button" className="text-button" onClick={onNext} disabled={!hasNext}>
neste →
</button>
</nav>
);
}
35 changes: 32 additions & 3 deletions frontend/src/graphql/games.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }[];
};
}
Expand All @@ -29,17 +35,40 @@ export interface GameFilterInput {
interface GamesVars {
search: string | null;
filter: GameFilterInput;
first: number;
first?: number;
after?: string;
last?: number;
before?: string;
}

/**
* Henter én side med spill til hovedlista. Uten søk gir backend de mest populære.
* Bare feltene spillkortet viser hentes, så svaret holdes lite.
*/
export const GAMES: TypedDocumentNode<GamesData, GamesVars> = 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
Expand Down