diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bfcc93e5..862af233 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,37 +5,96 @@ on: branches: [main, dev] push: branches: [main, dev] + workflow_dispatch: +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: false jobs: - lint-and-test: + check-and-deploy: runs-on: self-hosted - strategy: - matrix: - workspace: [frontend, backend] - defaults: - run: - working-directory: ${{ matrix.workspace }} + env: + DEPLOY_DIR: /var/www/project2 + SERVICE: project2-backend.service steps: - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24.21.0 + - uses: pnpm/action-setup@v4 with: version: 11.25.0 - - uses: actions/setup-node@v4 + - name: Get pnpm store path + id: pnpm-store + run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + + - uses: actions/cache@v4 with: - node-version: 24.21.0 - cache: pnpm - cache-dependency-path: ${{ matrix.workspace }}/pnpm-lock.yaml + path: ${{ steps.pnpm-store.outputs.path }} + key: pnpm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: pnpm-${{ runner.os }}- - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Lint - run: pnpm run lint + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Format + run: pnpm format:check + + - name: Lint frontend + if: ${{ !cancelled() }} + run: pnpm --filter frontend lint + + - name: Lint backend + if: ${{ !cancelled() }} + run: pnpm --filter backend lint + + - name: Build frontend + if: ${{ !cancelled() }} + run: pnpm --filter frontend build + + - name: Build backend + if: ${{ !cancelled() }} + run: pnpm --filter backend build + + - name: Test frontend + if: ${{ !cancelled() }} + run: pnpm --filter frontend --if-present run test + + - name: Test backend + if: ${{ !cancelled() }} + run: pnpm --filter backend --if-present run test + + - name: Deploy frontend + if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' + run: rsync -a --delete frontend/dist/ "$DEPLOY_DIR/frontend/" - - name: Typecheck - run: pnpm run --if-present typecheck + - name: Deploy backend + if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' + run: | + rm -rf "$DEPLOY_DIR/backend.new" "$DEPLOY_DIR/backend.old" + pnpm --filter backend deploy --prod "$DEPLOY_DIR/backend.new" + if [ -d "$DEPLOY_DIR/backend" ]; then mv "$DEPLOY_DIR/backend" "$DEPLOY_DIR/backend.old"; fi + mv "$DEPLOY_DIR/backend.new" "$DEPLOY_DIR/backend" + sudo -n /usr/bin/systemctl restart "$SERVICE" + rm -rf "$DEPLOY_DIR/backend.old" - - name: Test - run: pnpm run --if-present test --run + - name: Check backend + if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' + run: | + for i in $(seq 1 10); do + if curl -sf -X POST -H 'Content-Type: application/json' \ + -d '{"query":"{ genres { id } }"}' http://localhost:3001/ > /dev/null; then + echo "Backend is up" + exit 0 + fi + sleep 2 + done + echo "Backend did not respond" + systemctl status "$SERVICE" --no-pager || true + exit 1 diff --git a/.github/workflows/ci.yml.bak b/.github/workflows/ci.yml.bak new file mode 100644 index 00000000..69a97a45 --- /dev/null +++ b/.github/workflows/ci.yml.bak @@ -0,0 +1,50 @@ +name: CI + +on: + pull_request: + branches: [main, dev] + push: + branches: [main, dev] + +jobs: + lint-and-test: + runs-on: self-hosted + strategy: + fail-fast: false + matrix: + workspace: [frontend, backend] + defaults: + run: + working-directory: ${{ matrix.workspace }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 24.21.0 + + - uses: pnpm/action-setup@v4 + with: + version: 11.25.0 + + - name: Get pnpm store path + id: pnpm-store + run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + + - uses: actions/cache@v4 + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: pnpm-${{ runner.os }}-${{ matrix.workspace }}-${{ hashFiles(format('{0}/pnpm-lock.yaml', matrix.workspace)) }} + restore-keys: pnpm-${{ runner.os }}-${{ matrix.workspace }}- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Lint + run: pnpm run lint + + - name: Typecheck + run: pnpm run --if-present typecheck + + - name: Test + run: pnpm run --if-present test --run diff --git a/backend/.env.example b/backend/.env.example index 68799a7d..453b8c8a 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -6,4 +6,4 @@ TUNNEL_PORT=5433 # backend server env vars DATABASE_URL=postgresql://webapp:@localhost:5433/gamesdb PORT=3001 -JWT_TOKEN= \ No newline at end of file +#JWT_TOKEN= diff --git a/backend/backend-api.md b/backend/backend-api.md index 200bef0f..7dc50ea0 100644 --- a/backend/backend-api.md +++ b/backend/backend-api.md @@ -1,5 +1,7 @@ # Backend-API + ## Queries + ### game Henter informasjon om ett enkelt spill ved ID. @@ -25,7 +27,9 @@ game(id: ID!): Game game(id: "3498") { name esrbRating - genres { name } + genres { + name + } } } ``` @@ -54,11 +58,11 @@ games(search: String, filter: GameFilter, first: Int = 20): GameConnection! #### Argumenter -| Navn | Type | Default | Beskrivelse | -| -------- | --------------------------- | ------- | --------------------------------------------------------------------------------------------- | +| Navn | Type | Default | Beskrivelse | +| -------- | --------------------------- | ------- | --------------------------------------------------------------------------------------------------------- | | `search` | `String` | — | Søk i tittel og alternative navn. Skiller ikke mellom store og små bokstaver, 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. | +| `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. @@ -72,7 +76,11 @@ Alle argumenter er valgfrie og kan kombineres. { games(search: "zelda", first: 3) { totalCount - edges { node { name } } + edges { + node { + name + } + } } } ``` @@ -103,7 +111,12 @@ Action- og RPG-spill på PC, utgitt 2015–2020: first: 3 ) { totalCount - edges { node { name released } } + edges { + node { + name + released + } + } } } ``` @@ -145,7 +158,10 @@ Liste med [`Facet`](#facet). ```graphql { - genres { id name } + genres { + id + name + } } ``` @@ -185,7 +201,10 @@ Merk: ID-ene her er plattformfamilier og er ikke de samme som ID-ene i `Game.pla ```graphql { - platforms { id name } + platforms { + id + name + } } ``` @@ -202,36 +221,38 @@ Merk: ID-ene her er plattformfamilier og er ikke de samme som ID-ene i `Game.pla ``` ## 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. | -| `rating` | `Float` | RAWG-brukernes snittvurdering, 0–5. | -| `ratingsCount` | `Int!` | Antall vurderinger bak `rating`. | -| `playtime` | `Int` | Gjennomsnittlig spilletid i timer. | -| `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. | +| 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. | +| `rating` | `Float` | RAWG-brukernes snittvurdering, 0–5. | +| `ratingsCount` | `Int!` | Antall vurderinger bak `rating`. | +| `playtime` | `Int` | Gjennomsnittlig spilletid i timer. | +| `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. @@ -252,11 +273,11 @@ Felles type for sjangre, plattformer, butikker, utviklere, utgivere og tagger. 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. | +| 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. | --- @@ -264,18 +285,18 @@ Resultatet av [`games`](#games). Følger Relay-mønsteret for paginering (se [do Ett spill i en [`GameConnection`](#gameconnection). -| Felt | Type | Beskrivelse | -| -------- | -------------------- | ------------------------------------ | -| `node` | [`Game!`](#game-1) | Spillet. | -| `cursor` | `String!` | Posisjonen til spillet i listen. | +| 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. | +| 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. | --- @@ -284,13 +305,11 @@ Ett spill i en [`GameConnection`](#gameconnection). 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`. | +| 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/backend/package.json b/backend/package.json index 17b9e076..ee7697e9 100644 --- a/backend/package.json +++ b/backend/package.json @@ -11,6 +11,9 @@ "start": "pnpm run build && node dist/index.js", "lint": "eslint ." }, + "files": [ + "dist" + ], "keywords": [], "author": "", "license": "ISC", diff --git a/backend/src/auth.ts b/backend/src/auth.ts index 7816c105..3e717471 100644 --- a/backend/src/auth.ts +++ b/backend/src/auth.ts @@ -1,3 +1,2 @@ - // server jwt secret -// const secret = process.env.JWT_SECRET; \ No newline at end of file +// const secret = process.env.JWT_SECRET; diff --git a/backend/src/db.ts b/backend/src/db.ts index ead91fad..4b81d4df 100644 --- a/backend/src/db.ts +++ b/backend/src/db.ts @@ -1,7 +1,7 @@ import pg from 'pg'; const url = process.env.DATABASE_URL; -if (!url) throw new Error("DATABASE_URL not set in .env ...") +if (!url) throw new Error('DATABASE_URL not set in .env ...'); //pg pool -export const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL}) +export const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL }); diff --git a/backend/src/index.ts b/backend/src/index.ts index ba22d187..514ffc78 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,11 +1,11 @@ -import { ApolloServer } from "@apollo/server"; -import { startStandaloneServer } from "@apollo/server/standalone"; +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) +// 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'); @@ -15,7 +15,7 @@ const server = new ApolloServer({ typeDefs, resolvers }); // pass apolloserver to standaloneserver const { url } = await startStandaloneServer(server, { - listen: { port }, + listen: { port }, }); -console.log(`Server ready at: ${url}`); \ No newline at end of file +console.log(`Server ready at: ${url}`); diff --git a/backend/src/resolvers.ts b/backend/src/resolvers.ts index 5537c4fc..757dc296 100644 --- a/backend/src/resolvers.ts +++ b/backend/src/resolvers.ts @@ -1,4 +1,3 @@ -import { assertName } from 'graphql'; import { pool } from './db.js'; // type definitions @@ -13,12 +12,12 @@ type GameFilter = { 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; -const escapeChars = (s: string) => s.replace(/[\\%_]/g, '\\$&') +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 @@ -74,7 +73,6 @@ const GAMES_WHERE = ` AND ${WHERE_RELEASED_TO} `; - // SQL queries const SQL_COUNT = ` SELECT count(*)::int AS count FROM games @@ -101,18 +99,18 @@ const SQL_GAMES = ` // hjelpefunksjoner function toIds(ids?: string[] | null) { - const nums = (ids??[]).map(Number).filter(Number.isInteger); - return nums.length? nums : 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, + term ? `%${escapeChars(term)}%` : null, toIds(args.filter?.genreIds), toIds(args.filter?.platformIds), - args.filter?.releasedFrom??null, - args.filter?.releasedTo??null, + args.filter?.releasedFrom ?? null, + args.filter?.releasedTo ?? null, ]; } // resolver for koblings-tabellene i databasen @@ -124,27 +122,29 @@ function relation(table: string, junc: string, fk: string) { WHERE j.game_id = $1 ORDER BY t.name `; - return async (game: {id: string }) => { + 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]) + 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 ) { +async function games(_parent: unknown, args: GamesArgs) { const values = filterValues(args); - const first = Math.min(Math.max(args.first, 1), MAX_PAGE_SIZE); + const first = Math.min(Math.max(args.first, 1), MAX_PAGE_SIZE); const res = await pool.query(SQL_GAMES, [...values, first]); return { edges: res.rows.map((row) => ({ node: row, cursor: '' })), - pageInfo: { hasNextPage: false, endCursor: null}, + pageInfo: { hasNextPage: false, endCursor: null }, values, }; } @@ -174,5 +174,5 @@ export const resolvers = { tags: relation('tags', 'game_tags', 'tag_id'), }, - GameConnection: { totalCount } + GameConnection: { totalCount }, }; diff --git a/backend/src/schema.ts b/backend/src/schema.ts index 5e1b1c01..9d2621e1 100644 --- a/backend/src/schema.ts +++ b/backend/src/schema.ts @@ -1,4 +1,3 @@ - export const typeDefs = `#graphql """ Definerer input argumentene som er gyldige mot serveren. @@ -83,4 +82,4 @@ export const typeDefs = `#graphql genres: [Facet!]! platforms: [Facet!]! } -`; \ No newline at end of file +`; diff --git a/db/database-connect.md b/db/database-connect.md index 22b178e7..627b09dc 100644 --- a/db/database-connect.md +++ b/db/database-connect.md @@ -1,10 +1,13 @@ # 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`. `backend/.env` skal se ut slik: + ``` # tunnel script env vars VM_USER= @@ -17,9 +20,12 @@ PORT=3001 JWT_TOKEN= ``` -## 2. Database-tilkobling under utvikling +## 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 ``` @@ -27,10 +33,13 @@ pnpm db:tunnel La terminalen stå åpen, og start backend i en annen terminal med `pnpm --filter backend dev`. #### 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. #### Verifiser @@ -50,6 +59,7 @@ 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/db/database-layout.md b/db/database-layout.md index 58c91f4d..bd9345ed 100644 --- a/db/database-layout.md +++ b/db/database-layout.md @@ -1,4 +1,3 @@ - #### Database-layout Under er utskrift fra postgres lokalt på vm. Eksemplene viser henholdsvis alle tabellene i databasen vår `gamesdb`, og alle kolonnene som tabellen `games` inneholder. @@ -44,4 +43,3 @@ gamesdb-> \d games dominant_color | text | | | ``` - diff --git a/db/database-setup.md b/db/database-setup.md index b786ff44..2e3f26b5 100644 --- a/db/database-setup.md +++ b/db/database-setup.md @@ -60,11 +60,11 @@ 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. -| Fil | Innhold | -| --- | --- | -| `schema.sql` | Tabeller: `games`, oppslagstabeller, koblingstabeller, `reviews` | +| Fil | Innhold | +| ------------- | --------------------------------------------------------------------------------------- | +| `schema.sql` | Tabeller: `games`, oppslagstabeller, koblingstabeller, `reviews` | | `indexes.sql` | Trigram-indeks for søk, keyset-indekser for sortering, omvendte indekser for filtrering | -| `load.py` | Import fra Parquet til Postgres | +| `load.py` | Import fra Parquet til Postgres | ## 6. Verifiser diff --git a/db/database-usage.md b/db/database-usage.md index 142ddfa9..7795a683 100644 --- a/db/database-usage.md +++ b/db/database-usage.md @@ -1,13 +1,15 @@ ## Roller for databasen i postgres -|Rolle|Bruk|Rettigheter| -|-|-|-| -|postgres|Administrasjon|Superuser| -|gameapp|Import og schemaendringer|Eier av gamesdb| -|webapp|Backend|SELECT på alt, INSERT på reviews| +| 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 @@ -15,11 +17,13 @@ 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 ``` @@ -46,6 +50,7 @@ Under er noen generelle kommandoer som kan brukes i postgres for navigere databa ``` Se på data i tabellen (spørringer): + ```SQL // viser første 5 spillene i games-tabellen SELECT * FROM games LIMIT 5; @@ -60,6 +65,7 @@ SELECT metacritic FROM games WHERE id = 4291; 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' @@ -67,10 +73,11 @@ 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. +> [!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 diff --git a/frontend/index.html b/frontend/index.html index 78357eeb..63465482 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,10 +1,23 @@ - + Damp + +
diff --git a/frontend/src/App.css b/frontend/src/App.css index d6c7c9a0..2bf280f5 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,182 +1,30 @@ -.counter { - font-size: 16px; - padding: 5px 10px; - border-radius: 5px; - color: var(--accent); - background: var(--accent-bg); - border: 2px solid transparent; - transition: border-color 0.3s; - margin-bottom: 24px; - - &:hover { - border-color: var(--accent-border); - } - &:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; - } -} - -.hero { - position: relative; - - .base, - .framework, - .vite { - inset-inline: 0; - margin: 0 auto; - } - - .base { - width: 170px; - position: relative; - z-index: 0; - } - - .framework, - .vite { - position: absolute; - } - - .framework { - z-index: 1; - top: 34px; - height: 28px; - transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) scale(1.4); - } - - .vite { - z-index: 0; - top: 107px; - height: 26px; - width: auto; - transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) scale(0.8); - } -} - -#center { - display: flex; - flex-direction: column; - gap: 25px; - place-content: center; - place-items: center; - flex-grow: 1; - - @media (max-width: 1024px) { - padding: 32px 20px 24px; - gap: 18px; - } -} - -#next-steps { - display: flex; - border-top: 1px solid var(--border); +/* Hovedinnholdet under toppfeltet */ +.main { + padding: 24px; text-align: left; - - & > div { - flex: 1 1 0; - padding: 32px; - @media (max-width: 1024px) { - padding: 24px 20px; - } - } - - .icon { - margin-bottom: 16px; - width: 22px; - height: 22px; - } - - @media (max-width: 1024px) { - flex-direction: column; - text-align: center; - } -} - -#docs { - border-right: 1px solid var(--border); - - @media (max-width: 1024px) { - border-right: none; - border-bottom: 1px solid var(--border); - } } -#next-steps ul { - list-style: none; - padding: 0; +/* Linjen over resultatene: overskrift til venstre, valg av visning til høyre */ +.results-bar { display: flex; - gap: 8px; - margin: 32px 0 0; - - .logo { - height: 18px; - } - - a { - color: var(--text-h); - font-size: 16px; - border-radius: 6px; - background: var(--social-bg); - display: flex; - padding: 6px 12px; - align-items: center; - gap: 8px; - text-decoration: none; - transition: box-shadow 0.3s; - - &:hover { - box-shadow: var(--shadow); - } - .button-icon { - height: 18px; - width: 18px; - } - } - - @media (max-width: 1024px) { - margin-top: 20px; - flex-wrap: wrap; - justify-content: center; - - li { - flex: 1 1 calc(50% - 8px); - } - - a { - width: 100%; - justify-content: center; - box-sizing: border-box; - } - } -} - -#spacer { - height: 88px; - border-top: 1px solid var(--border); - @media (max-width: 1024px) { - height: 48px; - } + align-items: baseline; + justify-content: space-between; + padding-bottom: 8px; + border-bottom: 1px solid var(--border); } -.ticks { - position: relative; - width: 100%; - - &::before, - &::after { - content: ''; - position: absolute; - top: -4.5px; - border: 5px solid transparent; - } - - &::before { - left: 0; - border-left-color: var(--border); - } - &::after { - right: 0; - border-right-color: var(--border); - } +.results-bar__views { + display: flex; + gap: 16px; + font-family: var(--mono); + font-size: 14px; +} + +/* Antall treff under overskriften, i samme monospace som menyen */ +.results-count { + min-height: 1.5em; /* holder plassen, så lista ikke hopper når teksten kommer */ + margin: 8px 0 0; + font-family: var(--mono); + font-size: 13px; + color: var(--text); } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f657f66c..af1d58ba 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,27 +1,52 @@ import { useState } from 'react'; +import { useQuery } from '@apollo/client/react'; 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'; +import { Header } from './components/Header/Header'; +import { GAMES } from './graphql/games'; +import type { Suggestion } from './graphql/searchSuggestions'; +import type { GameSummary, GameView } from './types/game'; +import './App.css'; /** - * Midlertidig forside: viser spillene med mockdata til GraphQL er klart. + * Forsiden: søk, resultatliste fra databasen og favoritter. * Favoritter og valgt visning ligger bare i minnet her og skal senere inn i state management. */ function App() { const [favorites, setFavorites] = useState>(new Set()); - const [view, setView] = useState<'list' | 'grid'>('list'); + const [view, setView] = useState('list'); + const [showFavorites, setShowFavorites] = useState(false); - // 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); + // Søket i hovedlista. Settes først når brukeren trykker Enter, ikke for hver bokstav + // (forslagene i søkefeltet tar seg av det), så lista og pagineringen ikke hopper + const [query, setQuery] = useState(''); - // 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()), - ); + // MIDLERTIDIG: det finnes ingen detaljside ennå, så valgt spill vises i lista. + // Den som lager detaljsiden bytter denne linjen til navigering. + const handleSelectGame = (game: Suggestion) => setQuery(game.name); + + // Henter første side fra databasen. Kjøres på nytt bare når `query` endres (ved Enter). + // Tomt søk (null) gir de mest populære spillene. + const { data, previousData, loading, error } = useQuery(GAMES, { + variables: { search: query || null, first: 20 }, + }); + const result = data ?? previousData; // forrige liste vises mens den nye lastes + + // Backend gir sjangre som objekter ({ name }), spillkortet forventer bare navnene + const allGames: GameSummary[] = + result?.games.edges.map(({ node }) => ({ + ...node, + genres: node.genres.map((g) => g.name), + })) ?? []; + + // MIDLERTIDIG: favoritter filtreres blant spillene som er lastet inn. + // Når favoritter lagres i databasen, bør de hentes derfra i stedet. + const games = showFavorites ? allGames.filter((g) => favorites.has(g.id)) : allGames; + + // Teksten over lista. role="status" gjør at skjermlesere leser den opp når den endres + let status = ''; + if (loading && !result) status = 'Laster …'; + else if (result && !showFavorites) + status = `${result.games.totalCount.toLocaleString('nb-NO')} treff`; const toggleFavorite = (id: string) => { setFavorites((prev) => { @@ -33,28 +58,67 @@ function App() { }; return ( -
- {/* NY: logoen øverst til høyre */} - -

Søkeresultater

- - {/* Bytter mellom visning 1 (liste) og visning 2 (rutenett) */} -
- - -
- - +
setShowFavorites((v) => !v)} /> -
+ +
+
+

{showFavorites ? 'Favoritter' : 'Søkeresultater'}

+ +
+ + + + +
+
+ +

+ {status} +

+ {error &&

Kunne ikke hente spill: {error.message}

} + + +
+ ); } diff --git a/frontend/src/apollo.ts b/frontend/src/apollo.ts new file mode 100644 index 00000000..a4532adb --- /dev/null +++ b/frontend/src/apollo.ts @@ -0,0 +1,11 @@ +import { ApolloClient, HttpLink, InMemoryCache } from '@apollo/client'; + +/** + * Felles GraphQL-klient for hele appen. Svar caches i minnet, så samme søk + * to ganger gir bare én forespørsel. URL-en kan overstyres med VITE_GRAPHQL_URL ved deploy. + */ + +export const client = new ApolloClient({ + link: new HttpLink({ uri: import.meta.env.VITE_GRAPHQL_URL ?? 'http://localhost:3001' }), + cache: new InMemoryCache(), +}); diff --git a/frontend/src/assets/drawings/tegning.webp b/frontend/src/assets/drawings/tegning.webp new file mode 100644 index 00000000..1ac44aa3 Binary files /dev/null and b/frontend/src/assets/drawings/tegning.webp differ diff --git a/frontend/src/components/GameCard/GameCard.css b/frontend/src/components/GameCard/GameCard.css index 781fc6f8..b0b4965b 100644 --- a/frontend/src/components/GameCard/GameCard.css +++ b/frontend/src/components/GameCard/GameCard.css @@ -169,3 +169,59 @@ margin-top: 6px; } } + +/* ===== Visning 3: utstillingsrad (1 per rad, stort bilde til venstre) ===== */ +.game-card--showcase { + grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr); + gap: 28px; + padding: 28px 0; +} + +.game-card--showcase .game-card__body { + gap: 6px; + padding-right: 0; /* knappen ligger nederst her, ikke ved tittelen */ +} + +.game-card--showcase .game-card__title { + font-size: 26px; +} + +/* Store tall med små etiketter over, som i trykk */ +.game-card--showcase .game-card__scores { + gap: 32px; + margin: 16px 0; +} + +.game-card--showcase .game-card__scores div { + flex-direction: column; + gap: 2px; +} + +.game-card--showcase .game-card__scores dt { + font-family: var(--mono); + font-size: 12px; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.game-card--showcase .game-card__scores dd { + font-size: 28px; + line-height: 1.1; +} + +/* Favoritt-knappen nederst i tekstkolonnen i stedet for oppe i hjørnet */ +.game-card--showcase .game-card__favorite { + position: relative; + top: auto; + right: auto; + align-self: flex-start; + margin-top: auto; +} + +/* Smal skjerm: bildet over teksten */ +@media (max-width: 640px) { + .game-card--showcase { + grid-template-columns: 1fr; + gap: 14px; + } +} diff --git a/frontend/src/components/GameCard/GameCard.tsx b/frontend/src/components/GameCard/GameCard.tsx index 690148d2..06877430 100644 --- a/frontend/src/components/GameCard/GameCard.tsx +++ b/frontend/src/components/GameCard/GameCard.tsx @@ -1,4 +1,4 @@ -import type { GameSummary } from '../../types/game'; +import type { GameSummary, GameView } from '../../types/game'; import './GameCard.css'; interface GameCardProps { @@ -6,16 +6,16 @@ interface GameCardProps { isFavorite: boolean; onToggleFavorite: (id: string) => void; /** 'list' = visning 1 (rad), 'grid' = visning 2 (oppsummert rute) */ - view?: 'list' | 'grid'; + view?: GameView; } //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(' · '); + // Rutenett: 1 sjanger, liste: 2, utstillingsrad: alle (undefined = ingen grense) + const maxGenres = view === 'grid' ? 1 : view === 'list' ? 2 : undefined; + const meta = [year, game.genres.slice(0, maxGenres).join(', ')].filter(Boolean).join(' · '); return (
@@ -47,7 +47,7 @@ export function GameCard({ game, isFavorite, onToggleFavorite, view = 'list' }:
Rating
{game.rating != null ? `${game.rating.toFixed(1)} / 5` : 'Ingen'}
- {view === 'list' && ( + {view !== 'grid' && (
Metacritic
{game.metacritic ?? 'Ingen'}
diff --git a/frontend/src/components/GameList/GameList.tsx b/frontend/src/components/GameList/GameList.tsx index 2cc8e6ff..ef6b9449 100644 --- a/frontend/src/components/GameList/GameList.tsx +++ b/frontend/src/components/GameList/GameList.tsx @@ -1,4 +1,4 @@ -import type { GameSummary } from '../../types/game'; +import type { GameSummary, GameView } from '../../types/game'; import { GameCard } from '../GameCard/GameCard'; import './GameList.css'; @@ -6,7 +6,7 @@ interface GameListProps { games: GameSummary[]; favorites: Set; onToggleFavorite: (id: string) => void; - view: 'list' | 'grid'; + view: GameView; } /** diff --git a/frontend/src/components/GameSearch/GameSearch.css b/frontend/src/components/GameSearch/GameSearch.css new file mode 100644 index 00000000..f66f023d --- /dev/null +++ b/frontend/src/components/GameSearch/GameSearch.css @@ -0,0 +1,87 @@ +.game-search { + position: relative; /* forslagslista plasseres i forhold til skjemaet */ +} + +/* Skjuler labelen visuelt, men beholder den for skjermlesere */ +.game-search__label { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; +} + +/* Bare en linje under, som resten av toppfeltet */ +.game-search__input { + width: 100%; + padding: 0.4rem 0; + font: inherit; + color: var(--text-h); + background: transparent; + border: 0; + border-bottom: 1px solid var(--text); + border-radius: 0; + -webkit-appearance: textfield; + appearance: textfield; +} + +.game-search__input::placeholder { + color: var(--text); +} + +.game-search__input:focus-visible { + outline: none; + border-bottom-color: var(--text-h); + box-shadow: 0 1px 0 var(--text-h); +} + +/* Forslagene legger seg over innholdet under, med tynn linje rundt i stedet for skygge */ +.game-search__popup { + position: absolute; + z-index: 20; + top: calc(100% + 2px); + left: 0; + right: 0; + text-align: left; + background: var(--bg); + border: 1px solid var(--border); + border-top: 0; +} + +.game-search__list { + margin: 0; + padding: 4px 0; + list-style: none; +} + +.game-search__option { + display: flex; + justify-content: space-between; + gap: 16px; + padding: 6px 12px; + color: var(--text-h); + cursor: pointer; +} + +/* Markert forslag (pil eller mus): strek under som de andre knappene, og svak bakgrunn */ +.game-search__option[aria-selected='true'] { + background: var(--code-bg); + text-decoration: underline; + text-underline-offset: 4px; +} + +.game-search__year, +.game-search__status { + font-family: var(--mono); + font-size: 14px; + color: var(--text); +} + +.game-search__status { + margin: 0; + padding: 8px 12px; +} diff --git a/frontend/src/components/GameSearch/GameSearch.tsx b/frontend/src/components/GameSearch/GameSearch.tsx new file mode 100644 index 00000000..d753ebf9 --- /dev/null +++ b/frontend/src/components/GameSearch/GameSearch.tsx @@ -0,0 +1,138 @@ +import { useId, useState, type KeyboardEvent } from 'react'; +import { useQuery } from '@apollo/client/react'; +import { useDebounce } from '../../hooks/useDebounce'; +import { SEARCH_SUGGESTIONS, type Suggestion } from '../../graphql/searchSuggestions'; +import './GameSearch.css'; + +// Kortere tekst enn dette gir for mange tilfeldige treff, så vi spør ikke serveren +const MIN_CHARS = 2; + +interface GameSearchProps { + /** Enter uten valgt forslag: søk i hovedlista */ + onSearch: (term: string) => void; + /** Brukeren valgte ett spill i forslagslista */ + onSelectGame: (game: Suggestion) => void; +} + +/** + * Søkefelt med forslag (combobox). Mens man skriver hentes bare noen få treff fra + * serveren og vises under feltet. Hovedlista endres ikke før man trykker Enter, så + * den ikke hopper for hver bokstav og pagineringen ikke nullstilles hele tiden. + * + * Tastatur: pil opp/ned flytter i lista, Enter velger, Escape lukker. + * Følger WAI-ARIA-mønsteret "combobox med listbox". + */ + +export function GameSearch({ onSearch, onSelectGame }: GameSearchProps) { + const [input, setInput] = useState(''); + const [open, setOpen] = useState(false); + const [active, setActive] = useState(-1); // markert forslag, -1 = ingen + const id = useId(); + const listId = `${id}-list`; + + // Spør først 300 ms etter siste tastetrykk, og ikke ved for kort tekst + const term = useDebounce(input.trim(), 300); + const { data, previousData, loading } = useQuery(SEARCH_SUGGESTIONS, { + variables: { search: term }, + skip: term.length < MIN_CHARS, + }); + + // Viser forrige svar mens det nye lastes, så lista ikke blinker + const suggestions = (data ?? previousData)?.games.edges.map((e) => e.node) ?? []; + const showPopup = open && term.length >= MIN_CHARS; + + function select(game: Suggestion) { + setOpen(false); + setActive(-1); + onSelectGame(game); + } + + function handleKeyDown(e: KeyboardEvent) { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setOpen(true); + setActive((i) => Math.min(i + 1, suggestions.length - 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setActive((i) => Math.max(i - 1, -1)); + } else if (e.key === 'Escape') { + setOpen(false); + setActive(-1); + } else if (e.key === 'Enter' && active >= 0 && suggestions[active]) { + e.preventDefault(); // velger forslaget i stedet for å sende skjemaet + select(suggestions[active]); + } + } + + return ( +
{ + e.preventDefault(); + setOpen(false); + onSearch(input.trim()); + }} + > + {/* Skjult visuelt, men lest opp av skjermlesere. Placeholder alene er ikke nok */} + + 0} + aria-controls={listId} + aria-autocomplete="list" + aria-activedescendant={active >= 0 ? `${id}-option-${active}` : undefined} + placeholder="Søk etter spill …" + autoComplete="off" + value={input} + onChange={(e) => { + setInput(e.target.value); + setOpen(true); + setActive(-1); + }} + onKeyDown={handleKeyDown} + onFocus={() => setOpen(true)} + onBlur={() => setOpen(false)} + /> + + {showPopup && ( +
+ {suggestions.length > 0 ? ( +
    + {suggestions.map((game, i) => ( +
  • { + e.preventDefault(); + select(game); + }} + onMouseEnter={() => setActive(i)} + > + {game.name} + {game.released && ( + {game.released.slice(0, 4)} + )} +
  • + ))} +
+ ) : ( + // role="status" gjør at skjermlesere leser opp meldingen +

+ {loading ? 'Søker …' : 'Ingen treff'} +

+ )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/Header/Header.css b/frontend/src/components/Header/Header.css new file mode 100644 index 00000000..b686e9e7 --- /dev/null +++ b/frontend/src/components/Header/Header.css @@ -0,0 +1,33 @@ +.header { + display: flex; + align-items: center; + gap: 24px; + padding: 16px 24px; + border-bottom: 1px solid var(--border); +} + +.header__search { + flex: 1; +} + +/* Menyen til høyre, i monospace som på clemenbe.no */ +.header__nav { + display: flex; + gap: 20px; + margin-left: auto; + font-family: var(--mono); + font-size: 15px; + white-space: nowrap; +} + +/* Mobil: logo og meny på første linje, søket på hele andre linje */ +@media (max-width: 600px) { + .header { + flex-wrap: wrap; + gap: 12px 20px; + } + .header__search { + order: 3; + flex-basis: 100%; + } +} diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx new file mode 100644 index 00000000..ad0f70bb --- /dev/null +++ b/frontend/src/components/Header/Header.tsx @@ -0,0 +1,45 @@ +import { Logo } from '../Logo/Logo'; +import { GameSearch } from '../GameSearch/GameSearch'; +import type { Suggestion } from '../../graphql/searchSuggestions'; +import { ThemeToggle } from '../ThemeToggle/ThemeToggle'; +import './Header.css'; + +interface HeaderProps { + onSearch: (term: string) => void; + onSelectGame: (game: Suggestion) => void; + favoritesCount: number; + showFavorites: boolean; + onToggleFavorites: () => void; +} + +/** + * Toppfeltet: logo, søk og meny (favoritter og tema), med søket øverst som på RAWG. + * Ingen bokser, bare tekst og en tynn linje under. + */ +export function Header({ + onSearch, + onSelectGame, + favoritesCount, + showFavorites, + onToggleFavorites, +}: HeaderProps) { + return ( +
+ +
+ +
+ +
+ ); +} diff --git a/frontend/src/components/Logo/Logo.css b/frontend/src/components/Logo/Logo.css index a91c291b..008f1125 100644 --- a/frontend/src/components/Logo/Logo.css +++ b/frontend/src/components/Logo/Logo.css @@ -1,37 +1,38 @@ -/* Smale skjermer: øverst til venstre, scroller vekk så den ikke dekker innholdet */ +/* Smale skjermer: første element i toppfeltet (Header) */ .logo { - position: absolute; - top: 20px; - left: 20px; - z-index: 10; + flex-shrink: 0; } .logo img { display: block; - height: 88px; + height: 44px; width: auto; } -@media (max-width: 600px) { - .logo img { - height: 56px; /* Mindre logo på mobil */ - } +/* Viser riktig logo for valgt tema. display: none skjuler den andre også for skjermlesere */ +.logo .logo__img--dark { + display: none; +} +:root[data-theme='dark'] .logo .logo__img--light { + display: none; +} +:root[data-theme='dark'] .logo .logo__img--dark { + display: block; } /* Brede skjermer: fast og midt i sidepanelet til venstre. Må stå sist i filen */ @media (min-width: 1320px) { .logo { position: fixed; + z-index: 10; top: 20px; - /* Midten av sidepanelet. 1126px er bredden på #root i index.css; endres den, må den endres her også */ + /* Midten av sidepanelet. 1126px er bredden på #root i index.css */ 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 index 24eb3dcd..baa3d283 100644 --- a/frontend/src/components/Logo/Logo.tsx +++ b/frontend/src/components/Logo/Logo.tsx @@ -2,12 +2,12 @@ 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. */ +/** Damp-logoen (symbol med navnet under). CSS viser riktig versjon for valgt tema. */ export function Logo() { return ( - - - Damp - +
+ Damp + Damp +
); } diff --git a/frontend/src/components/SearchBar/SearchBar.css b/frontend/src/components/SearchBar/SearchBar.css index 949cd4b3..231755bf 100644 --- a/frontend/src/components/SearchBar/SearchBar.css +++ b/frontend/src/components/SearchBar/SearchBar.css @@ -11,12 +11,27 @@ border: 0; } +/* Bare en linje under, som et skjemafelt på papir */ .search-bar__input { width: 100%; - padding: 0.6rem 0.8rem; + padding: 0.4rem 0; font: inherit; - border: 1px solid #767676; - border-radius: 0; + color: var(--text-h); + background: transparent; + border: 0; + border-bottom: 1px solid var(--text); + border-radius: 0; -webkit-appearance: textfield; appearance: textfield; } + +.search-bar__input::placeholder { + color: var(--text); +} + +/* Fokus: linjen blir mørkere og dobbelt så tykk, uten at noe flytter seg */ +.search-bar__input:focus-visible { + outline: none; + border-bottom-color: var(--text-h); + box-shadow: 0 1px 0 var(--text-h); +} diff --git a/frontend/src/components/SearchBar/SearchBar.tsx b/frontend/src/components/SearchBar/SearchBar.tsx index e7f504e6..fa9fd6a5 100644 --- a/frontend/src/components/SearchBar/SearchBar.tsx +++ b/frontend/src/components/SearchBar/SearchBar.tsx @@ -27,4 +27,4 @@ export function SearchBar({ value, onChange }: SearchBarProps) { /> ); -} \ No newline at end of file +} diff --git a/frontend/src/components/ThemeToggle/ThemeToggle.tsx b/frontend/src/components/ThemeToggle/ThemeToggle.tsx new file mode 100644 index 00000000..b26ba3ad --- /dev/null +++ b/frontend/src/components/ThemeToggle/ThemeToggle.tsx @@ -0,0 +1,32 @@ +import { useEffect, useState } from 'react'; + +type Theme = 'light' | 'dark'; + +/** + * Knapp som bytter mellom lyst og mørkt tema. Første gang brukes temaet scriptet + * i index.html har satt (systemets innstilling), deretter huskes valget i localStorage. + */ +export function ThemeToggle() { + const [theme, setTheme] = useState(() => + document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light', + ); + + // Oppdaterer (som CSS-en leser) og husker valget + useEffect(() => { + document.documentElement.dataset.theme = theme; + try { + localStorage.setItem('theme', theme); + } catch { + // localStorage kan være blokkert (f.eks. privat modus), temaet virker likevel + } + }, [theme]); + + const next = theme === 'dark' ? 'light' : 'dark'; + + return ( + // Teksten sier hva knappen gjør, så skjermlesere og seende får samme informasjon + + ); +} diff --git a/frontend/src/graphql/games.ts b/frontend/src/graphql/games.ts new file mode 100644 index 00000000..6cc96ffb --- /dev/null +++ b/frontend/src/graphql/games.ts @@ -0,0 +1,49 @@ +import { gql, type TypedDocumentNode } from '@apollo/client'; + +/** Ett spill slik backend sender det til resultatlista */ +interface GameNode { + id: string; + name: string; + released: string | null; + backgroundImage: string | null; + rating: number | null; + metacritic: number | null; + genres: { name: string }[]; +} + +interface GamesData { + games: { + totalCount: number; + edges: { node: GameNode }[]; + }; +} + +interface GamesVars { + search: string | null; + first: number; +} + +/** + * Henter én side med spill til hovedlista. Uten søk gir backend de mest populære. + * Bare feltene spillkortet viser hentes, så svaret holdes lite. + */ +export const GAMES: TypedDocumentNode = gql` + query Games($search: String, $first: Int) { + games(search: $search, first: $first) { + totalCount + edges { + node { + id + name + released + backgroundImage + rating + metacritic + genres { + name + } + } + } + } + } +`; diff --git a/frontend/src/graphql/searchSuggestions.ts b/frontend/src/graphql/searchSuggestions.ts new file mode 100644 index 00000000..bd52ffb6 --- /dev/null +++ b/frontend/src/graphql/searchSuggestions.ts @@ -0,0 +1,34 @@ +import { gql, type TypedDocumentNode } from '@apollo/client'; + +/** Ett forslag i søkefeltet. Bare det dropdownen viser, så svaret blir lite. */ +export interface Suggestion { + id: string; + name: string; + released: string | null; +} + +interface SuggestionsData { + games: { edges: { node: Suggestion }[] }; +} + +interface SuggestionsVars { + search: string; +} + +/** + * Henter noen få spill som matcher teksten. totalCount spørres ikke om, + * så backend slipper å telle alle treff for hver forespørsel. + */ +export const SEARCH_SUGGESTIONS: TypedDocumentNode = gql` + query SearchSuggestions($search: String!) { + games(search: $search, first: 6) { + edges { + node { + id + name + released + } + } + } + } +`; diff --git a/frontend/src/hooks/useDebounce.ts b/frontend/src/hooks/useDebounce.ts index de407838..57c0cc8c 100644 --- a/frontend/src/hooks/useDebounce.ts +++ b/frontend/src/hooks/useDebounce.ts @@ -9,4 +9,4 @@ export function useDebounce(value: T, delay = 300): T { }, [value, delay]); return debounced; -} \ No newline at end of file +} diff --git a/frontend/src/index.css b/frontend/src/index.css index f12902e2..26d5f072 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,14 +1,15 @@ :root { - --text: #6b6375; - --text-h: #08060d; - --bg: #fff; - --border: #e5e4e7; - --code-bg: #f4f3ec; - --accent: #aa3bff; - --accent-bg: rgba(170, 59, 255, 0.1); - --accent-border: rgba(170, 59, 255, 0.5); - --social-bg: rgba(244, 243, 236, 0.5); - --shadow: rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; + /* Lyst tema: varmt papirhvitt */ + --text: #5c554c; + --text-h: #1f1b16; + --bg: #f8f5ee; + --border: #e3ddd1; + --code-bg: #efeae0; + --accent: #2c6370; /* fra Damp-logoen */ + --accent-bg: rgba(44, 99, 112, 0.1); + --accent-border: rgba(44, 99, 112, 0.5); + --social-bg: rgba(239, 234, 224, 0.5); + --shadow: rgba(31, 27, 22, 0.1) 0 10px 15px -3px, rgba(31, 27, 22, 0.05) 0 4px 6px -2px; --sans: system-ui, 'Segoe UI', Roboto, sans-serif; --heading: system-ui, 'Segoe UI', Roboto, sans-serif; @@ -16,7 +17,7 @@ font: 18px/145% var(--sans); letter-spacing: 0.18px; - color-scheme: light dark; + color-scheme: light; color: var(--text); background: var(--bg); font-synthesis: none; @@ -29,23 +30,20 @@ } } -@media (prefers-color-scheme: dark) { - :root { - --text: #9ca3af; - --text-h: #f3f4f6; - --bg: #16171d; - --border: #2e303a; - --code-bg: #1f2028; - --accent: #c084fc; - --accent-bg: rgba(192, 132, 252, 0.15); - --accent-border: rgba(192, 132, 252, 0.5); - --social-bg: rgba(47, 48, 58, 0.5); - --shadow: rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px; - } - - #social .button-icon { - filter: invert(1) brightness(2); - } +/* Mørkt tema. data-theme settes på av scriptet i index.html og av ThemeToggle */ +:root[data-theme='dark'] { + color-scheme: dark; + /* Nøytral mørk med et lite grønt stikk */ + --text: #a9aeab; + --text-h: #e8ebe9; + --bg: #191b1a; + --border: #2f3331; + --code-bg: #232625; + --accent: #6fa3ae; /* fra Damp-logoen (mørk versjon) */ + --accent-bg: rgba(111, 163, 174, 0.15); + --accent-border: rgba(111, 163, 174, 0.5); + --social-bg: rgba(35, 38, 37, 0.5); + --shadow: rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px; } #root { @@ -107,3 +105,67 @@ code { padding: 4px 8px; background: var(--code-bg); } + +/* + * Tegningen i sidefeltet til venstre, under logoen + * Bare på brede skjermer der det er plass ved siden av innholdet. Som CSS-bakgrunn + * i en media query lastes bildet bare ned når det faktisk vises (bærekraft). + * 1126px er bredden på #root, samme tall som i Logo.css. + */ +@media (min-width: 1320px) { + body::before { + content: ''; + position: fixed; + z-index: -1; + top: 50%; + left: calc((100vw - 1126px) / 4); + width: min(280px, calc((100vw - 1126px) / 2 - 48px)); + aspect-ratio: 800 / 698; + transform: translate(-50%, -30%); + background: url('./assets/drawings/tegning.webp') center / contain no-repeat; + /* Det hvite papiret i skanningen smelter inn i papirbakgrunnen */ + mix-blend-mode: multiply; + opacity: 0.85; + pointer-events: none; + } +} + +/* Mørkt tema: inverterer tegningen så blyanten blir lys og papiret blir borte */ +@media (min-width: 1320px) { + :root[data-theme='dark'] body::before { + filter: invert(1); + mix-blend-mode: screen; + opacity: 0.6; + } +} + +/* Tekstknapp uten boks (meny, tema, visning). Aktiv = mørkere tekst + strek under */ +.text-button { + padding: 0; + font: inherit; + color: var(--text); + background: none; + border: 0; + cursor: pointer; + text-underline-offset: 4px; +} +.text-button:hover, +.text-button[aria-pressed='true'] { + color: var(--text-h); + text-decoration: underline; +} +.text-button:focus-visible { + outline: 2px solid var(--text-h); + outline-offset: 4px; +} + +/* Seksjonsoverskrift i trykk-stil: små, sperrede versaler (som WRITING på clemenbe.no) */ +.label { + margin: 0; + font-family: var(--mono); + font-size: 13px; + font-weight: 500; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--text-h); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 2239905c..d97a9d9c 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,10 +1,14 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; +import { ApolloProvider } from '@apollo/client/react'; +import { client } from './apollo'; import './index.css'; import App from './App.tsx'; createRoot(document.getElementById('root')!).render( - + + + , ); diff --git a/frontend/src/types/game.ts b/frontend/src/types/game.ts index 0829d119..62107b8d 100644 --- a/frontend/src/types/game.ts +++ b/frontend/src/types/game.ts @@ -11,3 +11,6 @@ export interface GameSummary { metacritic: number | null; genres: string[]; } + +/** 'list' = visning 1 (rad), 'grid' = visning 2 (rute), 'showcase' = visning 3 (utstillingsrad) */ +export type GameView = 'list' | 'grid' | 'showcase'; diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 72367722..30aa20c3 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -4,5 +4,6 @@ import { defineConfig } from 'vite'; // https://vite.dev/config/ export default defineConfig({ + base: '/project2/', plugins: [react(), babel({ presets: [reactCompilerPreset()] })], }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f72dc0bb..19ed4030 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3,6 +3,7 @@ lockfileVersion: '9.0' settings: autoInstallPeers: true excludeLinksFromLockfile: false + injectWorkspacePackages: true importers: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 270b265c..fc9cae85 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,3 +4,5 @@ packages: allowBuilds: '@apollo/protobufjs': true esbuild: true + +injectWorkspacePackages: true diff --git a/scripts/db-tunnel.sh b/scripts/db-tunnel.sh index 908c0089..9589d04a 100755 --- a/scripts/db-tunnel.sh +++ b/scripts/db-tunnel.sh @@ -3,7 +3,7 @@ set -euo pipefail cd "$(dirname "$0")/.." set -a -source db/.env +source backend/.env set +a if lsof -i ":$TUNNEL_PORT" >/dev/null 2>&1; then