From 86e3058c270f350259c3b8bf4163a0cdc527a9d3 Mon Sep 17 00:00:00 2001 From: albdert Date: Fri, 2 Oct 2026 11:04:05 +0200 Subject: [PATCH 01/25] feature: db scripts, apollo install --- backend/package.json | 5 +++-- db/.env.example | 4 ++++ db/database-connect.md | 23 +++++++++++++++++++++++ package.json | 3 ++- scripts/db-tunnel.sh | 15 +++++++++++++++ scripts/test-select.sh | 2 ++ 6 files changed, 49 insertions(+), 3 deletions(-) create mode 100644 db/.env.example create mode 100644 db/database-connect.md create mode 100755 scripts/db-tunnel.sh create mode 100755 scripts/test-select.sh diff --git a/backend/package.json b/backend/package.json index c5121671..e70e316a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -3,16 +3,17 @@ "version": "1.0.0", "description": "", "main": "index.js", + "type": "module", "scripts": { "dev": "tsx watch src/index.ts", + "compile": "tsc", "build": "tsc", - "start": "node dist/index.js", + "start": "pnpm run build && node dist/index.js", "lint": "eslint ." }, "keywords": [], "author": "", "license": "ISC", - "type": "module", "dependencies": { "@apollo/server": "^5.5.1", "dotenv": "^18.0.5", diff --git a/db/.env.example b/db/.env.example new file mode 100644 index 00000000..2f4cadc4 --- /dev/null +++ b/db/.env.example @@ -0,0 +1,4 @@ +VM_USER=brukernavn +VM_HOST=it2810-02.idi.ntnu.no +TUNNEL_PORT=5433 +DATABASE_URL=postgresql://gameapp:PASSORD@localhost:5433/gamesdb diff --git a/db/database-connect.md b/db/database-connect.md new file mode 100644 index 00000000..c701b6c4 --- /dev/null +++ b/db/database-connect.md @@ -0,0 +1,23 @@ + +#### Database-connect + +Scripts for å koble seg til database ligger i `/scripts`. + +###### db-tunnel.sh + +Spesifikt inneholder denne `db-tunnel.sh` som oppretter en ssh tunnel til vm over porten `5432`. Dette gjør at all trafikk som normalt går til `localhost:5432` rutes til postgres på serveren, slik at vi slipper å laste ned hele databasen på hver av pc-ene våre. + +`db-tunnel.sh` scriptet er lagt inn som et script i `/package.json`, og kan kjøres med `pnpm db:tunnel`. + +###### test-select.sh + +Det andre scriptet er `test-select.sh`, dette er et enkelt script som kjører en spørring mot en database på `localhost:5432`. Hvis tunnellen fungerer skal dette returnere dette: + +``` + count +-------- + 120067 +(1 row) +``` + +For å kunne kjøre dette scriptet trenger man postgres-client. Dette kan lastes ned med `sudo apt update && sudo apt install postgresql-client` på debian/ubuntu, og med `brew install libpq` på mac. diff --git a/package.json b/package.json index 9f8245b3..0853878c 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "lint": "pnpm -r lint", "test": "pnpm -r test", "format": "prettier --write .", - "format:check": "prettier --check ." + "format:check": "prettier --check .", + "db:tunnel": "./scripts/db-tunnel.sh" }, "devDependencies": { "prettier": "^3.9.9" diff --git a/scripts/db-tunnel.sh b/scripts/db-tunnel.sh new file mode 100755 index 00000000..908c0089 --- /dev/null +++ b/scripts/db-tunnel.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")/.." +set -a +source db/.env +set +a + +if lsof -i ":$TUNNEL_PORT" >/dev/null 2>&1; then + echo "Port $TUNNEL_PORT er i bruk, tunnelen kjører antakelig fra før..." + exit 0 +fi + +echo "Tunnel: localhost:$TUNNEL_PORT -> $VM_HOST:5432 (Ctrl+C for å stoppe)" +ssh -N -L "$TUNNEL_PORT:localhost:5432" "$VM_USER@$VM_HOST" diff --git a/scripts/test-select.sh b/scripts/test-select.sh new file mode 100755 index 00000000..c3226c45 --- /dev/null +++ b/scripts/test-select.sh @@ -0,0 +1,2 @@ +#!/bin/bash +psql -h localhost -p 5433 -U gameapp -d gamesdb -c "SELECT count(*) FROM games;" From 4c60d7f043bf936d0a657d9bcfee1b0cd6e207a3 Mon Sep 17 00:00:00 2001 From: albdert Date: Fri, 2 Oct 2026 12:00:57 +0200 Subject: [PATCH 02/25] la til enkelt eksempel med apollo --- backend/src/index.ts | 49 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/backend/src/index.ts b/backend/src/index.ts index 5a97d4ff..d9926ee3 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,2 +1,47 @@ -// placeholder, erstattes av apollo server senere -console.log('backend'); +import { ApolloServer } from "@apollo/server"; +import { startStandaloneServer } from "@apollo/server/standalone"; +import { Query } from "pg"; +import { start } from "repl"; + +const typeDefs = `#graphql + # comment + type Book { + title: String + author: String + } + + type Query { + books: [Book] + } +`; + +const books = [ + { + title: 'The awakening', + author: 'Kate Chopin', + }, + { + title: 'City of glass', + author: 'Paul Auster', + }, +]; + +const resolvers = { + Query: { + books: () => books, + }, +}; + + +// apollo server instance +const server = new ApolloServer({ + typeDefs, + resolvers, +}); + +// pass apolloserver to standaloneserver +const { url } = await startStandaloneServer(server, { + listen: { port: 4000 }, +}); + +console.log(`Server ready at: ${url}`); \ No newline at end of file From 4bd45443de8866de6aea03826e49e2916b827db7 Mon Sep 17 00:00:00 2001 From: Albert Paulsen Lindberg Date: Thu, 8 Oct 2026 11:32:47 +0200 Subject: [PATCH 03/25] Create ssh-tunnel.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit liten dokumentasjon på bruk av tunnel-scriptet mot databasen på vm --- db/ssh-tunnel.md | 53 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 db/ssh-tunnel.md diff --git a/db/ssh-tunnel.md b/db/ssh-tunnel.md new file mode 100644 index 00000000..c0f0de44 --- /dev/null +++ b/db/ssh-tunnel.md @@ -0,0 +1,53 @@ +## Felles +#### Konfigurere `.env` +1. Koble til NTNU VPN hvis du er utenfor campus. +2. Kopier `backend/.env.example` til `backend/.env` +3. Fyll inn verdien til `VM_USER`, og `PASSORD` i `DATABASE_URL`. + +`backend/.env` skal da se ut slik: +``` +VM_USER= +VM_HOST=it2810-02.idi.ntnu.no +DATABASE_URL=postgresql://webapp:@localhost:5433/gamesdb +PORT=3001 +TUNNEL_PORT=5433 +``` + +#### Start tunnelen +Gå til rot i repo (dvs. `T02-Project-2` mappen) og kjør kommandoen: +```bash +pnpm db:tunnel +``` + +La terminalen stå åpen, og start backend i en annen terminal med `pnpm --filter backend dev`. + +## Windows + +OpenSSH følger med Windows 10 og 11. Bash-scriptet fungerer ikke via pnpm db:tunnel, fordi pnpm kjører scripts gjennom cmd. + +#### Start tunnelen i PowerShell: +```powershell +ssh -N -L 5433:localhost:5432 brukernavn@it2810-02.idi.ntnu.no +``` +Alternativt kan scriptet kjøres direkte i Git Bash eller WSL med ./scripts/db-tunnel.sh. Portsjekken i scriptet bruker lsof, som ikke finnes i Git Bash, men den hoppes da bare over. + +## Verifiser + +Med tunnelen åpen, fra rot i repoet: + +```bash +pnpm --filter backend exec tsx --env-file=.env -e " +import pg from 'pg'; +const c = new pg.Client(process.env.DATABASE_URL); +await c.connect(); +console.log((await c.query('SELECT count(*Kopier backend/.env.example til backend/.env og fyll inn VM_USER, VM_HOST, TUNNEL_PORT og DATABASE_URL (med webapp-rollen og port 5433).) FROM games')).rows); +await c.end(); +" +``` + +Skal skrive ut `[ { count: '120067' } ]`. + +#### Mulige feil +1. Connection refused på 5433 betyr at tunnelen ikke kjører. +2. Connection timed out mot VM-en betyr som regel at VPN mangler. +3. Password authentication failed betyr feil passord eller rolle i DATABASE_URL. From d532d445b24fa77715701833a2cd8e8f79bae72d Mon Sep 17 00:00:00 2001 From: Albert Paulsen Lindberg Date: Thu, 8 Oct 2026 12:00:55 +0200 Subject: [PATCH 04/25] Update database-usage.md oppdaterte med oversikt over roller, tilkobling, og litt om re-import av databasen --- db/database-usage.md | 53 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/db/database-usage.md b/db/database-usage.md index b9c6a64e..142ddfa9 100644 --- a/db/database-usage.md +++ b/db/database-usage.md @@ -1,5 +1,32 @@ +## Roller for databasen i postgres -#### Database-usage +|Rolle|Bruk|Rettigheter| +|-|-|-| +|postgres|Administrasjon|Superuser| +|gameapp|Import og schemaendringer|Eier av gamesdb| +|webapp|Backend|SELECT på alt, INSERT på reviews| + +## Koble til postgres +#### Fra vm-en: +```bash +psql -h localhost -U webapp -d gamesdb +psql -h localhost -U gameapp -d gamesdb +sudo -u postgres psql -d gamesdb +``` + +#### Fra egen maskin (`pnpm db:tunnel`): +```bash +psql -h localhost -p 5433 -U webapp -d gamesdb +``` + +#### Backend bruker `DATABASE_URL` i `backend/.env`: +``` +postgresql://webapp:PASSORD@localhost:5433/gamesdb +``` + +Port `5433` lokalt via tunnel, `5432` på VM-en. + +## Bruk av database Under er noen generelle kommandoer som kan brukes i postgres for navigere databasen: @@ -27,3 +54,27 @@ SELECT * FROM games LIMIT 5; SELECT metacritic FROM games WHERE id = 4291; ``` + +## Import og rettigheter + +Filer i `backend/db/`: `schema.sql`, `indexes.sql`, `grants.sql`, `load.py`, `requirements.txt`. + +_Full reimport på VM-en (sletter også reviews)_: +``` +cd backend/db && source .venv/bin/activate +export DATABASE_URL='postgresql://gameapp:PASSORD@localhost:5432/gamesdb' +python load.py --force +psql "$DATABASE_URL" -f grants.sql +``` + +>[!NOTE] +>`grants.sql` må kjøres etter hver reimport, siden tabellene gjenopprettes og mister rettighetene. + +_Sjekk status på tjenesten_: +```bash +sudo systemctl status postgresql +sudo systemctl restart postgresql +sudo ss -tlnp | grep 5432 +``` + +Siste skal kun vise `127.0.0.1:5432` og `[::1]:5432`. From 40b14e4ee33d6375a76bac7d6c847368cbd20cf8 Mon Sep 17 00:00:00 2001 From: Albert Paulsen Lindberg Date: Thu, 8 Oct 2026 12:07:19 +0200 Subject: [PATCH 05/25] Create grants.sql MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit grants for databasen, gir webapp tilgang til å lese til alle tabeller og kun skrive til reviews --- db/grants.sql | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 db/grants.sql diff --git a/db/grants.sql b/db/grants.sql new file mode 100644 index 00000000..3d3f5532 --- /dev/null +++ b/db/grants.sql @@ -0,0 +1,6 @@ +GRANT CONNECT ON DATABASE gamesdb TO webapp; +GRANT USAGE ON SCHEMA public TO webapp; + +GRANT SELECT ON ALL TABLES IN SCHEMA public TO webapp; +GRANT INSERT ON reviews TO webapp; +GRANT USAGE ON SEQUENCE reviews_id_seq TO webapp; From bded41f52261a8bf3e4eb3057b707497cb4379f6 Mon Sep 17 00:00:00 2001 From: LeonaS Date: Thu, 8 Oct 2026 13:32:32 +0200 Subject: [PATCH 06/25] feat(frontend): legg til GameSummary-type for spillkort --- frontend/src/types/game.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 frontend/src/types/game.ts diff --git a/frontend/src/types/game.ts b/frontend/src/types/game.ts new file mode 100644 index 00000000..0829d119 --- /dev/null +++ b/frontend/src/types/game.ts @@ -0,0 +1,13 @@ +//Inneholder dataene ett spillekort trenger. Feltene speiler tabellen 'games' + sjangernavn slik de vil komme fra GraphQL-APIet. + +export interface GameSummary { + id: string; + name: string; + released: string | null; + backgroundImage: string | null; + // RAWG-rating, 0-5 + rating: number | null; + // Metacritic score, 0-100 + metacritic: number | null; + genres: string[]; +} From ac70bf58c55a8ae261a7e87ea4e6a284a4db8011 Mon Sep 17 00:00:00 2001 From: LeonaS Date: Thu, 8 Oct 2026 13:32:43 +0200 Subject: [PATCH 07/25] feat(frontend): legg til GameCard-komponent --- frontend/src/components/GameCard/GameCard.tsx | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 frontend/src/components/GameCard/GameCard.tsx diff --git a/frontend/src/components/GameCard/GameCard.tsx b/frontend/src/components/GameCard/GameCard.tsx new file mode 100644 index 00000000..b2deeb3b --- /dev/null +++ b/frontend/src/components/GameCard/GameCard.tsx @@ -0,0 +1,64 @@ +import type { GameSummary } from '../../types/game'; +import './GameCard.css'; + +interface GameCardProps { + game: GameSummary; + isFavorite: boolean; + onToggleFavorite: (id: string) => void; +} + +//Viser ett spill i resultatlista. Lenken i tittelen dekker hele kortet (via CSS), så hele kortet er klikkbart, men skjermlesere hører bare én lenke per kort. + +export function GameCard({ game, isFavorite, onToggleFavorite }: GameCardProps) { + const year = game.released?.slice(0, 4) ?? null; + const meta = [year, game.genres.slice(0, 2).join(', ')].filter(Boolean).join(' · '); + + return ( +
+ {game.backgroundImage ? ( + // alt="" fordi tittelen allerede beskriver kortet. Fast størrelse og lazy + // loading hindrer at layouten hopper og sparer data ved lange lister. + + ) : ( +
+ ); +} From 885d705f2f1e86cfa6cd38fe1bcf4d5159e1efd0 Mon Sep 17 00:00:00 2001 From: LeonaS Date: Thu, 8 Oct 2026 13:33:03 +0200 Subject: [PATCH 08/25] feat(frontend): stil for GameCard --- frontend/src/components/GameCard/GameCard.css | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 frontend/src/components/GameCard/GameCard.css diff --git a/frontend/src/components/GameCard/GameCard.css b/frontend/src/components/GameCard/GameCard.css new file mode 100644 index 00000000..8566b789 --- /dev/null +++ b/frontend/src/components/GameCard/GameCard.css @@ -0,0 +1,118 @@ +/* Én rad i resultatlista. Ingen boks, bare en skillelinje under. */ +.game-card { + position: relative; + display: grid; + grid-template-columns: 192px 1fr; + gap: 20px; + padding: 20px 0; + text-align: left; + border-bottom: 1px solid var(--border); +} + +.game-card__image { + display: block; + width: 100%; + height: auto; + aspect-ratio: 16 / 9; + object-fit: cover; + background: var(--code-bg); /* vises mens bildet laster / når det mangler */ +} + +.game-card__body { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; +} + +.game-card__title { + margin: 0; + font-size: 20px; + font-weight: 600; + line-height: 1.25; +} + +.game-card__title a { + color: var(--text-h); + text-decoration: none; +} + +/* Strekker lenken over hele raden, så hele raden er klikkbar */ +.game-card__title a::after { + content: ''; + position: absolute; + inset: 0; +} + +.game-card:hover .game-card__title a { + text-decoration: underline; + text-underline-offset: 3px; +} + +/* Kantete fokusring rundt hele raden ved tastaturnavigasjon */ +.game-card__title a:focus-visible { + outline: none; +} +.game-card:has(.game-card__title a:focus-visible) { + outline: 2px solid var(--text-h); + outline-offset: 4px; +} + +.game-card__meta { + font-size: 15px; +} + +.game-card__scores { + display: flex; + gap: 24px; + margin: 4px 0 8px; + font-size: 15px; +} + +.game-card__scores div { + display: flex; + gap: 6px; +} + +.game-card__scores dd { + margin: 0; + color: var(--text-h); + font-variant-numeric: tabular-nums; /* tallene står rett under hverandre i lista */ +} + +/* Tekstknapp med rett ramme, som [+ Favoritt] i skissen */ +.game-card__favorite { + position: relative; + z-index: 1; /* over den strukne lenken */ + margin-top: auto; + min-height: 44px; /* stort nok trykkmål */ + padding: 0 14px; + font: inherit; + font-size: 15px; + color: var(--text-h); + background: none; + border: 1px solid var(--text-h); + border-radius: 0; + cursor: pointer; +} + +.game-card__favorite:hover { + text-decoration: underline; +} + +.game-card__favorite[aria-pressed='true'] { + color: var(--bg); + background: var(--text-h); +} + +.game-card__favorite:focus-visible { + outline: 2px solid var(--text-h); + outline-offset: 2px; +} + +@media (max-width: 480px) { + .game-card { + grid-template-columns: 1fr; + gap: 12px; + } +} From 759c4515b905dea416a33fa0592fe416f5af9e7e Mon Sep 17 00:00:00 2001 From: LeonaS Date: Fri, 9 Oct 2026 10:54:55 +0200 Subject: [PATCH 09/25] feat(frontend): legg til view-prop i GameCard for liste og rutenett --- frontend/src/components/GameCard/GameCard.tsx | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/GameCard/GameCard.tsx b/frontend/src/components/GameCard/GameCard.tsx index b2deeb3b..690148d2 100644 --- a/frontend/src/components/GameCard/GameCard.tsx +++ b/frontend/src/components/GameCard/GameCard.tsx @@ -5,16 +5,20 @@ interface GameCardProps { game: GameSummary; isFavorite: boolean; onToggleFavorite: (id: string) => void; + /** 'list' = visning 1 (rad), 'grid' = visning 2 (oppsummert rute) */ + view?: 'list' | 'grid'; } //Viser ett spill i resultatlista. Lenken i tittelen dekker hele kortet (via CSS), så hele kortet er klikkbart, men skjermlesere hører bare én lenke per kort. -export function GameCard({ game, isFavorite, onToggleFavorite }: GameCardProps) { +export function GameCard({ game, isFavorite, onToggleFavorite, view = 'list' }: GameCardProps) { const year = game.released?.slice(0, 4) ?? null; - const meta = [year, game.genres.slice(0, 2).join(', ')].filter(Boolean).join(' · '); + const meta = [year, game.genres.slice(0, view === 'grid' ? 1 : 2).join(', ')] + .filter(Boolean) + .join(' · '); return ( -
+
{game.backgroundImage ? ( // alt="" fordi tittelen allerede beskriver kortet. Fast størrelse og lazy // loading hindrer at layouten hopper og sparer data ved lange lister. @@ -43,10 +47,12 @@ export function GameCard({ game, isFavorite, onToggleFavorite }: GameCardProps)
Rating
{game.rating != null ? `${game.rating.toFixed(1)} / 5` : 'Ingen'}
-
-
Metacritic
-
{game.metacritic ?? 'Ingen'}
-
+ {view === 'list' && ( +
+
Metacritic
+
{game.metacritic ?? 'Ingen'}
+
+ )} - + const toggleFavorite = (id: string) => { + setFavorites((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; -
+ return ( +
+

Spill

+

Søkeresultater

-
-
- -

Documentation

-

Your questions, answered

- -
-
- -

Connect with us

-

Join the Vite community

- -
-
+ {/* Bytter mellom visning 1 (liste) og visning 2 (rutenett) */} +
+ + +
-
-
- + +
); } From 015fbb87be10e908bab9c01ac267128331f9e34b Mon Sep 17 00:00:00 2001 From: johannes Date: Fri, 9 Oct 2026 11:44:23 +0200 Subject: [PATCH 15/25] feat(frontend): legg til useDebounce-hook --- frontend/src/hooks/useDebounce.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 frontend/src/hooks/useDebounce.ts diff --git a/frontend/src/hooks/useDebounce.ts b/frontend/src/hooks/useDebounce.ts new file mode 100644 index 00000000..de407838 --- /dev/null +++ b/frontend/src/hooks/useDebounce.ts @@ -0,0 +1,12 @@ +import { useEffect, useState } from 'react'; + +export function useDebounce(value: T, delay = 300): T { + const [debounced, setDebounced] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebounced(value), delay); + return () => clearTimeout(timer); + }, [value, delay]); + + return debounced; +} \ No newline at end of file From 1a01103dee802cd83de89ab686eb73690d369ae0 Mon Sep 17 00:00:00 2001 From: johannes Date: Fri, 9 Oct 2026 11:44:43 +0200 Subject: [PATCH 16/25] feat(frontend): legg til SearchBar-komponent --- .../src/components/SearchBar/SearchBar.tsx | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 frontend/src/components/SearchBar/SearchBar.tsx diff --git a/frontend/src/components/SearchBar/SearchBar.tsx b/frontend/src/components/SearchBar/SearchBar.tsx new file mode 100644 index 00000000..e7f504e6 --- /dev/null +++ b/frontend/src/components/SearchBar/SearchBar.tsx @@ -0,0 +1,30 @@ +import './SearchBar.css'; + +interface SearchBarProps { + value: string; + onChange: (value: string) => void; +} + +// Søkefeltet øverst på siden. Komponenten eier ikke state selv: +// forelderen styrer verdien og bestemmer når det faktisk søkes (debounce). +export function SearchBar({ value, onChange }: SearchBarProps) { + return ( + // role="search" gjør at skjermlesere kan hoppe rett til søket. + // preventDefault hindrer at Enter laster siden på nytt. +
e.preventDefault()}> + {/* Skjult visuelt, men lest opp av skjermlesere. Placeholder alene er ikke nok. */} + + onChange(e.target.value)} + autoComplete="off" + /> +
+ ); +} \ No newline at end of file From 567f324faf00d05246de6c25bdef8cf245d55768 Mon Sep 17 00:00:00 2001 From: johannes Date: Fri, 9 Oct 2026 11:44:56 +0200 Subject: [PATCH 17/25] =?UTF-8?q?style(frontend):=20firkantet=20s=C3=B8kef?= =?UTF-8?q?elt=20med=20skjult=20label=20og=20fokusramme?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/SearchBar/SearchBar.css | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 frontend/src/components/SearchBar/SearchBar.css diff --git a/frontend/src/components/SearchBar/SearchBar.css b/frontend/src/components/SearchBar/SearchBar.css new file mode 100644 index 00000000..949cd4b3 --- /dev/null +++ b/frontend/src/components/SearchBar/SearchBar.css @@ -0,0 +1,22 @@ +/* Skjuler labelen visuelt, men beholder den for skjermlesere. */ +.search-bar__label { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; +} + +.search-bar__input { + width: 100%; + padding: 0.6rem 0.8rem; + font: inherit; + border: 1px solid #767676; + border-radius: 0; + -webkit-appearance: textfield; + appearance: textfield; +} From a72e0897bdc98248fae86bf4a583cdd20c6f82ef Mon Sep 17 00:00:00 2001 From: Albert Paulsen Lindberg Date: Fri, 9 Oct 2026 11:45:17 +0200 Subject: [PATCH 18/25] Update database-setup.md --- db/database-setup.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/db/database-setup.md b/db/database-setup.md index 614dbe08..b786ff44 100644 --- a/db/database-setup.md +++ b/db/database-setup.md @@ -19,7 +19,9 @@ sudo -u postgres psql ```sql CREATE USER gameapp WITH PASSWORD ''; +CREATE ROLE webapp WITH LOGIN PASSWORD ''; CREATE DATABASE gamesdb OWNER gameapp; + \c gamesdb CREATE EXTENSION IF NOT EXISTS pg_trgm; \q @@ -52,7 +54,8 @@ cd backend/db python3 -m venv .venv && source .venv/bin/activate pip install -r requirements.txt export DATABASE_URL='postgresql://gameapp:@localhost:5432/gamesdb' -python load.py +python load.py --force +psql "$DATABASE_URL" -f grants.sql ``` `load.py` leser Parquet-filen med DuckDB, deler opp flerverdi-felter til oppslags- og koblingstabeller, laster alt med `COPY`, oppretter indekser etter lasting og kjører `ANALYZE`. Filstien kan overstyres med `PARQUET_PATH`. Finnes tabellene allerede, avbryter scriptet. `python load.py --force` sletter og laster alt på nytt, inkludert reviews. From 05a0f6f739f2c89458a435a186d69f31329737e6 Mon Sep 17 00:00:00 2001 From: johannes Date: Fri, 9 Oct 2026 11:45:36 +0200 Subject: [PATCH 19/25] =?UTF-8?q?feat(frontend):=20koble=20s=C3=B8k=20til?= =?UTF-8?q?=20spill-lista=20(midlertidig=20klientfiltrering=20av=20mockdat?= =?UTF-8?q?a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7a024807..84c76beb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,8 @@ import { useState } from 'react'; import { GameList } from './components/GameList/GameList'; import { mockGames } from './mocks/games'; +import { SearchBar } from './components/SearchBar/SearchBar'; +import { useDebounce } from './hooks/useDebounce'; /** * Midlertidig forside: viser spillene med mockdata til GraphQL er klart. @@ -10,6 +12,16 @@ function App() { const [favorites, setFavorites] = useState>(new Set()); const [view, setView] = useState<'list' | 'grid'>('list'); + // Det brukeren skriver nå, og det vi faktisk søker på etter 300 ms pause + const [input, setInput] = useState(''); + const query = useDebounce(input.trim(), 300); + + // MIDLERTIDIG: filtrerer mockdata på klienten for å kunne teste søket. + // Når GraphQL er klart, sendes `query` til backend og filtreres i databasen. + const games = mockGames.filter((g) => + g.name.toLowerCase().includes(query.toLowerCase()), + ); + const toggleFavorite = (id: string) => { setFavorites((prev) => { const next = new Set(prev); @@ -22,6 +34,7 @@ function App() { return (

Spill

+

Søkeresultater

{/* Bytter mellom visning 1 (liste) og visning 2 (rutenett) */} @@ -35,7 +48,7 @@ function App() { Date: Fri, 9 Oct 2026 14:53:46 +0200 Subject: [PATCH 20/25] lagt inn logo top left, og fjernet SPILL --- frontend/index.html | 2 +- frontend/public/favicon.svg | 2 +- frontend/src/App.tsx | 5 ++- .../logo/damp-logo-horizontal-on-dark.svg | 1 + .../logo/damp-logo-horizontal-on-light.svg | 1 + .../assets/logo/damp-logo-stacked-on-dark.svg | 1 + .../logo/damp-logo-stacked-on-light.svg | 1 + .../src/assets/logo/damp-mark-on-dark.svg | 1 + .../src/assets/logo/damp-mark-on-light.svg | 1 + frontend/src/components/Logo/Logo.css | 37 +++++++++++++++++++ frontend/src/components/Logo/Logo.tsx | 13 +++++++ 11 files changed, 61 insertions(+), 4 deletions(-) create mode 100644 frontend/src/assets/logo/damp-logo-horizontal-on-dark.svg create mode 100644 frontend/src/assets/logo/damp-logo-horizontal-on-light.svg create mode 100644 frontend/src/assets/logo/damp-logo-stacked-on-dark.svg create mode 100644 frontend/src/assets/logo/damp-logo-stacked-on-light.svg create mode 100644 frontend/src/assets/logo/damp-mark-on-dark.svg create mode 100644 frontend/src/assets/logo/damp-mark-on-light.svg create mode 100644 frontend/src/components/Logo/Logo.css create mode 100644 frontend/src/components/Logo/Logo.tsx diff --git a/frontend/index.html b/frontend/index.html index 0fca6f04..78357eeb 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ - frontend + Damp
diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg index 6893eb13..34534755 100644 --- a/frontend/public/favicon.svg +++ b/frontend/public/favicon.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 84c76beb..f657f66c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,6 +3,7 @@ import { GameList } from './components/GameList/GameList'; import { mockGames } from './mocks/games'; import { SearchBar } from './components/SearchBar/SearchBar'; import { useDebounce } from './hooks/useDebounce'; +import { Logo} from './components/Logo/Logo'; /** * Midlertidig forside: viser spillene med mockdata til GraphQL er klart. @@ -32,8 +33,8 @@ function App() { }; return ( -
-

Spill

+
+ {/* NY: logoen øverst til høyre */}

Søkeresultater

diff --git a/frontend/src/assets/logo/damp-logo-horizontal-on-dark.svg b/frontend/src/assets/logo/damp-logo-horizontal-on-dark.svg new file mode 100644 index 00000000..e341514d --- /dev/null +++ b/frontend/src/assets/logo/damp-logo-horizontal-on-dark.svg @@ -0,0 +1 @@ +Damp diff --git a/frontend/src/assets/logo/damp-logo-horizontal-on-light.svg b/frontend/src/assets/logo/damp-logo-horizontal-on-light.svg new file mode 100644 index 00000000..d23dcef8 --- /dev/null +++ b/frontend/src/assets/logo/damp-logo-horizontal-on-light.svg @@ -0,0 +1 @@ +Damp diff --git a/frontend/src/assets/logo/damp-logo-stacked-on-dark.svg b/frontend/src/assets/logo/damp-logo-stacked-on-dark.svg new file mode 100644 index 00000000..980071ea --- /dev/null +++ b/frontend/src/assets/logo/damp-logo-stacked-on-dark.svg @@ -0,0 +1 @@ +Damp diff --git a/frontend/src/assets/logo/damp-logo-stacked-on-light.svg b/frontend/src/assets/logo/damp-logo-stacked-on-light.svg new file mode 100644 index 00000000..6ceb29b3 --- /dev/null +++ b/frontend/src/assets/logo/damp-logo-stacked-on-light.svg @@ -0,0 +1 @@ +Damp diff --git a/frontend/src/assets/logo/damp-mark-on-dark.svg b/frontend/src/assets/logo/damp-mark-on-dark.svg new file mode 100644 index 00000000..e2a0f87e --- /dev/null +++ b/frontend/src/assets/logo/damp-mark-on-dark.svg @@ -0,0 +1 @@ +Damp logo mark diff --git a/frontend/src/assets/logo/damp-mark-on-light.svg b/frontend/src/assets/logo/damp-mark-on-light.svg new file mode 100644 index 00000000..a81e0c36 --- /dev/null +++ b/frontend/src/assets/logo/damp-mark-on-light.svg @@ -0,0 +1 @@ +Damp logo mark diff --git a/frontend/src/components/Logo/Logo.css b/frontend/src/components/Logo/Logo.css new file mode 100644 index 00000000..a91c291b --- /dev/null +++ b/frontend/src/components/Logo/Logo.css @@ -0,0 +1,37 @@ +/* Smale skjermer: øverst til venstre, scroller vekk så den ikke dekker innholdet */ +.logo { + position: absolute; + top: 20px; + left: 20px; + z-index: 10; +} + +.logo img { + display: block; + height: 88px; + width: auto; +} + +@media (max-width: 600px) { + .logo img { + height: 56px; /* Mindre logo på mobil */ + } +} + +/* Brede skjermer: fast og midt i sidepanelet til venstre. Må stå sist i filen */ +@media (min-width: 1320px) { + .logo { + position: fixed; + top: 20px; + /* Midten av sidepanelet. 1126px er bredden på #root i index.css; endres den, må den endres her også */ + left: calc((100vw - 1126px) / 4); + /* Flytter logoen halvparten av sin egen bredde til venstre, så midten av logoen havner midt i panelet */ + transform: translateX(-50%); + } + + .logo img { + height: auto; + /* Større: opptil 160px bred, men aldri bredere enn panelet minus 24px luft på hver side */ + width: min(90px, calc((100vw - 1126px) / 2 - 48px)); + } +} \ No newline at end of file diff --git a/frontend/src/components/Logo/Logo.tsx b/frontend/src/components/Logo/Logo.tsx new file mode 100644 index 00000000..24eb3dcd --- /dev/null +++ b/frontend/src/components/Logo/Logo.tsx @@ -0,0 +1,13 @@ +import logoOnDark from '../../assets/logo/damp-logo-stacked-on-dark.svg'; +import logoOnLight from '../../assets/logo/damp-logo-stacked-on-light.svg'; +import './Logo.css'; + +/** Damp-logoen (symbol med navnet under). Bytter versjon etter lyst/mørkt tema. */ +export function Logo() { + return ( + + + Damp + + ); +} From a45741402f0403a0fc567181a7925fed422ad830 Mon Sep 17 00:00:00 2001 From: albdert Date: Mon, 12 Oct 2026 13:11:13 +0200 Subject: [PATCH 21/25] #27 fungerende graphql server --- .gitignore | 1 - backend/.env.example | 9 +++- backend/package.json | 2 +- backend/src/auth.ts | 3 ++ backend/src/db.ts | 7 ++++ backend/src/index.ts | 44 ++++---------------- backend/src/resolvers.ts | 89 ++++++++++++++++++++++++++++++++++++++++ backend/src/schema.ts | 71 ++++++++++++++++++++++++++++++++ 8 files changed, 188 insertions(+), 38 deletions(-) create mode 100644 backend/src/auth.ts create mode 100644 backend/src/db.ts create mode 100644 backend/src/resolvers.ts create mode 100644 backend/src/schema.ts diff --git a/.gitignore b/.gitignore index 817612c5..f46ca984 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,6 @@ node_modules dist coverage .env -.env.* !.env.example *.log playwright-report diff --git a/backend/.env.example b/backend/.env.example index 6aed5c03..68799a7d 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,2 +1,9 @@ -DATABASE_URL=postgresql://gameapp:@localhost:5432/gamesdb +# tunnel script env vars +VM_USER= +VM_HOST=it2810-02.idi.ntnu.no +TUNNEL_PORT=5433 + +# backend server env vars +DATABASE_URL=postgresql://webapp:@localhost:5433/gamesdb PORT=3001 +JWT_TOKEN= \ No newline at end of file diff --git a/backend/package.json b/backend/package.json index e70e316a..17b9e076 100644 --- a/backend/package.json +++ b/backend/package.json @@ -5,7 +5,7 @@ "main": "index.js", "type": "module", "scripts": { - "dev": "tsx watch src/index.ts", + "dev": "tsx watch --env-file=.env src/index.ts", "compile": "tsc", "build": "tsc", "start": "pnpm run build && node dist/index.js", diff --git a/backend/src/auth.ts b/backend/src/auth.ts new file mode 100644 index 00000000..7816c105 --- /dev/null +++ b/backend/src/auth.ts @@ -0,0 +1,3 @@ + +// server jwt secret +// const secret = process.env.JWT_SECRET; \ No newline at end of file diff --git a/backend/src/db.ts b/backend/src/db.ts new file mode 100644 index 00000000..ead91fad --- /dev/null +++ b/backend/src/db.ts @@ -0,0 +1,7 @@ +import pg from 'pg'; + +const url = process.env.DATABASE_URL; +if (!url) throw new Error("DATABASE_URL not set in .env ...") + +//pg pool +export const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL}) diff --git a/backend/src/index.ts b/backend/src/index.ts index d9926ee3..ba22d187 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,47 +1,21 @@ import { ApolloServer } from "@apollo/server"; import { startStandaloneServer } from "@apollo/server/standalone"; -import { Query } from "pg"; -import { start } from "repl"; +import { pool } from './db.js'; +import { typeDefs } from './schema.js'; +import { resolvers } from './resolvers.js'; -const typeDefs = `#graphql - # comment - type Book { - title: String - author: String - } - - type Query { - books: [Book] - } -`; - -const books = [ - { - title: 'The awakening', - author: 'Kate Chopin', - }, - { - title: 'City of glass', - author: 'Paul Auster', - }, -]; - -const resolvers = { - Query: { - books: () => books, - }, -}; +// apollo-server port number [default port 3001] +const port = Number(process.env.PORT ?? 3001) +// tester db tilkobling med en query +await pool.query('SELECT 1'); // apollo server instance -const server = new ApolloServer({ - typeDefs, - resolvers, -}); +const server = new ApolloServer({ typeDefs, resolvers }); // pass apolloserver to standaloneserver const { url } = await startStandaloneServer(server, { - listen: { port: 4000 }, + listen: { port }, }); console.log(`Server ready at: ${url}`); \ No newline at end of file diff --git a/backend/src/resolvers.ts b/backend/src/resolvers.ts new file mode 100644 index 00000000..bbbca361 --- /dev/null +++ b/backend/src/resolvers.ts @@ -0,0 +1,89 @@ +import { pool } from './db.js'; + +const VALID_ID_REGEX = /^\d{1,18}$/; +const MAX_PAGE_SIZE = 50; + +// definerer hvilke kolonner som skal hentes for en generell +// forespørsel om et spill, inkludert oversetting fra databasen +// sin navnekonvensjon til GraphQL, og konvertering av typer +const GAME_COLS = ` + id, + name, + description, + released::text AS "released", + website, + added, + metacritic, + esrb_rating AS "esrbRating", + dominant_color AS "dominantColor", + background_image AS "backgroundImage", + background_image_additional AS "backgroundImageAdditional", + COALESCE(screenshots, '{}') AS "screenshots" +`; + +// SQL queries +const SQL_COUNT = ` + SELECT count(*)::int AS count FROM games +`; + +const SQL_GAME = ` + SELECT ${GAME_COLS} FROM games + WHERE id = $1 +`; + +const SQL_GAMES = ` + SELECT ${GAME_COLS} FROM games + ORDER BY added DESC, id DESC + LIMIT $1 +`; + +// resolver for koblings-tabellene i databasen +const relation = (table: string, junc: string, fk: string) => { + const sql = ` + SELECT t.id, t.name + FROM ${junc} j + JOIN ${table} t ON t.id = j.${fk} + WHERE j.game_id = $1 + ORDER BY t.name + `; + return async (game: {id: string }) => { + const res = await pool.query(sql, [game.id]); + return res.rows; + }; +}; + +// resolvers for å hente kjøre spørringer mot databasen +export const resolvers = { + Query: { + // ett spill basert på id + game: async (_parent: unknown, args: { id: string }) => { + if (!VALID_ID_REGEX.test(args.id)) { return null; } + const res = await pool.query(SQL_GAME, [args.id]) + return res.rows[0] ?? null; + }, + + // liste med spill formattert som en GameConnection + games: async (_parent: unknown, args: { first: number }) => { + const first = Math.min(Math.max(args.first, 1), MAX_PAGE_SIZE); + const res = await pool.query(SQL_GAMES, [first]); + return { + edges: res.rows.map((row) => ({ node: row, cursor: '' })), + pageInfo: { hasNextPage: false, endCursor: null}, + }; + } + }, + + Game: { + genres: relation('genres', 'game_genres', 'genre_id'), + platforms: relation('platforms', 'game_platforms', 'platform_id'), + parentPlatforms: relation('parent_platforms', 'game_parent_platforms', 'parent_platform_id'), + stores: relation('stores', 'game_stores', 'store_id'), + developers: relation('developers', 'game_developers', 'developer_id'), + publishers: relation('publishers', 'game_publishers', 'publisher_id'), + tags: relation('tags', 'game_tags', 'tag_id'), + }, + + GameConnection: { + totalCount: async() => { return (await pool.query(SQL_COUNT)).rows[0].count; } + } +}; diff --git a/backend/src/schema.ts b/backend/src/schema.ts new file mode 100644 index 00000000..c18d3a3b --- /dev/null +++ b/backend/src/schema.ts @@ -0,0 +1,71 @@ + +export const typeDefs = `#graphql + """ + Generell type for attributter som sjanger, platform, etc. + """ + type Facet { + id: ID! + name: String! + } + + """ + Game holder all informasjon om ett enkelt spill. + """ + type Game { + id: ID!, + name: String!, + description: String, + released: String, + website: String, + added: Int!, + metacritic: Int, + esrbRating: String, + dominantColor: String, + backgroundImage: String, + backgroundImageAdditional: String, + screenshots: [String!]! + + genres: [Facet!]! + platforms: [Facet!]! + parentPlatforms: [Facet!]! + stores: [Facet!]! + developers: [Facet!]! + publishers: [Facet!]! + tags: [Facet!]! + } + + """ + Resultatet av en 'games' query, dette returnerer en 'page' + med mange 'edges' (spill) og kontroll-informasjon for pagination. + """ + type GameConnection { + edges: [GameEdge!]! + pageInfo: PageInfo! + totalCount: Int! + } + + """ + Én 'game-edge' fra GameConnection. Inneholder informasjon om spillet (Game) + og posisjonen til spillet i lista (cursor). + """ + type GameEdge { + node: Game! + cursor: String! + } + + """ + Info om nåværende page, kontrollinformasjon for neste forespørsel. + """ + type PageInfo { + hasNextPage: Boolean! + endCursor: String + } + + """ + Alle gyldige queries mot backend er definert her. + """ + type Query { + game(id: ID!): Game + games(first: Int=20): GameConnection! + } +`; \ No newline at end of file From e512f225661f81d16225bc857f766906d29d4fb3 Mon Sep 17 00:00:00 2001 From: albdert Date: Mon, 12 Oct 2026 13:15:33 +0200 Subject: [PATCH 22/25] readd .env.* --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index f46ca984..817612c5 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ node_modules dist coverage .env +.env.* !.env.example *.log playwright-report From ef2d526aa9c238125594093863e4b17d8510907f Mon Sep 17 00:00:00 2001 From: albdert Date: Mon, 12 Oct 2026 14:02:29 +0200 Subject: [PATCH 23/25] search --- backend/src/resolvers.ts | 67 +++++++++++++++++++++++++--------------- backend/src/schema.ts | 2 +- 2 files changed, 43 insertions(+), 26 deletions(-) diff --git a/backend/src/resolvers.ts b/backend/src/resolvers.ts index bbbca361..3df91903 100644 --- a/backend/src/resolvers.ts +++ b/backend/src/resolvers.ts @@ -1,7 +1,15 @@ import { pool } from './db.js'; +// type definitions +type GamesArgs = { + search?: string | null; + first: number; +}; + +// const vars and func const VALID_ID_REGEX = /^\d{1,18}$/; const MAX_PAGE_SIZE = 50; +const escapeChars = (s: string) => s.replace(/[\\%_]/g, '\\$&') // definerer hvilke kolonner som skal hentes for en generell // forespørsel om et spill, inkludert oversetting fra databasen @@ -22,23 +30,51 @@ const GAME_COLS = ` `; // SQL queries +const GAMES_PATTERN = ` + WHERE ($1::text IS NULL + OR search_names ILIKE $1) +`; const SQL_COUNT = ` SELECT count(*)::int AS count FROM games + ${GAMES_PATTERN} `; - const SQL_GAME = ` SELECT ${GAME_COLS} FROM games WHERE id = $1 `; - const SQL_GAMES = ` SELECT ${GAME_COLS} FROM games + ${GAMES_PATTERN} ORDER BY added DESC, id DESC - LIMIT $1 + LIMIT $2 `; +// game resolvers +async function game(_parent: unknown, args: { id: string }) { + if (!VALID_ID_REGEX.test(args.id)) { return null; } + const res = await pool.query(SQL_GAME, [args.id]) + return res.rows[0] ?? null; +}; + +async function games(_parent: unknown, args: GamesArgs ) { + const term = args.search?.trim(); + const pattern = term?`%${escapeChars(term)}%`:null; + const first = Math.min(Math.max(args.first, 1), MAX_PAGE_SIZE); + + const res = await pool.query(SQL_GAMES, [pattern, first]); + return { + edges: res.rows.map((row) => ({ node: row, cursor: '' })), + pageInfo: { hasNextPage: false, endCursor: null}, + pattern, + }; +} + +async function totalCount(parent: { pattern: string | null }) { + return (await pool.query(SQL_COUNT, [parent.pattern])).rows[0].count; +} + // resolver for koblings-tabellene i databasen -const relation = (table: string, junc: string, fk: string) => { +function relation(table: string, junc: string, fk: string) { const sql = ` SELECT t.id, t.name FROM ${junc} j @@ -54,24 +90,7 @@ const relation = (table: string, junc: string, fk: string) => { // resolvers for å hente kjøre spørringer mot databasen export const resolvers = { - Query: { - // ett spill basert på id - game: async (_parent: unknown, args: { id: string }) => { - if (!VALID_ID_REGEX.test(args.id)) { return null; } - const res = await pool.query(SQL_GAME, [args.id]) - return res.rows[0] ?? null; - }, - - // liste med spill formattert som en GameConnection - games: async (_parent: unknown, args: { first: number }) => { - const first = Math.min(Math.max(args.first, 1), MAX_PAGE_SIZE); - const res = await pool.query(SQL_GAMES, [first]); - return { - edges: res.rows.map((row) => ({ node: row, cursor: '' })), - pageInfo: { hasNextPage: false, endCursor: null}, - }; - } - }, + Query: { game, games }, Game: { genres: relation('genres', 'game_genres', 'genre_id'), @@ -83,7 +102,5 @@ export const resolvers = { tags: relation('tags', 'game_tags', 'tag_id'), }, - GameConnection: { - totalCount: async() => { return (await pool.query(SQL_COUNT)).rows[0].count; } - } + GameConnection: { totalCount } }; diff --git a/backend/src/schema.ts b/backend/src/schema.ts index c18d3a3b..e8bb0864 100644 --- a/backend/src/schema.ts +++ b/backend/src/schema.ts @@ -66,6 +66,6 @@ export const typeDefs = `#graphql """ type Query { game(id: ID!): Game - games(first: Int=20): GameConnection! + games(search: String, first: Int=20): GameConnection! } `; \ No newline at end of file From 69308656131e41c881ee8a0ad304a4ca095eb3c0 Mon Sep 17 00:00:00 2001 From: albdert Date: Mon, 12 Oct 2026 15:04:15 +0200 Subject: [PATCH 24/25] filtering! --- backend/src/resolvers.ts | 129 ++++++++++++++++++++++++++++++--------- backend/src/schema.ts | 16 ++++- 2 files changed, 113 insertions(+), 32 deletions(-) diff --git a/backend/src/resolvers.ts b/backend/src/resolvers.ts index 3df91903..9a9e10ae 100644 --- a/backend/src/resolvers.ts +++ b/backend/src/resolvers.ts @@ -1,11 +1,20 @@ +import { assertName } from 'graphql'; import { pool } from './db.js'; // type definitions type GamesArgs = { search?: string | null; + filter?: GameFilter | null; first: number; }; +type GameFilter = { + genreIds?: string[] | null; + platformIds?: string[] | null; + releasedFrom?: number | null; + releasedTo?: number | null; +} + // const vars and func const VALID_ID_REGEX = /^\d{1,18}$/; const MAX_PAGE_SIZE = 50; @@ -29,14 +38,52 @@ const GAME_COLS = ` COALESCE(screenshots, '{}') AS "screenshots" `; -// SQL queries -const GAMES_PATTERN = ` - WHERE ($1::text IS NULL - OR search_names ILIKE $1) +// SQL filtrering +const WHERE_SEARCH = ` + ($1::text IS NULL OR + search_names ILIKE $1) +`; +const WHERE_GENRES = ` + ($2::int[] IS NULL OR ( + SELECT count(*) FROM game_genres gg + WHERE gg.game_id = games.id + AND gg.genre_id = ANY($2)) = cardinality($2)) +`; +const WHERE_PLATFORM = ` + ($3::int[] IS NULL OR EXISTS ( + SELECT 1 FROM game_parent_platforms gp + WHERE gp.game_id = games.id + AND gp.parent_platform_id = ANY($3))) +`; +const WHERE_RELEASED_FROM = ` + ($4::int IS NULL OR + released >= make_date($4, 1, 1)) +`; +const WHERE_RELEASED_TO = ` + ($5::int IS NULL OR + released < make_date($5 + 1, 1, 1)) `; +const GAMES_WHERE = ` + WHERE ${WHERE_SEARCH} + AND ${WHERE_GENRES} + AND ${WHERE_PLATFORM} + AND ${WHERE_RELEASED_FROM} + AND ${WHERE_RELEASED_TO} +`; + + +// SQL queries const SQL_COUNT = ` SELECT count(*)::int AS count FROM games - ${GAMES_PATTERN} + ${GAMES_WHERE} +`; +const SQL_GENRES = ` + SELECT id, name FROM genres + ORDER BY name +`; +const SQL_PLATFORMS = ` + SELECT id, name FROM parent_platforms + ORDER BY name `; const SQL_GAME = ` SELECT ${GAME_COLS} FROM games @@ -44,12 +91,43 @@ const SQL_GAME = ` `; const SQL_GAMES = ` SELECT ${GAME_COLS} FROM games - ${GAMES_PATTERN} + ${GAMES_WHERE} ORDER BY added DESC, id DESC - LIMIT $2 + LIMIT $6 `; -// game resolvers +// hjelpefunksjoner +function toIds(ids?: string[] | null) { + const nums = (ids??[]).map(Number).filter(Number.isInteger); + return nums.length? nums : null; +} +// returnerer array med filter-verdiene +function filterValues(args: GamesArgs) { + const term = args.search?.trim(); + return [ + term? `%${escapeChars(term)}%` : null, + toIds(args.filter?.genreIds), + toIds(args.filter?.platformIds), + args.filter?.releasedFrom??null, + args.filter?.releasedTo??null, + ]; +} +// resolver for koblings-tabellene i databasen +function relation(table: string, junc: string, fk: string) { + const sql = ` + SELECT t.id, t.name + FROM ${junc} j + JOIN ${table} t ON t.id = j.${fk} + WHERE j.game_id = $1 + ORDER BY t.name + `; + return async (game: {id: string }) => { + const res = await pool.query(sql, [game.id]); + return res.rows; + }; +}; + +// resolver-funksjoner async function game(_parent: unknown, args: { id: string }) { if (!VALID_ID_REGEX.test(args.id)) { return null; } const res = await pool.query(SQL_GAME, [args.id]) @@ -57,45 +135,36 @@ async function game(_parent: unknown, args: { id: string }) { }; async function games(_parent: unknown, args: GamesArgs ) { - const term = args.search?.trim(); - const pattern = term?`%${escapeChars(term)}%`:null; + const values = filterValues(args); const first = Math.min(Math.max(args.first, 1), MAX_PAGE_SIZE); - const res = await pool.query(SQL_GAMES, [pattern, first]); + const res = await pool.query(SQL_GAMES, [...values, first]); return { edges: res.rows.map((row) => ({ node: row, cursor: '' })), pageInfo: { hasNextPage: false, endCursor: null}, - pattern, + values, }; } -async function totalCount(parent: { pattern: string | null }) { - return (await pool.query(SQL_COUNT, [parent.pattern])).rows[0].count; +async function totalCount(parent: { values: unknown[] }) { + return (await pool.query(SQL_COUNT, parent.values)).rows[0].count; } -// resolver for koblings-tabellene i databasen -function relation(table: string, junc: string, fk: string) { - const sql = ` - SELECT t.id, t.name - FROM ${junc} j - JOIN ${table} t ON t.id = j.${fk} - WHERE j.game_id = $1 - ORDER BY t.name - `; - return async (game: {id: string }) => { - const res = await pool.query(sql, [game.id]); - return res.rows; - }; -}; +async function genres() { + return (await pool.query(SQL_GENRES)).rows; +} + +async function platforms() { + return (await pool.query(SQL_PLATFORMS)).rows; +} // resolvers for å hente kjøre spørringer mot databasen export const resolvers = { - Query: { game, games }, + Query: { game, games, genres, platforms }, Game: { genres: relation('genres', 'game_genres', 'genre_id'), platforms: relation('platforms', 'game_platforms', 'platform_id'), - parentPlatforms: relation('parent_platforms', 'game_parent_platforms', 'parent_platform_id'), stores: relation('stores', 'game_stores', 'store_id'), developers: relation('developers', 'game_developers', 'developer_id'), publishers: relation('publishers', 'game_publishers', 'publisher_id'), diff --git a/backend/src/schema.ts b/backend/src/schema.ts index e8bb0864..fa8b1eb1 100644 --- a/backend/src/schema.ts +++ b/backend/src/schema.ts @@ -1,5 +1,16 @@ export const typeDefs = `#graphql + """ + Definerer input argumentene som er gyldige mot serveren. + Brukes for å filtrere visningen av spill. + """ + input GameFilter { + genreIds: [ID!] + platformIds: [ID!] + releasedFrom: Int + releasedTo: Int + } + """ Generell type for attributter som sjanger, platform, etc. """ @@ -27,7 +38,6 @@ export const typeDefs = `#graphql genres: [Facet!]! platforms: [Facet!]! - parentPlatforms: [Facet!]! stores: [Facet!]! developers: [Facet!]! publishers: [Facet!]! @@ -66,6 +76,8 @@ export const typeDefs = `#graphql """ type Query { game(id: ID!): Game - games(search: String, first: Int=20): GameConnection! + games(search: String, filter: GameFilter, first: Int=20): GameConnection! + genres: [Facet!]! + platforms: [Facet!]! } `; \ No newline at end of file From b404781862de330fec94fb39d400f67b9c30a097 Mon Sep 17 00:00:00 2001 From: albdert Date: Mon, 12 Oct 2026 16:18:32 +0200 Subject: [PATCH 25/25] backend api docs --- backend/backend-api.md | 293 +++++++++++++++++++++++++++++++++++++++++ db/.env.example | 4 - db/.gitignore | 1 - db/database-connect.md | 60 +++++++-- db/ssh-tunnel.md | 53 -------- 5 files changed, 339 insertions(+), 72 deletions(-) create mode 100644 backend/backend-api.md delete mode 100644 db/.env.example delete mode 100644 db/.gitignore delete mode 100644 db/ssh-tunnel.md diff --git a/backend/backend-api.md b/backend/backend-api.md new file mode 100644 index 00000000..edb1e946 --- /dev/null +++ b/backend/backend-api.md @@ -0,0 +1,293 @@ +# Backend-API +## Queries +### game + +Henter informasjon om ett enkelt spill ved ID. + +```graphql +game(id: ID!): Game +``` + +#### Argumenter + +| Navn | Type | Default | Beskrivelse | +| ---- | ----- | ------- | --------------- | +| `id` | `ID!` | — | ID til spillet. | + +#### Returnerer + +[`Game`](#game-1). Returnerer `null` hvis spillet ikke finnes eller id-en er ugyldig. + +#### Eksempel + +```graphql +{ + game(id: "3498") { + name + esrbRating + genres { name } + } +} +``` + +```json +{ + "data": { + "game": { + "name": "Grand Theft Auto V", + "esrbRating": "Mature", + "genres": [{ "name": "Action" }] + } + } +} +``` + +--- + +### games + +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! +``` + +#### 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. | + +Alle argumenter er valgfrie og kan kombineres. + +#### Returnerer + +[`GameConnection`](#gameconnection) med spillene på siden og totalt antall treff. + +#### Eksempel: søk + +```graphql +{ + games(search: "zelda", first: 3) { + totalCount + edges { node { name } } + } +} +``` + +```json +{ + "data": { + "games": { + "totalCount": 55, + "edges": [ + { "node": { "name": "The Legend of Zelda: Breath of the Wild" } }, + { "node": { "name": "The Legend of Zelda: Ocarina of Time" } }, + { "node": { "name": "The Legend of Zelda: Tears of the Kingdom" } } + ] + } + } +} +``` + +#### Eksempel: filtre + +Action- og RPG-spill på PC, utgitt 2015–2020: + +```graphql +{ + games( + filter: { genreIds: ["1", "14"], platformIds: ["9"], releasedFrom: 2015, releasedTo: 2020 } + first: 3 + ) { + totalCount + edges { node { name released } } + } +} +``` + +```json +{ + "data": { + "games": { + "totalCount": 2006, + "edges": [ + { "node": { "name": "The Witcher 3: Wild Hunt", "released": "2015-05-18" } }, + { "node": { "name": "Fallout 4", "released": "2015-11-09" } }, + { "node": { "name": "Cyberpunk 2077", "released": "2020-12-10" } } + ] + } + } +} +``` + +--- + +### genres + +Henter alle sjangre, sortert alfabetisk. + +```graphql +genres: [Facet!]! +``` + +#### Argumenter + +Ingen. + +#### Returnerer + +Liste med [`Facet`](#facet). + +#### Eksempel + +```graphql +{ + genres { id name } +} +``` + +```json +{ + "data": { + "genres": [ + { "id": "1", "name": "Action" }, + { "id": "2", "name": "Adventure" }, + { "id": "3", "name": "Arcade" } + ] + } +} +``` + +--- + +### platforms + +Henter alle plattformfamilier (PC, PlayStation, Xbox, …), sortert alfabetisk. + +```graphql +platforms: [Facet!]! +``` + +#### Argumenter + +Ingen. + +#### Returnerer + +Liste med [`Facet`](#facet). + +Merk: ID-ene her er plattformfamilier og er ikke de samme som ID-ene i `Game.platforms`, som er enkeltkonsoller. + +#### Eksempel + +```graphql +{ + platforms { id name } +} +``` + +```json +{ + "data": { + "platforms": [ + { "id": "9", "name": "PC" }, + { "id": "10", "name": "PlayStation" }, + { "id": "13", "name": "Xbox" } + ] + } +} +``` + +## Mutations +> Dette skal legges til når funksjonalitet for brukere blir lagt til + +## Types +### Game + +Et spill med detaljer og relasjoner. + +| Felt | Type | Beskrivelse | +| --------------------------- | ------------------- | ----------------------------------------------------------------- | +| `id` | `ID!` | ID til spillet. | +| `name` | `String!` | Tittel. | +| `description` | `String` | Beskrivelse i ren tekst. | +| `released` | `String` | Utgivelsesdato, `YYYY-MM-DD`. | +| `website` | `String` | Offisiell nettside. | +| `added` | `Int!` | Antall RAWG-brukere som har lagt spillet til i en liste. Brukes som mål på popularitet. | +| `metacritic` | `Int` | Metacritic-score, 0–100. | +| `esrbRating` | `String` | Amerikansk aldersgrense, f.eks. `Mature`. | +| `dominantColor` | `String` | Hovedfargen i forsidebildet som hex, f.eks. `0f0f0f`. | +| `backgroundImage` | `String` | URL til forsidebildet. | +| `backgroundImageAdditional` | `String` | URL til et ekstra bilde. | +| `screenshots` | `[String!]!` | URL-er til skjermbilder. Tom liste hvis ingen. | +| `genres` | [`[Facet!]!`](#facet) | Sjangre. | +| `platforms` | [`[Facet!]!`](#facet) | Enkeltkonsoller, f.eks. PlayStation 4. | +| `stores` | [`[Facet!]!`](#facet) | Butikker spillet selges i. | +| `developers` | [`[Facet!]!`](#facet) | Utviklere. | +| `publishers` | [`[Facet!]!`](#facet) | Utgivere. | +| `tags` | [`[Facet!]!`](#facet) | Tagger fra RAWG, f.eks. Open World. | + +Relasjonene returnerer en tom liste hvis spillet ikke har noen. + +--- + +### Facet + +Felles type for sjangre, plattformer, butikker, utviklere, utgivere og tagger. + +| Felt | Type | Beskrivelse | +| ------ | --------- | ----------- | +| `id` | `ID!` | ID. | +| `name` | `String!` | Navn. | + +--- + +### GameConnection + +Resultatet av [`games`](#games). Følger Relay-mønsteret for paginering (se [dokumentasjon](https://graphql.org/learn/pagination/)). + +| Felt | Type | Beskrivelse | +| ------------ | --------------------------- | --------------------------------------------------- | +| `edges` | [`[GameEdge!]!`](#gameedge) | Spillene på denne siden. | +| `pageInfo` | [`PageInfo!`](#pageinfo) | Informasjon for å hente neste side. | +| `totalCount` | `Int!` | Totalt antall spill som matcher søk og filtre. | + +--- + +### GameEdge + +Ett spill i en [`GameConnection`](#gameconnection). + +| Felt | Type | Beskrivelse | +| -------- | -------------------- | ------------------------------------ | +| `node` | [`Game!`](#game-1) | Spillet. | +| `cursor` | `String!` | Posisjonen til spillet i listen. | + +--- + +### PageInfo + +| Felt | Type | Beskrivelse | +| ------------- | ---------- | ------------------------------------------------------------- | +| `hasNextPage` | `Boolean!` | `true` hvis det finnes flere spill etter denne siden. | +| `endCursor` | `String` | Cursor til siste spill på siden. Sendes som `after` for neste side. | + +--- + +### GameFilter + +Input-type for filtrering i [`games`](#games). Alle felt er valgfrie. + +| Felt | Type | Beskrivelse | +| -------------- | -------- | ------------------------------------------------------------------------------ | +| `genreIds` | `[ID!]` | Spill som har **alle** de valgte sjangrene. ID-er fra [`genres`](#genres). | +| `platformIds` | `[ID!]` | Spill på **minst én** av de valgte plattformfamiliene. ID-er fra [`platforms`](#platforms). | +| `releasedFrom` | `Int` | Utgitt dette året eller senere, f.eks. `2015`. | +| `releasedTo` | `Int` | Utgitt dette året eller tidligere, f.eks. `2020`. | + +Merk: Spill uten utgivelsesdato utelates når `releasedFrom` eller `releasedTo` er satt. ID-er som ikke er tall ignoreres. + + diff --git a/db/.env.example b/db/.env.example deleted file mode 100644 index 2f4cadc4..00000000 --- a/db/.env.example +++ /dev/null @@ -1,4 +0,0 @@ -VM_USER=brukernavn -VM_HOST=it2810-02.idi.ntnu.no -TUNNEL_PORT=5433 -DATABASE_URL=postgresql://gameapp:PASSORD@localhost:5433/gamesdb diff --git a/db/.gitignore b/db/.gitignore deleted file mode 100644 index 4bed5da9..00000000 --- a/db/.gitignore +++ /dev/null @@ -1 +0,0 @@ -*.parquet diff --git a/db/database-connect.md b/db/database-connect.md index c701b6c4..22b178e7 100644 --- a/db/database-connect.md +++ b/db/database-connect.md @@ -1,23 +1,55 @@ +# Database-connect +## 1. Konfigurere `.env` +1. Koble til NTNU VPN hvis du er utenfor campus. +2. Kopier `backend/.env.example` til `backend/.env` +3. Fyll inn verdien til `VM_USER`, og `PASSORD` i `DATABASE_URL`. -#### Database-connect - -Scripts for å koble seg til database ligger i `/scripts`. +`backend/.env` skal se ut slik: +``` +# tunnel script env vars +VM_USER= +VM_HOST=it2810-02.idi.ntnu.no +TUNNEL_PORT=5433 + +# backend server env vars +DATABASE_URL=postgresql://webapp:@localhost:5433/gamesdb +PORT=3001 +JWT_TOKEN= +``` -###### db-tunnel.sh +## 2. Database-tilkobling under utvikling +#### Linux/Mac +Gå til rot i repo (dvs. `T02-Project-2` mappen) og kjør kommandoen: +```bash +pnpm db:tunnel +``` -Spesifikt inneholder denne `db-tunnel.sh` som oppretter en ssh tunnel til vm over porten `5432`. Dette gjør at all trafikk som normalt går til `localhost:5432` rutes til postgres på serveren, slik at vi slipper å laste ned hele databasen på hver av pc-ene våre. +La terminalen stå åpen, og start backend i en annen terminal med `pnpm --filter backend dev`. -`db-tunnel.sh` scriptet er lagt inn som et script i `/package.json`, og kan kjøres med `pnpm db:tunnel`. +#### Windows +Start tunnelen i PowerShell: +```powershell +ssh -N -L 5433:localhost:5432 brukernavn@it2810-02.idi.ntnu.no +``` +Alternativt kan scriptet kjøres direkte i Git Bash eller WSL med ./scripts/db-tunnel.sh. Portsjekken i scriptet bruker lsof, som ikke finnes i Git Bash, men den hoppes da bare over. -###### test-select.sh +#### Verifiser -Det andre scriptet er `test-select.sh`, dette er et enkelt script som kjører en spørring mot en database på `localhost:5432`. Hvis tunnellen fungerer skal dette returnere dette: +Med tunnelen åpen, fra rot i repoet: +```bash +pnpm --filter backend exec tsx --env-file=.env -e " +import pg from 'pg'; +const c = new pg.Client(process.env.DATABASE_URL); +await c.connect(); +console.log((await c.query('SELECT count(*) FROM games')).rows); +await c.end(); +" ``` - count --------- - 120067 -(1 row) -``` -For å kunne kjøre dette scriptet trenger man postgres-client. Dette kan lastes ned med `sudo apt update && sudo apt install postgresql-client` på debian/ubuntu, og med `brew install libpq` på mac. +Skal skrive ut `[ { count: '120067' } ]`. + +#### Mulige feil +1. Connection refused på 5433 betyr at tunnelen ikke kjører. +2. Connection timed out mot VM-en betyr som regel at VPN mangler. +3. Password authentication failed betyr feil passord eller rolle i DATABASE_URL. diff --git a/db/ssh-tunnel.md b/db/ssh-tunnel.md deleted file mode 100644 index c0f0de44..00000000 --- a/db/ssh-tunnel.md +++ /dev/null @@ -1,53 +0,0 @@ -## Felles -#### Konfigurere `.env` -1. Koble til NTNU VPN hvis du er utenfor campus. -2. Kopier `backend/.env.example` til `backend/.env` -3. Fyll inn verdien til `VM_USER`, og `PASSORD` i `DATABASE_URL`. - -`backend/.env` skal da se ut slik: -``` -VM_USER= -VM_HOST=it2810-02.idi.ntnu.no -DATABASE_URL=postgresql://webapp:@localhost:5433/gamesdb -PORT=3001 -TUNNEL_PORT=5433 -``` - -#### Start tunnelen -Gå til rot i repo (dvs. `T02-Project-2` mappen) og kjør kommandoen: -```bash -pnpm db:tunnel -``` - -La terminalen stå åpen, og start backend i en annen terminal med `pnpm --filter backend dev`. - -## Windows - -OpenSSH følger med Windows 10 og 11. Bash-scriptet fungerer ikke via pnpm db:tunnel, fordi pnpm kjører scripts gjennom cmd. - -#### Start tunnelen i PowerShell: -```powershell -ssh -N -L 5433:localhost:5432 brukernavn@it2810-02.idi.ntnu.no -``` -Alternativt kan scriptet kjøres direkte i Git Bash eller WSL med ./scripts/db-tunnel.sh. Portsjekken i scriptet bruker lsof, som ikke finnes i Git Bash, men den hoppes da bare over. - -## Verifiser - -Med tunnelen åpen, fra rot i repoet: - -```bash -pnpm --filter backend exec tsx --env-file=.env -e " -import pg from 'pg'; -const c = new pg.Client(process.env.DATABASE_URL); -await c.connect(); -console.log((await c.query('SELECT count(*Kopier backend/.env.example til backend/.env og fyll inn VM_USER, VM_HOST, TUNNEL_PORT og DATABASE_URL (med webapp-rollen og port 5433).) FROM games')).rows); -await c.end(); -" -``` - -Skal skrive ut `[ { count: '120067' } ]`. - -#### Mulige feil -1. Connection refused på 5433 betyr at tunnelen ikke kjører. -2. Connection timed out mot VM-en betyr som regel at VPN mangler. -3. Password authentication failed betyr feil passord eller rolle i DATABASE_URL.