Skip to content
Merged
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,21 @@ Resultatlista kan sorteres på:

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

## Forsiden og listesiden

Appen har to hovedsider:

| Adresse | Innhold |
| --- | --- |
| `/` | Forsiden med rader: Populære nå, og populære på PC, PlayStation og Nintendo |
| `/spill` | Listesiden med søk, filtre, sortering og paginering |

**Forsiden** henter alle radene i en forespørsel med GraphQL-aliaser (`popular:`, `pc:` osv.) og et fragment for feltene spillkortet trenger. Hver rad viser bare spill som ikke står i en rad over. De mest populære spillene finnes på nesten alle plattformer, så uten dette ville alle radene startet likt. Radene scrolles vannrett, uten automatisk rotering, og kortene kan nås med Tab. "Populære nå" bruker foreløpig RAWG sin popularitet og byttes til brukernes egen rating når den er på plass.

**"Se alle"** på en rad går til listesiden med plattformfilteret satt. Valgte filtre vises ved antall treff (f.eks. "61 234 treff · PC"), så det synes at lista er filtrert.

**Søk med Enter** går til listesiden. Logoen går til forsiden og nullstiller søk, filtre og sortering.

## Skisse

![Skisse av applikasjonen](documents/game-showcase.png)
Expand All @@ -114,6 +129,7 @@ Resultatlista kan sorteres på:

- Detaljside for hvert spill
- Brukere kan gi spill en vurdering, som lagres i databasen
- Legge sketches inn i bakgrunnen og ikke inn i sidebar

## Teknologi

Expand Down
2 changes: 2 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,6 @@ TUNNEL_PORT=5433
# backend server env vars
DATABASE_URL=postgresql://webapp:<PASSORD>@localhost:5433/gamesdb
PORT=3001

JWT_SECRET=aaaa

54 changes: 44 additions & 10 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { GameDetail } from './pages/GameDetail/GameDetail';
import { GAMES, type GameSort } from './graphql/games';
import { Pagination } from './components/Pagination/Pagination';
import { SortSelect } from './components/SortSelect/SortSelect';
import { Home } from './pages/Home/Home';
import type { Suggestion } from './graphql/searchSuggestions';
import type { GameSummary, GameView } from './types/game';
import './App.css';
Expand All @@ -31,7 +32,8 @@ function App() {
const [view, setView] = useState<GameView>('list');
const [showFavorites, setShowFavorites] = useState(false);
const navigate = useNavigate();
const onHome = useLocation().pathname === '/';
// Søk, filtre og sortering vises på listesiden /spill. Forsiden / har rader
const onListPage = useLocation().pathname === '/spill';

// Søket i hovedlista. Settes først når brukeren trykker Enter, ikke for hver bokstav
// (forslagene i søkefeltet tar seg av det), så lista og pagineringen ikke hopper
Expand Down Expand Up @@ -68,24 +70,24 @@ function App() {

// Valgene i dropdownene (sjangre og plattformer) fra databasen.
// Filtrene vises bare på forsiden, så de hentes ikke på detaljsiden.
const { data: options } = useQuery(FILTER_OPTIONS, { skip: !onHome });
const { data: options } = useQuery(FILTER_OPTIONS, { skip: !onListPage });

// Valgt forslag i søkefeltet åpner detaljsiden til spillet
const handleSelectGame = (game: Suggestion) => navigate(`/game/${game.id}`);

// Søkeresultater og favoritter vises på forsiden, så fra detaljsiden går vi dit
// Søkeresultater vises på listesiden. Tømmes søket (X) andre steder, blir man der man er
const handleSearch = (term: string) => {
setQuery(term);
resetPage(); // nytt søk starter på side 1
if (!onHome) navigate('/');
resetPage();
if (term && !onListPage) navigate('/spill');
};

const handleToggleFavorites = () => {
if (onHome) {
if (onListPage) {
setShowFavorites((v) => !v);
} else {
setShowFavorites(true);
navigate('/');
navigate('/spill');
}
};

Expand All @@ -100,6 +102,18 @@ function App() {
resetPage();
};

// "Se alle" på en rad på forsiden: lista med plattformfilteret, uten søk,
// mest populære først. Selve sidebyttet gjør lenken i GameRow
const handleShowAll = (platformIds: string[]) => {
setQuery('');
setFilters({ ...defaultFilters, platformIds });
setSort('POPULARITY');
setShowFavorites(false);
resetPage();
setSearchResetKey((k) => k + 1);
window.scrollTo({ top: 0 });
};

// 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 @@ -114,7 +128,7 @@ function App() {
sort,
...pageVars,
},
skip: !onHome,
skip: !onListPage,
});

const result = data ?? previousData; // forrige liste vises mens den nye lastes
Expand Down Expand Up @@ -152,11 +166,20 @@ function App() {
// Når favoritter lagres i databasen, bør de hentes derfra i stedet.
const games = showFavorites ? allGames.filter((g) => favorites.has(g.id)) : allGames;

// Navnene på valgte sjangre og plattformer, så det synes at lista er filtrert
// (f.eks. etter "se alle" på forsiden, når filtergruppene i sidefeltet er lukket)
const activeFilterNames = [
...(options?.genres ?? []).filter((g) => filters.genreIds.includes(g.id)),
...(options?.platforms ?? []).filter((p) => filters.platformIds.includes(p.id)),
].map((f) => f.name);

// Teksten over lista. role="status" gjør at skjermlesere leser den opp når den endres
let status = '';
if (loading && !result) status = 'Laster …';
else if (result && !showFavorites)
else if (result && !showFavorites) {
status = `${result.games.totalCount.toLocaleString('nb-NO')} treff`;
if (activeFilterNames.length) status += ` · ${activeFilterNames.join(', ')}`;
}

const toggleFavorite = (id: string) => {
setFavorites((prev) => {
Expand All @@ -175,13 +198,24 @@ function App() {
onSearch={handleSearch}
onSelectGame={handleSelectGame}
favoritesCount={favorites.size}
showFavorites={showFavorites && onHome}
showFavorites={showFavorites && onListPage}
onToggleFavorites={handleToggleFavorites}
/>

<Routes>
{/* Forsiden med rader */}
<Route
path="/"
element={
<Home
favorites={favorites}
onToggleFavorite={toggleFavorite}
onShowAll={handleShowAll}
/>
}
/>
<Route
path="/spill"
element={
<>
<FilterSidebar
Expand Down
40 changes: 40 additions & 0 deletions frontend/src/components/GameRow/GameRow.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
.game-row {
margin-bottom: 40px;
}

/* Overskrift til venstre, "se alle" til høyre, tynn linje under */
.game-row__header {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 16px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}

.game-row__all {
font-family: var(--mono);
font-size: 14px;
text-decoration: none;
}

/* Kortene ligger bortover og scrolles vannrett */
.game-row__list {
display: grid;
grid-auto-flow: column; /* nye kort legges til høyre, ikke under */
grid-auto-columns: 220px; /* fast bredde per kort */
gap: 20px;
margin: 0;
padding: 0 0 12px; /* plass til scrollbaren */
list-style: none;
overflow-x: auto;
scroll-snap-type: x mandatory; /* kortene stopper pent på kanten når man sveiper */
scrollbar-width: thin;
}

/* Kortet fyller hele høyden, så bunnlinjene står likt */
.game-row__list > li {
display: flex;
flex-direction: column;
scroll-snap-align: start;
}
55 changes: 55 additions & 0 deletions frontend/src/components/GameRow/GameRow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { useId } from 'react';
import { Link } from 'react-router';
import type { GameSummary } from '../../types/game';
import { GameCard } from '../GameCard/GameCard';
import './GameRow.css';

interface GameRowProps {
title: string;
games: GameSummary[];
favorites: Set<string>;
onToggleFavorite: (id: string) => void;
/** Kalles før "se alle" går til listesiden, så App kan sette riktig filter */
onShowAll: () => void;
}

/**
* En rad på forsiden: overskrift, "se alle" og spillkort som scrolles bortover.
* Ingen automatisk rotering (WCAG). Kortene kan nås med Tab, og nettleseren
* scroller raden av seg selv til kortet som har fokus.
*/
export function GameRow({ title, games, favorites, onToggleFavorite, onShowAll }: GameRowProps) {
const headingId = useId(); // kobler <section> til overskriften for skjermlesere

return (
<section className="game-row" aria-labelledby={headingId}>
<div className="game-row__header">
<h2 id={headingId} className="label">
{title}
</h2>
{/* aria-label så skjermlesere hører hvilken rad "se alle" gjelder */}
<Link
to="/spill"
className="text-button game-row__all"
aria-label={`Se alle, ${title}`}
onClick={onShowAll}
>
se alle →
</Link>
</div>

<ul className="game-row__list">
{games.map((game) => (
<li key={game.id}>
<GameCard
game={game}
view="grid"
isFavorite={favorites.has(game.id)}
onToggleFavorite={onToggleFavorite}
/>
</li>
))}
</ul>
</section>
);
}
2 changes: 1 addition & 1 deletion frontend/src/graphql/games.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { gql, type TypedDocumentNode } from '@apollo/client';

/** Ett spill slik backend sender det til resultatlista */
interface GameNode {
export interface GameNode {
id: string;
name: string;
released: string | null;
Expand Down
71 changes: 71 additions & 0 deletions frontend/src/graphql/homeRows.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { gql, type TypedDocumentNode } from '@apollo/client';
import type { GameNode } from './games';

interface Row {
edges: { node: GameNode }[];
}

/** Svaret: en liste med spill per rad, med navnene fra aliasene i spørringen */
export interface HomeRowsData {
popular: Row;
pc: Row;
playstation: Row;
nintendo: Row;
}

/** Plattform-ID-ene for hver rad (se PLATFORMS i Home.tsx) */
interface HomeRowsVars {
pc: string[];
playstation: string[];
nintendo: string[];
}

/**
* Henter alle radene på forsiden i en forespørsel. Hver rad er games-queryen med et
* eget navn (alias) og eget filter. Bare 12 spill per rad og ingen totalCount,
* så databasen slipper å telle alle treff.
*/
export const HOME_ROWS: TypedDocumentNode<HomeRowsData, HomeRowsVars> = gql`
fragment RowGame on Game {
id
name
released
backgroundImage
rating
metacritic
genres {
name
}
}
query HomeRows($pc: [ID!], $playstation: [ID!], $nintendo: [ID!]) {
popular: games(first: 12) {
edges {
node {
...RowGame
}
}
}
pc: games(filter: { platformIds: $pc }, first: 36) {
edges {
node {
...RowGame
}
}
}
playstation: games(filter: { platformIds: $playstation }, first: 36) {
edges {
node {
...RowGame
}
}
}
nintendo: games(filter: { platformIds: $nintendo }, first: 36) {
edges {
node {
...RowGame
}
}
}
}
`;
2 changes: 1 addition & 1 deletion frontend/src/pages/GameDetail/GameDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export function GameDetail({ favorites, onToggleFavorite }: GameDetailProps) {
}, [loadedId, title]);

const backLink = (
<Link to="/" className="text-button game-detail__back">
<Link to="/spill" className="text-button game-detail__back">
← tilbake til resultatene
</Link>
);
Expand Down
Loading