diff --git a/backend/src/resolvers.ts b/backend/src/resolvers.ts index 5208819..bf85f05 100644 --- a/backend/src/resolvers.ts +++ b/backend/src/resolvers.ts @@ -35,7 +35,6 @@ const GAME_COLS = ` ratings_count AS "ratingsCount", playtime, esrb_rating AS "esrbRating", - dominant_color AS "dominantColor", background_image AS "backgroundImage", background_image_additional AS "backgroundImageAdditional", COALESCE(screenshots, '{}') AS "screenshots" diff --git a/backend/src/schema.ts b/backend/src/schema.ts index 437122e..90ea49a 100644 --- a/backend/src/schema.ts +++ b/backend/src/schema.ts @@ -45,7 +45,6 @@ export const typeDefs = `#graphql ratingsCount: Int!, playtime: Int, esrbRating: String, - dominantColor: String, backgroundImage: String, backgroundImageAdditional: String, screenshots: [String!]! diff --git a/db/grants.sql b/db/grants.sql index 1de3387..509bfe9 100644 --- a/db/grants.sql +++ b/db/grants.sql @@ -10,5 +10,5 @@ GRANT INSERT ON users TO webapp; GRANT USAGE ON SEQUENCE users_id_seq TO webapp; GRANT INSERT, DELETE ON reviews TO webapp; -GRANT UPDATE (rating, body) ON reviews TO webapp; +GRANT UPDATE (rating, body, hours_played) ON reviews TO webapp; GRANT USAGE ON SEQUENCE reviews_id_seq TO webapp; diff --git a/db/load.py b/db/load.py index 5b65b74..1dba2aa 100644 --- a/db/load.py +++ b/db/load.py @@ -1,3 +1,4 @@ +import html import os import sys import tempfile @@ -12,6 +13,9 @@ FORCE = "--force" in sys.argv WIPE_USERS = "--wipe-user-data" in sys.argv +# Tags used by fewer games than this are dropped (long tail of one-off, noisy tags). +MIN_TAG_GAMES = int(os.environ.get("MIN_TAG_GAMES", "50")) + # (parquet column, lookup table, junction table, junction fk column) RELATIONS = [ ("genres", "genres", "game_genres", "genre_id"), @@ -24,20 +28,28 @@ ] GAME_COLUMNS = ( - "id, slug, name, name_original, alternative_names, search_names, released, " + "id, slug, name, alternative_names, search_names, released, " "background_image, background_image_additional, screenshots, rating, ratings_count, " - "added, metacritic, playtime, esrb_rating, description, website, dominant_color" + "added, metacritic, playtime, esrb_rating, description, website" ) 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')" + """Empty strings and the texts 'None'/'nan' (nulls written as strings) become NULL.""" + return f"CASE WHEN lower(trim({col})) IN ('', 'none', 'nan') THEN NULL ELSE trim({col}) END" -# Pipe-separated names, trimmed, empty entries removed. +# Pipe-separated names, trimmed, empty and null-like entries removed. ALT_NAMES = ( f"list_filter(list_transform(string_split({clean('alternative_names')}, '|'), s -> trim(s)), " - "s -> s NOT IN ('', 'None'))" + "s -> lower(s) NOT IN ('', 'none', 'nan'))" +) + +# description_raw is plain text; description is the fallback (tags stripped, HTML entities kept). +# Both get entities decoded and leftover Markdown headings / HTML tags removed. +DESCRIPTION = ( + "NULLIF(trim(regexp_replace(unescape(COALESCE(" + f"{clean('description_raw')}, {clean('description')})), " + r"'#{2,}\s*|<[^>]+>', '', 'g')), '')" ) # short_screenshots holds Python-style dicts ({'id': 123, 'image': '...'}), not JSON. @@ -69,10 +81,11 @@ def pg_array(list_expr: str) -> str: def export_csvs(out: Path) -> None: con = duckdb.connect() + con.create_function("unescape", html.unescape, ["VARCHAR"], "VARCHAR") con.execute( f"""CREATE VIEW src AS SELECT * FROM read_parquet('{PARQUET}') - WHERE added > 0 AND name IS NOT NULL""" + WHERE added > 0 AND name IS NOT NULL AND trim(name) <> ''""" ) con.execute( f"""COPY ( @@ -80,11 +93,11 @@ def export_csvs(out: Path) -> None: id, 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, + concat_ws(' | ', name, array_to_string({ALT_NAMES}, ' | ')) AS search_names, + -- Dates far in the future are placeholders, not real release dates. + CASE WHEN TRY_CAST(released AS DATE) <= current_date + INTERVAL 5 YEAR + THEN TRY_CAST(released AS DATE) END AS released, {clean('background_image')} AS background_image, {clean('background_image_additional')} AS background_image_additional, {pg_array(SCREENSHOTS)} AS screenshots, @@ -94,9 +107,8 @@ def export_csvs(out: Path) -> None: TRY_CAST(metacritic AS INTEGER) AS metacritic, TRY_CAST(playtime AS INTEGER) AS playtime, {ESRB} AS esrb_rating, - {clean('description_raw')} AS description, - {clean('website')} AS website, - {clean('dominant_color')} AS dominant_color + {DESCRIPTION} AS description, + {clean('website')} AS website FROM src ) TO '{out}/games.csv' (FORMAT csv, HEADER)""" ) @@ -106,10 +118,14 @@ def export_csvs(out: Path) -> None: SELECT id AS game_id, trim(unnest(string_split({col}, '|'))) AS name FROM src WHERE {col} IS NOT NULL""" ) + min_games = MIN_TAG_GAMES if table == "tags" else 1 con.execute( - """CREATE OR REPLACE TEMP TABLE lk AS + f"""CREATE OR REPLACE TEMP TABLE lk AS SELECT row_number() OVER (ORDER BY name) AS id, name - FROM (SELECT DISTINCT name FROM pairs WHERE name NOT IN ('', 'None'))""" + FROM (SELECT name FROM pairs + WHERE name NOT IN ('', 'None') + GROUP BY name + HAVING count(DISTINCT game_id) >= {min_games})""" ) con.execute(f"COPY (SELECT id, name FROM lk) TO '{out}/{table}.csv' (FORMAT csv, HEADER)") con.execute( diff --git a/db/requirements.txt b/db/requirements.txt index c3eb184..99a49dc 100644 --- a/db/requirements.txt +++ b/db/requirements.txt @@ -1,2 +1,3 @@ duckdb +numpy psycopg[binary] diff --git a/db/schema.sql b/db/schema.sql index 558654d..e3df81b 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -8,7 +8,6 @@ CREATE TABLE games ( id BIGINT PRIMARY KEY, slug TEXT, name TEXT NOT NULL, - name_original TEXT, alternative_names TEXT[], search_names TEXT NOT NULL, released DATE, @@ -22,8 +21,7 @@ CREATE TABLE games ( playtime INTEGER, esrb_rating TEXT, description TEXT, - website TEXT, - dominant_color TEXT + website TEXT ); CREATE TABLE genres (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE); @@ -83,6 +81,7 @@ CREATE TABLE reviews ( user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, rating SMALLINT NOT NULL CHECK (rating BETWEEN 1 AND 5), body TEXT NOT NULL CHECK (char_length(body) BETWEEN 1 AND 2000), + hours_played SMALLINT CHECK (hours_played BETWEEN 1 AND 10000), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (game_id, user_id) ); diff --git a/frontend/package.json b/frontend/package.json index 271323b..7ced1dd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,7 +13,8 @@ "@apollo/client": "^4.3.1", "graphql": "^17.0.2", "react": "^19.2.8", - "react-dom": "^19.2.8" + "react-dom": "^19.2.8", + "react-router": "^8.4.0" }, "devDependencies": { "@babel/core": "^7.29.7", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 19dd0f4..2570c53 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,9 @@ import { useState } from 'react'; +import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router'; import { useQuery } from '@apollo/client/react'; import { GameList } from './components/GameList/GameList'; import { Header } from './components/Header/Header'; +import { GameDetail } from './pages/GameDetail/GameDetail'; import { GAMES } from './graphql/games'; import type { Suggestion } from './graphql/searchSuggestions'; import type { GameSummary, GameView } from './types/game'; @@ -13,13 +15,17 @@ import { useDebounce } from './hooks/useDebounce'; import { defaultFilters, toFilterInput, type GameFilters } from './types/filters'; /** - * Forsiden: søk, resultatliste fra databasen og favoritter. + * Rammen rundt hele appen: toppfeltet og sidene (forsiden med filtre og resultatliste, + * og detaljsiden for ett spill). Søk, filtre, visning og favoritter ligger her, så de + * huskes når man går fra detaljsiden tilbake til lista. * 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'); const [showFavorites, setShowFavorites] = useState(false); + const navigate = useNavigate(); + const onHome = useLocation().pathname === '/'; // 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 @@ -28,17 +34,46 @@ function App() { const [filters, setFilters] = useState(defaultFilters); const debouncedFilters = useDebounce(filters, 300); - // Valgene i dropdownene (sjangre og plattformer) fra databasen - const { data: options } = useQuery(FILTER_OPTIONS); + // Øker hver gang man trykker på logoen. Brukes som key på søkefeltet, så det tømmes + const [searchResetKey, setSearchResetKey] = useState(0); + + // Logoen: tilbake til forsiden med standardlista (mest populære, uten filtre). + // Selve sidebyttet gjør lenken i Logo, her nullstilles bare innholdet. + const goHome = () => { + setQuery(''); + setFilters(defaultFilters); + setShowFavorites(false); + setSearchResetKey((k) => k + 1); + window.scrollTo({ top: 0 }); + }; + + // Valgene i dropdownene (sjangre og plattformer) fra databasen. + // Filtrene vises bare på forsiden, så de hentes ikke på detaljsiden. + const { data: options } = useQuery(FILTER_OPTIONS, { skip: !onHome }); - // 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); + // Valgt forslag i søkefeltet åpner detaljsiden til spillet + const handleSelectGame = (game: Suggestion) => navigate(`/game/${game.id}`); + + // Søkeresultater og favoritter vises på forsiden, så fra detaljsiden går vi dit + const handleSearch = (term: string) => { + setQuery(term); + if (!onHome) navigate('/'); + }; + const handleToggleFavorites = () => { + if (onHome) { + setShowFavorites((v) => !v); + } else { + setShowFavorites(true); + navigate('/'); + } + }; // 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. + // Tomt søk (null) gir de mest populære spillene. Hoppes over på detaljsiden, der lista + // ikke vises. Tilbake på forsiden kommer lista fra Apollo-cachen uten ny forespørsel. const { data, previousData, loading, error } = useQuery(GAMES, { variables: { search: query || null, filter: toFilterInput(debouncedFilters), first: 20 }, + skip: !onHome, }); const result = data ?? previousData; // forrige liste vises mens den nye lastes @@ -71,71 +106,91 @@ function App() { return ( <>
setShowFavorites((v) => !v)} + showFavorites={showFavorites && onHome} + onToggleFavorites={handleToggleFavorites} /> - + + + + +
+
+

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

+ +
+ + + + +
+
+ +

+ {status} +

+ {error &&

Kunne ikke hente spill: {error.message}

} + + +
+ + } + /> -
-
-

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

- -
- - - - -
-
- -

- {status} -

- {error &&

Kunne ikke hente spill: {error.message}

} - - } /> -
+ + {/* Ukjente adresser sendes til forsiden */} + } /> +
); } diff --git a/frontend/src/components/GameCard/GameCard.tsx b/frontend/src/components/GameCard/GameCard.tsx index 0687743..5c8c3ee 100644 --- a/frontend/src/components/GameCard/GameCard.tsx +++ b/frontend/src/components/GameCard/GameCard.tsx @@ -1,3 +1,4 @@ +import { Link } from 'react-router'; import type { GameSummary, GameView } from '../../types/game'; import './GameCard.css'; @@ -37,7 +38,8 @@ export function GameCard({ game, isFavorite, onToggleFavorite, view = 'list' }:

- {game.name} + {/* Link i stedet for : bytter side uten å laste hele appen på nytt, og tar med /project2 */} + {game.name}

{meta &&

{meta}

} diff --git a/frontend/src/components/GameSearch/GameSearch.css b/frontend/src/components/GameSearch/GameSearch.css index f66f023..c387eae 100644 --- a/frontend/src/components/GameSearch/GameSearch.css +++ b/frontend/src/components/GameSearch/GameSearch.css @@ -18,7 +18,7 @@ /* Bare en linje under, som resten av toppfeltet */ .game-search__input { width: 100%; - padding: 0.4rem 0; + padding: 0.4rem 2rem 0.4rem 0; /* topp, høyre, bunn, venstre: plass til X-en til høyre */ font: inherit; color: var(--text-h); background: transparent; @@ -39,6 +39,32 @@ box-shadow: 0 1px 0 var(--text-h); } +/* X-knappen helt til høyre inne i søkefeltet, uten boks som resten av tekstknappene */ +.game-search__clear { + position: absolute; /* plasseres i forhold til .game-search, som har position: relative */ + top: 0; + right: 0; + height: 100%; + min-width: 32px; /* stort nok til å treffe med finger */ + padding: 0; + font: inherit; + font-size: 1.3em; + line-height: 1; + color: var(--text); + background: none; + border: 0; + cursor: pointer; +} + +.game-search__clear:hover { + color: var(--text-h); +} + +.game-search__clear:focus-visible { + outline: 2px solid var(--text-h); + outline-offset: 2px; +} + /* Forslagene legger seg over innholdet under, med tynn linje rundt i stedet for skygge */ .game-search__popup { position: absolute; diff --git a/frontend/src/components/GameSearch/GameSearch.tsx b/frontend/src/components/GameSearch/GameSearch.tsx index d753ebf..74fbeda 100644 --- a/frontend/src/components/GameSearch/GameSearch.tsx +++ b/frontend/src/components/GameSearch/GameSearch.tsx @@ -1,7 +1,8 @@ -import { useId, useState, type KeyboardEvent } from 'react'; +import { useId, useRef, 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 @@ -29,6 +30,7 @@ export function GameSearch({ onSearch, onSelectGame }: GameSearchProps) { const [active, setActive] = useState(-1); // markert forslag, -1 = ingen const id = useId(); const listId = `${id}-list`; + const inputRef = useRef(null); // Spør først 300 ms etter siste tastetrykk, og ikke ved for kort tekst const term = useDebounce(input.trim(), 300); @@ -39,7 +41,10 @@ export function GameSearch({ onSearch, onSelectGame }: GameSearchProps) { // 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; + + // Sjekker også det som står i feltet nå, ikke bare den forsinkede teksten. + // Ellers dukker gamle forslag opp et øyeblikk etter at feltet er tømt. + const showPopup = open && input.trim().length >= MIN_CHARS && term.length >= MIN_CHARS; function select(game: Suggestion) { setOpen(false); @@ -47,6 +52,15 @@ export function GameSearch({ onSearch, onSelectGame }: GameSearchProps) { onSelectGame(game); } + // X-knappen: tømmer feltet og viser standardlista (forsiden) + function clear() { + setInput(''); + setOpen(false); + setActive(-1); + onSearch(''); // App tømmer søket, og fra en detaljside går den til forsiden + inputRef.current?.focus(); // fokus blir i feltet, ellers forsvinner det med knappen + } + function handleKeyDown(e: KeyboardEvent) { if (e.key === 'ArrowDown') { e.preventDefault(); @@ -100,6 +114,13 @@ export function GameSearch({ onSearch, onSelectGame }: GameSearchProps) { onBlur={() => setOpen(false)} /> + {/* Vises bare når det står noe i feltet. aria-label fordi "×" alene ikke sier noe */} + {input && ( + + )} + {showPopup && (
{suggestions.length > 0 ? ( diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx index ad0f70b..c9dd7f3 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -10,6 +10,8 @@ interface HeaderProps { favoritesCount: number; showFavorites: boolean; onToggleFavorites: () => void; + onHome: () => void; + searchResetKey: number; } /** @@ -19,15 +21,17 @@ interface HeaderProps { export function Header({ onSearch, onSelectGame, + onHome, + searchResetKey, favoritesCount, showFavorites, onToggleFavorites, }: HeaderProps) { return (
- +
- +
+ + {game.backgroundImage && ( + // alt="" fordi tittelen over allerede sier hva bildet er. Fast størrelse + // hindrer at innholdet under hopper når bildet laster. + + )} + +
+
+
Rating
+
+ {game.rating != null ? `${game.rating.toFixed(1)} / 5` : 'Ingen'} + {game.ratingsCount > 0 && ( + + {game.ratingsCount.toLocaleString('nb-NO')} vurderinger + + )} +
+
+
+
Metacritic
+
{game.metacritic ?? 'Ingen'}
+
+ {/* RAWG har 0 timer for spill uten data, så da viser vi ikke spilletid */} + {game.playtime != null && game.playtime > 0 && ( +
+
Spilletid
+
{game.playtime} t
+
+ )} + {game.esrbRating && ( +
+
Aldersgrense
+
{game.esrbRating}
+
+ )} +
+ +
+ + {screenshots.length > 0 && ( +
+

+ Skjermbilder +

+
    + {screenshots.map((src, i) => ( +
  • + {/* lazy: lastes først når man scroller ned til dem */} + {`Skjermbilde +
  • + ))} +
+
+ )} + + + ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 19ed403..91b5994 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -70,6 +70,9 @@ importers: react-dom: specifier: ^19.2.8 version: 19.3.0(react@19.3.0) + react-router: + specifier: ^8.4.0 + version: 8.4.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) devDependencies: '@babel/core': specifier: ^7.29.7 @@ -596,6 +599,9 @@ packages: '@protobufjs/utf8@1.1.2': resolution: {integrity: sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==} + '@remix-run/route-pattern@0.22.1': + resolution: {integrity: sha512-czdGJWh09Lapvcqt7Vb09KlrU2PhRJ8xQaI+AItTuD9aDJ5MAgxnS38lnys6Ll+6RJEqPiaUqTwIQhVLwFvv+w==} + '@rolldown/binding-android-arm-eabi@1.2.12': resolution: {integrity: sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==} engines: {node: ^20.19.0 || >=22.12.0} @@ -923,6 +929,9 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-es@3.1.1: + resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} + cors@2.8.6: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} engines: {node: '>= 0.10'} @@ -1519,6 +1528,16 @@ packages: peerDependencies: react: ^19.3.0 + react-router@8.4.0: + resolution: {integrity: sha512-JtydAkBvU9UTEe5qNC+POhu+ZBNM7rlKY2IegwXc7Idj7xfRG16x5RZrw3mju+XlqBxfvb2ky9P3jUp9WyWC1g==} + engines: {node: '>=22.22.0'} + peerDependencies: + react: '>=19.2.7' + react-dom: '>=19.2.7' + peerDependenciesMeta: + react-dom: + optional: true + react@19.3.0: resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==} engines: {node: '>=0.10.0'} @@ -2190,6 +2209,8 @@ snapshots: '@protobufjs/utf8@1.1.2': {} + '@remix-run/route-pattern@0.22.1': {} + '@rolldown/binding-android-arm-eabi@1.2.12': optional: true @@ -2501,6 +2522,8 @@ snapshots: convert-source-map@2.0.0: {} + cookie-es@3.1.1: {} + cors@2.8.6: dependencies: object-assign: 4.1.1 @@ -3052,6 +3075,14 @@ snapshots: react: 19.3.0 scheduler: 0.28.0 + react-router@8.4.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0): + dependencies: + '@remix-run/route-pattern': 0.22.1 + cookie-es: 3.1.1 + react: 19.3.0 + optionalDependencies: + react-dom: 19.3.0(react@19.3.0) + react@19.3.0: {} retry@0.13.1: {}