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: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,23 @@ Resultatlista viser 20 spill per side, med "forrige" og "neste" og "side X av Y"

**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">`.

## Sortering

Resultatlista kan sorteres på:

| Valg | Sorteres etter | Rekkefølge |
| --- | --- | --- |
| populære | Antall RAWG-brukere som har lagt spillet i biblioteket sitt (`added`) | Flest først |
| nyeste | Utgivelsesdato | Nyeste først, spill uten dato sist |
| metacritic | Metacritic-score | Høyest først, spill uten score sist |
| navn (a–å) | Tittel | Alfabetisk, med æ, ø og å riktig plassert |

**Sorteringen skjer i databasen**, på hele resultatsettet med søk og filtre, ikke bare på de 20 spillene som er lastet inn. Frontend sender valget som `sort` i `games`-queryen. Se [backend-api.md](backend/backend-api.md) for detaljer.

**Ny sortering starter på side 1.** Cursoren backend bruker for paginering inneholder sorteringen den ble laget med, så en cursor fra én sortering passer ikke til en annen.

**Tilgjengelighet:** Menyen er en vanlig `<select>` med en synlig `<label>` ("sorter"), så den kan brukes med tastatur og leses riktig av skjermlesere uten ekstra kode.

## Skisse

![Skisse av applikasjonen](documents/game-showcase.png)
Expand All @@ -96,7 +113,6 @@ Resultatlista viser 20 spill per side, med "forrige" og "neste" og "side X av Y"


- 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

## Teknologi
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@
font-size: 14px;
}

/* Sortering og visning til høyre i linjen over resultatene */
.results-bar__controls {
display: flex;
flex-wrap: wrap; /* legger seg på to linjer på smale skjermer */
align-items: baseline;
justify-content: flex-end;
gap: 8px 24px;
}

/* Antall treff under overskriften, i samme monospace som menyen */
.results-count {
min-height: 1.5em; /* holder plassen, så lista ikke hopper når teksten kommer */
Expand Down
88 changes: 54 additions & 34 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ 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 { GAMES, type GameSort } from './graphql/games';
import { Pagination } from './components/Pagination/Pagination';
import { SortSelect } from './components/SortSelect/SortSelect';
import type { Suggestion } from './graphql/searchSuggestions';
import type { GameSummary, GameView } from './types/game';
import './App.css';
Expand Down Expand Up @@ -38,6 +39,8 @@ function App() {
// Filtrene fra sidebaren. Debounce så slideren ikke sender en request per steg
const [filters, setFilters] = useState<GameFilters>(defaultFilters);
const debouncedFilters = useDebounce(filters, 300);
//sortering hele resultatsettet, standard blir mest populære
const [sort, setSort] = useState<GameSort>('POPULARITY');

const [page, setPage] = useState(1);
const [cursor, setCursor] = useState<{ after?: string; before?: string }>({});
Expand All @@ -56,6 +59,7 @@ function App() {
const goHome = () => {
setQuery('');
setFilters(defaultFilters);
setSort('POPULARITY');
setShowFavorites(false);
resetPage();
setSearchResetKey((k) => k + 1);
Expand Down Expand Up @@ -91,6 +95,11 @@ function App() {
resetPage();
};

const handleSortChange = (next: GameSort) => {
setSort(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 }
Expand All @@ -99,7 +108,12 @@ function App() {
// 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), ...pageVars },
variables: {
search: query || null,
filter: toFilterInput(debouncedFilters),
sort,
...pageVars,
},
skip: !onHome,
});

Expand Down Expand Up @@ -182,38 +196,44 @@ function App() {
<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
type="button"
className="text-button"
aria-pressed={view === 'list'}
aria-label="Visning 1: liste"
title="Liste"
onClick={() => setView('list')}
>
1
</button>
<button
type="button"
className="text-button"
aria-pressed={view === 'grid'}
aria-label="Visning 2: rutenett"
title="Rutenett"
onClick={() => setView('grid')}
>
2
</button>
<button
type="button"
className="text-button"
aria-pressed={view === 'showcase'}
aria-label="Visning 3: utstilling"
title="Utstilling"
onClick={() => setView('showcase')}
>
3
</button>

<div className="results-bar__controls">
{/* Favorittvisningen filtrerer bare det som er lastet, så sortering skjules der */}
{!showFavorites && <SortSelect value={sort} onChange={handleSortChange} />}

<div role="group" aria-label="Visning" className="results-bar__views">
<span aria-hidden="true">visning</span>
<button
type="button"
className="text-button"
aria-pressed={view === 'list'}
aria-label="Visning 1: liste"
title="Liste"
onClick={() => setView('list')}
>
1
</button>
<button
type="button"
className="text-button"
aria-pressed={view === 'grid'}
aria-label="Visning 2: rutenett"
title="Rutenett"
onClick={() => setView('grid')}
>
2
</button>
<button
type="button"
className="text-button"
aria-pressed={view === 'showcase'}
aria-label="Visning 3: utstilling"
title="Utstilling"
onClick={() => setView('showcase')}
>
3
</button>
</div>
</div>
</div>

Expand Down
25 changes: 25 additions & 0 deletions frontend/src/components/SortSelect/SortSelect.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/* "sorter" og menyen på samme linje, i samme monospace som "visning" */
.sort-select {
display: flex;
align-items: baseline;
gap: 8px;
font-family: var(--mono);
font-size: 14px;
}

/* Ingen boks, bare en linje under som søkefeltet */
.sort-select select {
padding: 0 0 2px;
font: inherit;
color: var(--text-h);
background: transparent;
border: 0;
border-bottom: 1px solid var(--text);
border-radius: 0;
cursor: pointer;
}

.sort-select select:focus-visible {
outline: 2px solid var(--text-h);
outline-offset: 2px;
}
38 changes: 38 additions & 0 deletions frontend/src/components/SortSelect/SortSelect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { useId } from 'react';
import type { GameSort } from '../../graphql/games';
import './SortSelect.css';

// Valgene i menyen. Verdien er det backend forventer, teksten er det brukeren ser
const OPTIONS: { value: GameSort; label: string }[] = [
{ value: 'POPULARITY', label: 'populære' },
{ value: 'NEWEST', label: 'nyeste' },
{ value: 'METACRITIC', label: 'metacritic' },
{ value: 'NAME', label: 'navn (a–å)' },
];

interface SortSelectProps {
value: GameSort;
onChange: (sort: GameSort) => void;
}

/**
* Nedtrekksmeny for sortering av hele resultatsettet. Sorteringen skjer i databasen,
* ikke bare på spillene som er lastet inn. Vanlig <select>, så tastatur og
* skjermlesere fungerer uten ekstra kode.
*/
export function SortSelect({ value, onChange }: SortSelectProps) {
const id = useId(); // unik id, så labelen hører til riktig meny

return (
<div className="sort-select">
<label htmlFor={id}>sorter</label>
<select id={id} value={value} onChange={(e) => onChange(e.target.value as GameSort)}>
{OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
);
}
5 changes: 5 additions & 0 deletions frontend/src/graphql/games.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,12 @@ export interface GameFilterInput {
releasedTo: number | null;
}

export type GameSort = 'POPULARITY' | 'NEWEST' | 'METACRITIC' | 'NAME';

interface GamesVars {
search: string | null;
filter: GameFilterInput;
sort: GameSort;
first?: number;
after?: string;
last?: number;
Expand All @@ -49,6 +52,7 @@ export const GAMES: TypedDocumentNode<GamesData, GamesVars> = gql`
query Games(
$search: String
$filter: GameFilter
$sort: GameSort! = POPULARITY
$first: Int
$after: String
$last: Int
Expand All @@ -57,6 +61,7 @@ export const GAMES: TypedDocumentNode<GamesData, GamesVars> = gql`
games(
search: $search
filter: $filter
sort: $sort
first: $first
after: $after
last: $last
Expand Down