From ed676eda61efd8c91b182d5304b2fbf4c62361fb Mon Sep 17 00:00:00 2001 From: albdert Date: Wed, 21 Oct 2026 15:31:15 +0200 Subject: [PATCH 1/3] pagination fungerer tror jeg --- backend/backend-api.md | 144 ++++++++++++++++++++++++++++++++++++--- backend/src/cursor.ts | 29 ++++++++ backend/src/resolvers.ts | 89 +++++++++++++++++++++--- backend/src/schema.ts | 13 +++- db/indexes.sql | 2 +- db/load.py | 9 ++- 6 files changed, 263 insertions(+), 23 deletions(-) create mode 100644 backend/src/cursor.ts diff --git a/backend/backend-api.md b/backend/backend-api.md index 7dc50ea..41be1ca 100644 --- a/backend/backend-api.md +++ b/backend/backend-api.md @@ -53,22 +53,37 @@ game(id: ID!): Game Henter en side med spill, med valgfritt søk og filtre. Resultatet er sortert etter popularitet. ```graphql -games(search: String, filter: GameFilter, first: Int = 20): GameConnection! +games( + search: String + filter: GameFilter + sort: GameSort! = POPULARITY + first: Int + after: String + last: Int + before: String +): GameConnection! ``` #### Argumenter -| Navn | Type | Default | Beskrivelse | -| -------- | --------------------------- | ------- | --------------------------------------------------------------------------------------------------------- | -| `search` | `String` | — | Søk i tittel og alternative navn. Skiller ikke mellom store og små bokstaver, og matcher deler av navnet. | -| `filter` | [`GameFilter`](#gamefilter) | — | Filtrering på sjanger, plattform og utgivelsesår. | -| `first` | `Int` | `20` | Antall spill på siden. Begrenset til 1–50. | +| Navn | Type | Default | Beskrivelse | +| -------- | --------------------------- | ------------ | ---------------------------------------------------------------------------- | +| `search` | `String` | — | Søk i tittel og alternative navn. Skiller ikke mellom store og små bokstaver. | +| `filter` | [`GameFilter`](#gamefilter) | — | Filtrering på sjanger, plattform og utgivelsesår. | +| `sort` | [`GameSort!`](#gamesort) | `POPULARITY` | Sorteringsrekkefølge. | +| `first` | `Int` | `20` | Antall spill fremover fra `after`. Begrenses til 1–50. | +| `after` | `String` | — | Cursor fra `pageInfo.endCursor`. Henter spillene etter denne. | +| `last` | `Int` | — | Antall spill bakover fra `before`. Begrenses til 1–50. | +| `before` | `String` | — | Cursor fra `pageInfo.startCursor`. Henter spillene før denne. | + +`first`/`after` og `last`/`before` kan ikke kombineres. `last` uten `before` henter siste side. -Alle argumenter er valgfrie og kan kombineres. #### Returnerer -[`GameConnection`](#gameconnection) med spillene på siden og totalt antall treff. +[`GameConnection!`](#gameconnection). `totalCount` er antall treff for søk og filter, uavhengig av cursor. En cursor er bare gyldig for sorteringen den ble laget med. + +Gir feilen `BAD_USER_INPUT` hvis begge retningene brukes samtidig, eller hvis cursoren er ugyldig eller tilhører en annen sortering. #### Eksempel: søk @@ -136,6 +151,119 @@ Action- og RPG-spill på PC, utgitt 2015–2020: } ``` +#### Eksempel: paginering fremover + +```graphql +{ + games(sort: POPULARITY, first: 2) { + pageInfo { + hasNextPage + hasPrevPage + startCursor + endCursor + } + edges { + node { + name + } + } + } +} +``` + +```json +{ + "data": { + "games": { + "pageInfo": { + "hasNextPage": true, + "hasPrevPage": false, + "startCursor": "eyJzIjoiUE9QVUxBUklUWSIsImsiOiIyMjYzNSIsImlkIjozNDk4fQ", + "endCursor": "eyJzIjoiUE9QVUxBUklUWSIsImsiOiIyMjI4NSIsImlkIjozMzI4fQ" + }, + "edges": [ + { "node": { "name": "Grand Theft Auto V" } }, + { "node": { "name": "The Witcher 3: Wild Hunt" } } + ] + } + } +} +``` + +Neste side hentes med `endCursor` som `after`: + +```graphql +{ + games(sort: POPULARITY, first: 2, after: "eyJzIjoiUE9QVUxBUklUWSIsImsiOiIyMjI4NSIsImlkIjozMzI4fQ") { + pageInfo { + hasNextPage + hasPrevPage + startCursor + } + edges { + node { + name + } + } + } +} +``` + +```json +{ + "data": { + "games": { + "pageInfo": { + "hasNextPage": true, + "hasPrevPage": true, + "startCursor": "eyJzIjoiUE9QVUxBUklUWSIsImsiOiIyMDkyNiIsImlkIjo0MjAwfQ" + }, + "edges": [ + { "node": { "name": "Portal 2" } }, + { "node": { "name": "Counter-Strike: Global Offensive" } } + ] + } + } +} +``` + +#### Eksempel: paginering bakover + +Forrige side hentes med `startCursor` som `before`: + +```graphql +{ + games(sort: POPULARITY, last: 2, before: "eyJzIjoiUE9QVUxBUklUWSIsImsiOiIyMDkyNiIsImlkIjo0MjAwfQ") { + pageInfo { + hasNextPage + hasPrevPage + } + edges { + node { + name + } + } + } +} +``` + +```json +{ + "data": { + "games": { + "pageInfo": { + "hasNextPage": true, + "hasPrevPage": false + }, + "edges": [ + { "node": { "name": "Grand Theft Auto V" } }, + { "node": { "name": "The Witcher 3: Wild Hunt" } } + ] + } + } +} +``` + --- ### genres diff --git a/backend/src/cursor.ts b/backend/src/cursor.ts new file mode 100644 index 0000000..d76fa94 --- /dev/null +++ b/backend/src/cursor.ts @@ -0,0 +1,29 @@ +import { GraphQLError } from "graphql"; +import type { GameSort } from "./resolvers.js"; + +type Cursor = { s: GameSort, k: string, id: number }; + +const error = () => new GraphQLError('Invalid cursor', { extensions: { code: 'BAD_USER_INPUT' }}); + +// konverter cursor til base64url +export function encodeCursor(cursor: Cursor) { + return Buffer.from(JSON.stringify(cursor)).toString('base64url'); +} + +// konverter base64url til cursor-JSON +export function decodeCursor(str: string, sort: GameSort) { + let cursor: Cursor; + + try { + cursor = JSON.parse(Buffer.from(str, 'base64url').toString()); + } catch { + throw error(); + } + + if (typeof cursor !== 'object' || cursor === null) { throw error() } + + if (cursor.s!==sort) { throw error() } + if (typeof cursor.k !== 'string') { throw error() } + if (!Number.isSafeInteger(cursor.id) || cursor.id<=0 ) { throw error() } + return cursor; +} diff --git a/backend/src/resolvers.ts b/backend/src/resolvers.ts index bf85f05..e2205d4 100644 --- a/backend/src/resolvers.ts +++ b/backend/src/resolvers.ts @@ -1,3 +1,5 @@ +import { GraphQLError } from 'graphql/error'; +import { decodeCursor, encodeCursor } from './cursor.js'; import { pool } from './db.js'; // type definitions @@ -5,7 +7,10 @@ type GamesArgs = { search?: string | null; filter?: GameFilter | null; sort: GameSort; - first: number; + first?: number | null; + after?: string | null; + last?: number | null; + before?: string | null; }; type GameFilter = { @@ -75,13 +80,14 @@ const GAMES_WHERE = ` // SQL sorting med order by // denne variablen mapper en enum til en gitt order // som kan brukes i SQL-spørringen -const ORDER_BY = { - POPULARITY: 'added DESC, id DESC', - NEWEST: 'released DESC NULLS LAST, id DESC', - METACRITIC: 'metacritic DESC NULLS LAST, id DESC', - NAME: 'name ASC, id ASC', +const SORTS = { + POPULARITY: { key: 'added', cast: 'int', dir: 'DESC' }, + NEWEST: { key: "COALESCE(released, '-infinity'::date)", cast: 'date', dir: 'DESC' }, + METACRITIC: { key: 'COALESCE(metacritic, -1)', cast: 'int', dir: 'DESC' }, + // ?? dette fikser noe sortering i databasen + NAME: { key: 'name COLLATE "und-x-icu"', cast: 'text', dir: 'ASC' }, } as const; -type GameSort = keyof typeof ORDER_BY; +export type GameSort = keyof typeof SORTS; // SQL queries const SQL_COUNT = ` @@ -101,13 +107,27 @@ const SQL_GAME = ` WHERE id = $1 `; +function sortSql(sort: GameSort, reverse: boolean) { + const { key, cast, dir } = SORTS[sort]; + const order = reverse ? (dir === 'DESC' ? 'ASC' : 'DESC') : dir; + const op = order === 'DESC' ? '<' : '>'; + + return { + keyCol: `${key}::text as "sortKey"`, + orderBy: `${key} ${order}, id ${order}`, + cursorWhere: `($8::bigint IS NULL OR (${key}, id) ${op} ($7::${cast}, $8::bigint))`, + }; +} + // denne må være en funksjon for å kunne ta inn en variabel sort // finnes sikkert en bedre måte... -function sqlGames(sort: GameSort) { +function sqlGames(sort: GameSort, reverse: boolean) { + const { keyCol, orderBy, cursorWhere } = sortSql(sort, reverse); return ` - SELECT ${GAME_COLS} FROM games + SELECT ${GAME_COLS}, ${keyCol} FROM games ${GAMES_WHERE} - ORDER BY ${ORDER_BY[sort]} + AND ${cursorWhere} + ORDER BY ${orderBy} LIMIT $6 `; } @@ -152,6 +172,11 @@ async function game(_parent: unknown, args: { id: string }) { return res.rows[0] ?? null; } +/* + !! IKKE FJERNE DENNE !! + + beholder intill videre til vi ser at pagineringen faktisk fungerer + async function games(_parent: unknown, args: GamesArgs) { const values = filterValues(args); const first = Math.min(Math.max(args.first, 1), MAX_PAGE_SIZE); @@ -163,6 +188,50 @@ async function games(_parent: unknown, args: GamesArgs) { values, }; } + */ + +async function games(_parent: unknown, args: GamesArgs) { + const hasForwardArgs = args.first != null || args.after != null; + const hasReverseArgs = args.last != null || args.before != null; + if (hasForwardArgs && hasReverseArgs) { + throw new GraphQLError('Use either first/after or last/before', { + extensions: { code: 'BAD_USER_INPUT' }, + }); + } + const reverse = hasReverseArgs; + + const limit = Math.min(Math.max(args.first ?? args.last ?? 20, 1), MAX_PAGE_SIZE); + const raw = args.after ?? args.before; + const cursor = raw ? decodeCursor(raw, args.sort) : null; + + const values = filterValues(args); + const res = await pool.query(sqlGames(args.sort, reverse), [ + ...values, + limit + 1, + cursor?.k ?? null, + cursor?.id ?? null, + ]); + + const hasMore = res.rows.length > limit; + const rows = res.rows.slice(0, limit); + if (reverse) rows.reverse(); + + const edges = rows.map((row) => ({ + node: row, + cursor: encodeCursor({ s: args.sort, k: row.sortKey, id: Number(row.id) }), + })); + + return { + edges, + pageInfo: { + hasNextPage: reverse ? args.before != null : hasMore, + hasPrevPage: reverse ? hasMore : args.after != null, + startCursor: edges[0]?.cursor ?? null, + endCursor: edges.at(-1)?.cursor ?? null, + }, + values, + }; +} async function totalCount(parent: { values: unknown[] }) { return (await pool.query(SQL_COUNT, parent.values)).rows[0].count; diff --git a/backend/src/schema.ts b/backend/src/schema.ts index 90ea49a..d93dd50 100644 --- a/backend/src/schema.ts +++ b/backend/src/schema.ts @@ -81,6 +81,8 @@ export const typeDefs = `#graphql """ type PageInfo { hasNextPage: Boolean! + hasPrevPage: Boolean! + startCursor: String endCursor: String } @@ -89,7 +91,16 @@ export const typeDefs = `#graphql """ type Query { game(id: ID!): Game - games(search: String, filter: GameFilter, sort: GameSort!=POPULARITY first: Int!=20): GameConnection! + games( + search: String, + filter: GameFilter, + sort: GameSort!=POPULARITY + first: Int, + after: String, + last: Int, + before: String + ): GameConnection! + genres: [Facet!]! platforms: [Facet!]! } diff --git a/db/indexes.sql b/db/indexes.sql index 0343637..d5d2d0b 100644 --- a/db/indexes.sql +++ b/db/indexes.sql @@ -3,7 +3,7 @@ CREATE INDEX games_search_trgm ON games USING gin (search_names gin_trgm_ops); CREATE INDEX games_added_id ON games (added DESC, id DESC); CREATE INDEX games_released_id ON games ((COALESCE(released, '-infinity'::date)) DESC, id DESC); CREATE INDEX games_meta_id ON games ((COALESCE(metacritic, -1)) DESC, id DESC); -CREATE INDEX games_name_id ON games (name, id); +CREATE INDEX games_name_id ON games ((name COLLATE "und-x-icu"), id); CREATE INDEX game_genres_rev ON game_genres (genre_id, game_id); CREATE INDEX game_platforms_rev ON game_platforms (platform_id, game_id); diff --git a/db/load.py b/db/load.py index 524c8e4..9d42998 100644 --- a/db/load.py +++ b/db/load.py @@ -82,9 +82,12 @@ def export_csvs(out: Path) -> None: con = duckdb.connect() con.create_function("unescape", html.unescape, ["VARCHAR"], "VARCHAR") con.execute( - f"""CREATE VIEW src AS - SELECT * FROM read_parquet('{PARQUET}') - WHERE added > 0 AND name IS NOT NULL AND trim(name) <> ''""" + f"""CREATE VIEW src AS + SELECT * FROM ( + SELECT * REPLACE (trim(regexp_replace(name, '\\p{{Cf}}', '', 'g')) AS name) + FROM read_parquet('{PARQUET}') + ) + WHERE added > 0 AND regexp_matches(name, '[\\p{{L}}\\p{{N}}]')""" ) con.execute( f"""COPY ( From 837a44e018a8ec44e8f5b3145d117035f30e32f1 Mon Sep 17 00:00:00 2001 From: albdert Date: Wed, 21 Oct 2026 15:33:24 +0200 Subject: [PATCH 2/3] format --- backend/backend-api.md | 29 ++++++++++++++++++----------- backend/src/cursor.ts | 40 ++++++++++++++++++++++++---------------- backend/src/resolvers.ts | 2 +- 3 files changed, 43 insertions(+), 28 deletions(-) diff --git a/backend/backend-api.md b/backend/backend-api.md index 41be1ca..6f97c0c 100644 --- a/backend/backend-api.md +++ b/backend/backend-api.md @@ -66,19 +66,18 @@ games( #### Argumenter -| Navn | Type | Default | Beskrivelse | -| -------- | --------------------------- | ------------ | ---------------------------------------------------------------------------- | +| Navn | Type | Default | Beskrivelse | +| -------- | --------------------------- | ------------ | ----------------------------------------------------------------------------- | | `search` | `String` | — | Søk i tittel og alternative navn. Skiller ikke mellom store og små bokstaver. | -| `filter` | [`GameFilter`](#gamefilter) | — | Filtrering på sjanger, plattform og utgivelsesår. | -| `sort` | [`GameSort!`](#gamesort) | `POPULARITY` | Sorteringsrekkefølge. | -| `first` | `Int` | `20` | Antall spill fremover fra `after`. Begrenses til 1–50. | -| `after` | `String` | — | Cursor fra `pageInfo.endCursor`. Henter spillene etter denne. | -| `last` | `Int` | — | Antall spill bakover fra `before`. Begrenses til 1–50. | -| `before` | `String` | — | Cursor fra `pageInfo.startCursor`. Henter spillene før denne. | +| `filter` | [`GameFilter`](#gamefilter) | — | Filtrering på sjanger, plattform og utgivelsesår. | +| `sort` | [`GameSort!`](#gamesort) | `POPULARITY` | Sorteringsrekkefølge. | +| `first` | `Int` | `20` | Antall spill fremover fra `after`. Begrenses til 1–50. | +| `after` | `String` | — | Cursor fra `pageInfo.endCursor`. Henter spillene etter denne. | +| `last` | `Int` | — | Antall spill bakover fra `before`. Begrenses til 1–50. | +| `before` | `String` | — | Cursor fra `pageInfo.startCursor`. Henter spillene før denne. | `first`/`after` og `last`/`before` kan ikke kombineres. `last` uten `before` henter siste side. - #### Returnerer [`GameConnection!`](#gameconnection). `totalCount` er antall treff for søk og filter, uavhengig av cursor. En cursor er bare gyldig for sorteringen den ble laget med. @@ -194,7 +193,11 @@ Neste side hentes med `endCursor` som `after`: ```graphql { - games(sort: POPULARITY, first: 2, after: "eyJzIjoiUE9QVUxBUklUWSIsImsiOiIyMjI4NSIsImlkIjozMzI4fQ") { + games( + sort: POPULARITY + first: 2 + after: "eyJzIjoiUE9QVUxBUklUWSIsImsiOiIyMjI4NSIsImlkIjozMzI4fQ" + ) { pageInfo { hasNextPage hasPrevPage @@ -233,7 +236,11 @@ Forrige side hentes med `startCursor` som `before`: ```graphql { - games(sort: POPULARITY, last: 2, before: "eyJzIjoiUE9QVUxBUklUWSIsImsiOiIyMDkyNiIsImlkIjo0MjAwfQ") { + games( + sort: POPULARITY + last: 2 + before: "eyJzIjoiUE9QVUxBUklUWSIsImsiOiIyMDkyNiIsImlkIjo0MjAwfQ" + ) { pageInfo { hasNextPage hasPrevPage diff --git a/backend/src/cursor.ts b/backend/src/cursor.ts index d76fa94..59e4b62 100644 --- a/backend/src/cursor.ts +++ b/backend/src/cursor.ts @@ -1,29 +1,37 @@ -import { GraphQLError } from "graphql"; -import type { GameSort } from "./resolvers.js"; +import { GraphQLError } from 'graphql'; +import type { GameSort } from './resolvers.js'; -type Cursor = { s: GameSort, k: string, id: number }; +type Cursor = { s: GameSort; k: string; id: number }; -const error = () => new GraphQLError('Invalid cursor', { extensions: { code: 'BAD_USER_INPUT' }}); +const error = () => new GraphQLError('Invalid cursor', { extensions: { code: 'BAD_USER_INPUT' } }); // konverter cursor til base64url export function encodeCursor(cursor: Cursor) { - return Buffer.from(JSON.stringify(cursor)).toString('base64url'); + return Buffer.from(JSON.stringify(cursor)).toString('base64url'); } // konverter base64url til cursor-JSON export function decodeCursor(str: string, sort: GameSort) { - let cursor: Cursor; + let cursor: Cursor; - try { - cursor = JSON.parse(Buffer.from(str, 'base64url').toString()); - } catch { - throw error(); - } + try { + cursor = JSON.parse(Buffer.from(str, 'base64url').toString()); + } catch { + throw error(); + } - if (typeof cursor !== 'object' || cursor === null) { throw error() } + if (typeof cursor !== 'object' || cursor === null) { + throw error(); + } - if (cursor.s!==sort) { throw error() } - if (typeof cursor.k !== 'string') { throw error() } - if (!Number.isSafeInteger(cursor.id) || cursor.id<=0 ) { throw error() } - return cursor; + if (cursor.s !== sort) { + throw error(); + } + if (typeof cursor.k !== 'string') { + throw error(); + } + if (!Number.isSafeInteger(cursor.id) || cursor.id <= 0) { + throw error(); + } + return cursor; } diff --git a/backend/src/resolvers.ts b/backend/src/resolvers.ts index e2205d4..08e0122 100644 --- a/backend/src/resolvers.ts +++ b/backend/src/resolvers.ts @@ -108,7 +108,7 @@ const SQL_GAME = ` `; function sortSql(sort: GameSort, reverse: boolean) { - const { key, cast, dir } = SORTS[sort]; + const { key, cast, dir } = SORTS[sort]; const order = reverse ? (dir === 'DESC' ? 'ASC' : 'DESC') : dir; const op = order === 'DESC' ? '<' : '>'; From ec208c5c1d4b96f23cf501880e762f1efb21db89 Mon Sep 17 00:00:00 2001 From: albdert Date: Wed, 21 Oct 2026 15:37:23 +0200 Subject: [PATCH 3/3] format 2? --- frontend/src/components/GameCard/GameCard.tsx | 4 ++-- frontend/src/pages/GameDetail/GameDetail.tsx | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/GameCard/GameCard.tsx b/frontend/src/components/GameCard/GameCard.tsx index ac4c524..bc8c70c 100644 --- a/frontend/src/components/GameCard/GameCard.tsx +++ b/frontend/src/components/GameCard/GameCard.tsx @@ -21,8 +21,8 @@ export function GameCard({ game, isFavorite, onToggleFavorite, view = 'list' }: // albert: // fix: bildestørrelse // Leona, gjerne se over at det funker som det skal og evt. endre hvis du er uenig - const imgSuffix = game.backgroundImage?.split("/media/")[1]; - const imgSize = view==='list'? "200" : view==='grid'? "420": "640"; + const imgSuffix = game.backgroundImage?.split('/media/')[1]; + const imgSize = view === 'list' ? '200' : view === 'grid' ? '420' : '640'; const imgURL = `https://media.rawg.io/media/resize/${imgSize}/-/${imgSuffix}`; return ( diff --git a/frontend/src/pages/GameDetail/GameDetail.tsx b/frontend/src/pages/GameDetail/GameDetail.tsx index 85c9a9d..b53947c 100644 --- a/frontend/src/pages/GameDetail/GameDetail.tsx +++ b/frontend/src/pages/GameDetail/GameDetail.tsx @@ -97,9 +97,9 @@ export function GameDetail({ favorites, onToggleFavorite }: GameDetailProps) { // albert: // fix: bildestørrelse // Leona, gjerne se over at det funker som det skal og evt. endre hvis du er uenig - const screenshots = game.screenshots.slice(0, MAX_SCREENSHOTS).map((src) => - `https://media.rawg.io/media/resize/420/-/${src?.split("/media/")[1]}` - ); + const screenshots = game.screenshots + .slice(0, MAX_SCREENSHOTS) + .map((src) => `https://media.rawg.io/media/resize/420/-/${src?.split('/media/')[1]}`); return (