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 c5121671..17b9e076 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", + "dev": "tsx watch --env-file=.env 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/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 5a97d4ff..ba22d187 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,2 +1,21 @@ -// placeholder, erstattes av apollo server senere -console.log('backend'); +import { ApolloServer } from "@apollo/server"; +import { startStandaloneServer } from "@apollo/server/standalone"; +import { pool } from './db.js'; +import { typeDefs } from './schema.js'; +import { resolvers } from './resolvers.js'; + +// 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 }); + +// pass apolloserver to standaloneserver +const { url } = await startStandaloneServer(server, { + 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..3df91903 --- /dev/null +++ b/backend/src/resolvers.ts @@ -0,0 +1,106 @@ +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 +// 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 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 $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 +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; + }; +}; + +// resolvers for å hente kjøre spørringer mot databasen +export const resolvers = { + Query: { game, games }, + + 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 } +}; diff --git a/backend/src/schema.ts b/backend/src/schema.ts new file mode 100644 index 00000000..e8bb0864 --- /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(search: String, first: Int=20): GameConnection! + } +`; \ No newline at end of file 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/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. 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`. diff --git a/db/grants.sql b/db/grants.sql new file mode 100644 index 00000000..1689bfdc --- /dev/null +++ b/db/grants.sql @@ -0,0 +1,9 @@ +-- Rettigheter for backend rollen webapp. Kjør etter hver import! +-- Rollen skal opprettes èn gang i postgres før scriptet kjøres: +-- CREATE ROLE webapp WITH LOGIN PASSWORD ''; +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; diff --git a/db/indexes.sql b/db/indexes.sql index 52263486..929a7042 100644 --- a/db/indexes.sql +++ b/db/indexes.sql @@ -1,19 +1,17 @@ +CREATE INDEX games_search_trgm ON games USING gin (search_names gin_trgm_ops); -CREATE INDEX games_name_trgm ON games USING gin (name gin_trgm_ops); - CREATE INDEX games_added_id ON games (added DESC, id DESC); CREATE INDEX games_rating_id ON games (rating DESC NULLS LAST, id DESC); CREATE INDEX games_released_id ON games (released DESC NULLS LAST, id DESC); CREATE INDEX games_meta_id ON games (metacritic DESC NULLS LAST, id DESC); CREATE INDEX games_name_id ON games (name, 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); -CREATE INDEX game_stores_rev ON game_stores (store_id, game_id); -CREATE INDEX game_developers_rev ON game_developers (developer_id, game_id); -CREATE INDEX game_publishers_rev ON game_publishers (publisher_id, game_id); -CREATE INDEX game_tags_rev ON game_tags (tag_id, game_id); - -CREATE INDEX reviews_game_created ON reviews (game_id, created_at DESC); - +CREATE INDEX game_genres_rev ON game_genres (genre_id, game_id); +CREATE INDEX game_platforms_rev ON game_platforms (platform_id, game_id); +CREATE INDEX game_parent_platforms_rev ON game_parent_platforms (parent_platform_id, game_id); +CREATE INDEX game_stores_rev ON game_stores (store_id, game_id); +CREATE INDEX game_developers_rev ON game_developers (developer_id, game_id); +CREATE INDEX game_publishers_rev ON game_publishers (publisher_id, game_id); +CREATE INDEX game_tags_rev ON game_tags (tag_id, game_id); + +CREATE INDEX reviews_game_created ON reviews (game_id, created_at DESC); diff --git a/db/load.py b/db/load.py index 4ee04537..3e6f8253 100644 --- a/db/load.py +++ b/db/load.py @@ -15,66 +15,95 @@ RELATIONS = [ ("genres", "genres", "game_genres", "genre_id"), ("platforms", "platforms", "game_platforms", "platform_id"), + ("parent_platforms", "parent_platforms", "game_parent_platforms", "parent_platform_id"), ("stores", "stores", "game_stores", "store_id"), ("developers", "developers", "game_developers", "developer_id"), ("publishers", "publishers", "game_publishers", "publisher_id"), ("tags", "tags", "game_tags", "tag_id"), ] -REQUIRED_COLUMNS = [ - "id", "slug", "name", "released", "background_image", "rating", "ratings_count", - "added", "metacritic", "playtime", "esrb_rating", "description_raw", "website", - "dominant_color", "genres", "platforms", "stores", "developers", "publishers", "tags", -] - GAME_COLUMNS = ( - "id, slug, name, released, background_image, rating, ratings_count, added, " - "metacritic, playtime, esrb_rating, description, website, dominant_color" + "id, slug, name, name_original, alternative_names, search_names, released, " + "background_image, background_image_additional, screenshots, rating, ratings_count, " + "added, metacritic, playtime, esrb_rating, description, website, dominant_color" ) +def clean(col: str) -> str: + """Empty strings and the text 'None' (a Python null written as a string) become NULL.""" + return f"NULLIF(NULLIF(trim({col}), ''), 'None')" -def export_csvs(out: Path) -> None: - con = duckdb.connect() - found = {r[0] for r in con.execute(f"DESCRIBE SELECT * FROM read_parquet('{PARQUET}')").fetchall()} - missing = [c for c in REQUIRED_COLUMNS if c not in found] - if missing: - sys.exit(f"Parquet file is missing expected columns: {missing}\nAvailable: {sorted(found)}") +# Pipe-separated names, trimmed, empty entries removed. +ALT_NAMES = ( + f"list_filter(list_transform(string_split({clean('alternative_names')}, '|'), s -> trim(s)), " + "s -> s NOT IN ('', 'None'))" +) + +# short_screenshots holds Python-style dicts ({'id': 123, 'image': '...'}), not JSON. +# \d+ skips the id -1 entry, which duplicates background_image. +SCREENSHOTS = ( + "regexp_extract_all(" + clean("short_screenshots") + + r""", '''id'': (\d+), ''image'': ''([^'']+)''', 2)""" +) + +# esrb_rating may be JSON, a Python-style dict, a plain name or 'None'. +ESRB = ( + f"CASE WHEN {clean('esrb_rating')} IS NULL THEN NULL " + f"WHEN trim(esrb_rating) LIKE '{{%' THEN " + + r"""NULLIF(regexp_extract(esrb_rating, '[''"]name[''"]\s*:\s*[''"]([^''"]+)', 1), '') """ + + f"ELSE {clean('esrb_rating')} END" +) + + +def pg_array(list_expr: str) -> str: + """DuckDB list -> Postgres array literal ({"a","b"}), NULL when empty.""" + quoted = ( + "list_transform(" + list_expr + r""", x -> '"' || replace(replace(x, '\', '\\'), '"', '\"') || '"')""" + ) + return ( + f"CASE WHEN len({list_expr}) > 0 " + f"THEN '{{' || array_to_string({quoted}, ',') || '}}' END" + ) + - # The dataset stores missing values as the text 'None'; convert to real NULLs. +def export_csvs(out: Path) -> None: + con = duckdb.connect() + con.execute( + f"""CREATE VIEW src AS + SELECT * FROM read_parquet('{PARQUET}') + WHERE added > 0 AND name IS NOT NULL""" + ) con.execute( - f"""CREATE TEMP TABLE src AS + f"""COPY ( SELECT id, - NULLIF(slug, 'None') AS slug, + slug, name, + NULLIF({clean('name_original')}, name) AS name_original, + {pg_array(ALT_NAMES)} AS alternative_names, + concat_ws(' | ', name, NULLIF({clean('name_original')}, name), + array_to_string({ALT_NAMES}, ' | ')) AS search_names, TRY_CAST(released AS DATE) AS released, - NULLIF(background_image, 'None') AS background_image, + {clean('background_image')} AS background_image, + {clean('background_image_additional')} AS background_image_additional, + {pg_array(SCREENSHOTS)} AS screenshots, NULLIF(rating, 0) AS rating, COALESCE(TRY_CAST(ratings_count AS INTEGER), 0) AS ratings_count, TRY_CAST(added AS INTEGER) AS added, TRY_CAST(metacritic AS INTEGER) AS metacritic, TRY_CAST(playtime AS INTEGER) AS playtime, - NULLIF(regexp_extract(esrb_rating, '"name":[ ]*"([^"]*)"', 1), '') AS esrb_rating, - NULLIF(description_raw, 'None') AS description, - NULLIF(website, 'None') AS website, - NULLIF(dominant_color, 'None') AS dominant_color, - genres, platforms, stores, developers, publishers, tags - FROM read_parquet('{PARQUET}') - WHERE added > 0 AND name IS NOT NULL""" - ) - - con.execute( - f"COPY (SELECT {GAME_COLUMNS} FROM src) TO '{out}/games.csv' (FORMAT csv, HEADER)" + {ESRB} AS esrb_rating, + {clean('description_raw')} AS description, + {clean('website')} AS website, + {clean('dominant_color')} AS dominant_color + FROM src + ) TO '{out}/games.csv' (FORMAT csv, HEADER)""" ) - for col, table, junction, fk in RELATIONS: con.execute( f"""CREATE OR REPLACE TEMP TABLE pairs AS - SELECT game_id, trim(item) AS name FROM ( - SELECT id AS game_id, unnest(string_split({col}, '|')) AS item - FROM src WHERE {col} IS NOT NULL - ) t""" + SELECT id AS game_id, trim(unnest(string_split({col}, '|'))) AS name + FROM src WHERE {col} IS NOT NULL""" ) con.execute( """CREATE OR REPLACE TEMP TABLE lk AS diff --git a/db/schema.sql b/db/schema.sql index 43affb0f..43d074c4 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1,31 +1,37 @@ DROP TABLE IF EXISTS reviews CASCADE; -DROP TABLE IF EXISTS game_genres, game_platforms, game_stores, game_developers, game_publishers, game_tags CASCADE; -DROP TABLE IF EXISTS genres, platforms, stores, developers, publishers, tags CASCADE; +DROP TABLE IF EXISTS game_genres, game_platforms, game_parent_platforms, game_stores, game_developers, game_publishers, game_tags CASCADE; +DROP TABLE IF EXISTS genres, platforms, parent_platforms, stores, developers, publishers, tags CASCADE; DROP TABLE IF EXISTS games CASCADE; CREATE TABLE games ( - id BIGINT PRIMARY KEY, - slug TEXT, - name TEXT NOT NULL, - released DATE, - background_image TEXT, - rating REAL, - ratings_count INTEGER NOT NULL DEFAULT 0, - added INTEGER NOT NULL DEFAULT 0, - metacritic INTEGER, - playtime INTEGER, - esrb_rating TEXT, - description TEXT, - website TEXT, - dominant_color TEXT + id BIGINT PRIMARY KEY, + slug TEXT, + name TEXT NOT NULL, + name_original TEXT, + alternative_names TEXT[], + search_names TEXT NOT NULL, + released DATE, + background_image TEXT, + background_image_additional TEXT, + screenshots TEXT[], + rating REAL, + ratings_count INTEGER NOT NULL DEFAULT 0, + added INTEGER NOT NULL DEFAULT 0, + metacritic INTEGER, + playtime INTEGER, + esrb_rating TEXT, + description TEXT, + website TEXT, + dominant_color TEXT ); -CREATE TABLE genres (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE); -CREATE TABLE platforms (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE); -CREATE TABLE stores (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE); -CREATE TABLE developers (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE); -CREATE TABLE publishers (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE); -CREATE TABLE tags (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE); +CREATE TABLE genres (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE); +CREATE TABLE platforms (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE); +CREATE TABLE parent_platforms (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE); +CREATE TABLE stores (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE); +CREATE TABLE developers (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE); +CREATE TABLE publishers (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE); +CREATE TABLE tags (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE); CREATE TABLE game_genres ( game_id BIGINT NOT NULL REFERENCES games(id) ON DELETE CASCADE, @@ -37,6 +43,11 @@ CREATE TABLE game_platforms ( platform_id INTEGER NOT NULL REFERENCES platforms(id) ON DELETE CASCADE, PRIMARY KEY (game_id, platform_id) ); +CREATE TABLE game_parent_platforms ( + game_id BIGINT NOT NULL REFERENCES games(id) ON DELETE CASCADE, + parent_platform_id INTEGER NOT NULL REFERENCES parent_platforms(id) ON DELETE CASCADE, + PRIMARY KEY (game_id, parent_platform_id) +); CREATE TABLE game_stores ( game_id BIGINT NOT NULL REFERENCES games(id) ON DELETE CASCADE, store_id INTEGER NOT NULL REFERENCES stores(id) ON DELETE CASCADE, 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. 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 9ec34bc7..f657f66c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,101 +1,60 @@ import { useState } from 'react'; -import heroImg from './assets/hero.png'; -import reactLogo from './assets/react.svg'; -import viteLogo from './assets/vite.svg'; -import './App.css'; +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. + * Favoritter og valgt visning ligger bare i minnet her og skal senere inn i state management. + */ function App() { - const [count, setCount] = useState(0); + const [favorites, setFavorites] = useState>(new Set()); + const [view, setView] = useState<'list' | 'grid'>('list'); - return ( - <> -
-
- - React logo - Vite logo -
-
-

Get started

-

- Edit src/App.tsx and save to test HMR -

-
- -
+ // 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); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; -
-
- -

Documentation

-

Your questions, answered

- -
-
- -

Connect with us

-

Join the Vite community

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

Søkeresultater

+ + {/* Bytter mellom visning 1 (liste) og visning 2 (rutenett) */} +
+ + +
-
-
- + +
); } 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/GameCard/GameCard.css b/frontend/src/components/GameCard/GameCard.css new file mode 100644 index 00000000..781fc6f8 --- /dev/null +++ b/frontend/src/components/GameCard/GameCard.css @@ -0,0 +1,171 @@ +/* Én rad i resultatlista. Ingen boks, bare en skillelinje under. */ +.game-card { + position: relative; + display: grid; + grid-template-columns: 150px 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 */ +} + +.game-card__favorite { + position: absolute; /* plasseres i forhold til .game-card, som har position: relative */ + top: 12px; /* samme som padding-toppen på raden */ + right: 0; + z-index: 1; /* over den strukne lenken */ + min-height: 32px; + padding: 0 10px; + font: inherit; + font-size: 14px; + color: var(--text-h); + background: none; + border: 1px solid var(--text-h); + border-radius: 0; + cursor: pointer; +} + +/* Plass til knappen, så lange titler ikke havner under den */ +.game-card__body { + padding-right: 110px; +} + +.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; + } +} + +/* ===== Visning 2: oppsummert rute (4 per rad) ===== */ +.game-card--grid { + grid-template-columns: 1fr; /* bildet over teksten */ + grid-template-rows: auto 1fr; /* teksten fyller resten av ruta */ + height: 100%; /* like høye ruter i samme rad */ + box-sizing: border-box; + gap: 10px; + padding: 0 0 14px; +} + +.game-card--grid .game-card__body { + gap: 2px; + padding-right: 0; /* knappen ligger ikke ved tittelen her */ +} + +/* Maks to linjer tittel, så rutene blir like høye */ +.game-card--grid .game-card__title { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + font-size: 16px; +} + +.game-card--grid .game-card__meta, +.game-card--grid .game-card__scores { + font-size: 14px; +} + +.game-card--grid .game-card__scores { + margin: auto 0 0; /* skyver rating ned til bunnen av ruta */ +} + +/* Favoritt nederst til høyre, på linje med rating */ +.game-card--grid .game-card__favorite { + top: auto; + bottom: 8px; +} + +/* Smale ruter (mobil): knappen under rating i stedet for ved siden av */ +@media (max-width: 640px) { + .game-card--grid .game-card__favorite { + position: relative; + bottom: auto; + margin-top: 6px; + } +} diff --git a/frontend/src/components/GameCard/GameCard.tsx b/frontend/src/components/GameCard/GameCard.tsx new file mode 100644 index 00000000..690148d2 --- /dev/null +++ b/frontend/src/components/GameCard/GameCard.tsx @@ -0,0 +1,70 @@ +import type { GameSummary } from '../../types/game'; +import './GameCard.css'; + +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, view = 'list' }: GameCardProps) { + const year = game.released?.slice(0, 4) ?? null; + 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. + + ) : ( +
+ ); +} diff --git a/frontend/src/components/GameList/GameList.css b/frontend/src/components/GameList/GameList.css new file mode 100644 index 00000000..7c4e83ed --- /dev/null +++ b/frontend/src/components/GameList/GameList.css @@ -0,0 +1,32 @@ +.game-list { + margin: 0; + padding: 0; + list-style: none; +} + +/* Visning 2: fire ruter per rad. minmax(0, 1fr) hindrer at lange titler sprenger kolonnene. */ +.game-list--grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 28px 24px; + padding-top: 20px; +} + +/* Lar kortet fylle hele høyden i ruta, så bunnlinjene står likt */ +.game-list--grid > li { + display: flex; + flex-direction: column; +} + +@media (max-width: 900px) { + .game-list--grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@media (max-width: 640px) { + .game-list--grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 24px 16px; + } +} diff --git a/frontend/src/components/GameList/GameList.tsx b/frontend/src/components/GameList/GameList.tsx new file mode 100644 index 00000000..2cc8e6ff --- /dev/null +++ b/frontend/src/components/GameList/GameList.tsx @@ -0,0 +1,31 @@ +import type { GameSummary } from '../../types/game'; +import { GameCard } from '../GameCard/GameCard'; +import './GameList.css'; + +interface GameListProps { + games: GameSummary[]; + favorites: Set; + onToggleFavorite: (id: string) => void; + view: 'list' | 'grid'; +} + +/** + * Resultatlista. Samme data og samme kort i begge visninger, + * bare layouten endres.
    /
  • gjør at skjermlesere sier hvor mange spill som vises. + */ +export function GameList({ games, favorites, onToggleFavorite, view }: GameListProps) { + return ( +
      + {games.map((game) => ( +
    • + +
    • + ))} +
    + ); +} 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 + + ); +} 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; +} 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 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 diff --git a/frontend/src/mocks/games.ts b/frontend/src/mocks/games.ts new file mode 100644 index 00000000..ae05b1ef --- /dev/null +++ b/frontend/src/mocks/games.ts @@ -0,0 +1,62 @@ +import type { GameSummary } from '../types/game'; + +/** + * Midlertidige testdata så vi kan se spillkortene før GraphQL-API-et er koblet på. + * Slettes når listen henter ekte data. Tallene er omtrentlige. + */ +export const mockGames: GameSummary[] = [ + { + id: '1', + name: 'The Witcher 3: Wild Hunt', + released: '2015-05-18', + backgroundImage: null, + rating: 4.66, + metacritic: 92, + genres: ['Action', 'RPG'], + }, + { + id: '2', + name: 'Red Dead Redemption 2', + released: '2018-10-26', + backgroundImage: null, + rating: 4.59, + metacritic: 96, + genres: ['Action', 'Adventure'], + }, + { + id: '3', + name: 'Portal 2', + released: '2011-04-18', + backgroundImage: null, + rating: 4.62, + metacritic: 95, + genres: ['Shooter', 'Puzzle'], + }, + { + id: '4', + name: 'Hades', + released: '2020-09-17', + backgroundImage: null, + rating: 4.5, + metacritic: 93, + genres: ['Action', 'Indie'], + }, + { + id: '5', + name: 'Stardew Valley', + released: '2016-02-26', + backgroundImage: null, + rating: 4.4, + metacritic: 89, + genres: ['Simulation', 'RPG'], + }, + { + id: '6', + name: 'Et spill uten data', + released: null, + backgroundImage: null, + rating: null, + metacritic: null, + genres: [], + }, +]; 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[]; +} 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;"