From a80a8492033f6ac0c3ec031764644ff4a3b88021 Mon Sep 17 00:00:00 2001 From: johannes Date: Fri, 9 Oct 2026 14:28:04 +0200 Subject: [PATCH 1/9] feat(frontend): legg til GameFilters-type --- frontend/src/types/filters.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 frontend/src/types/filters.ts diff --git a/frontend/src/types/filters.ts b/frontend/src/types/filters.ts new file mode 100644 index 0000000..6685274 --- /dev/null +++ b/frontend/src/types/filters.ts @@ -0,0 +1,16 @@ +export interface GameFilters { + genre: string | null; + platform: string | null; + // RAWG-rating 0-5, f.eks. 4 betyr "minst 4" + minRating: number | null; + yearFrom: number; + yearTo: number; +} + +export const defaultFilters: GameFilters = { + genre: null, + platform: null, + minRating: null, + yearFrom: 1980, + yearTo: 2025, +}; \ No newline at end of file From f5b371829acce694fcd8c2a7759a598a3f49524b Mon Sep 17 00:00:00 2001 From: johannes Date: Fri, 9 Oct 2026 14:28:14 +0200 Subject: [PATCH 2/9] chore(frontend): midlertidige valg for sjanger og plattform --- frontend/src/mocks/filterOptions.ts | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 frontend/src/mocks/filterOptions.ts diff --git a/frontend/src/mocks/filterOptions.ts b/frontend/src/mocks/filterOptions.ts new file mode 100644 index 0000000..d17ceff --- /dev/null +++ b/frontend/src/mocks/filterOptions.ts @@ -0,0 +1,7 @@ +/** + * Midlertidige valg til dropdownene i sidebaren. + * Skal erstattes av queries mot tabellene genres og platforms. + */ +export const mockGenres = ['Action', 'Adventure', 'Indie', 'Puzzle', 'RPG', 'Shooter', 'Simulation']; + +export const mockPlatforms = ['PC', 'PlayStation 5', 'Xbox Series S/X', 'Nintendo Switch']; \ No newline at end of file From 55846e3d842779e79f98f500758c6b8cd84b9df0 Mon Sep 17 00:00:00 2001 From: johannes Date: Fri, 16 Oct 2026 12:15:20 +0200 Subject: [PATCH 3/9] fix(frontend): hindre at sjangre og plattformer overskriver hverandre i Apollo-cachen --- frontend/src/apollo.ts | 8 +++++++- frontend/src/mocks/filterOptions.ts | 7 ------- 2 files changed, 7 insertions(+), 8 deletions(-) delete mode 100644 frontend/src/mocks/filterOptions.ts diff --git a/frontend/src/apollo.ts b/frontend/src/apollo.ts index a4532ad..fafa93c 100644 --- a/frontend/src/apollo.ts +++ b/frontend/src/apollo.ts @@ -7,5 +7,11 @@ import { ApolloClient, HttpLink, InMemoryCache } from '@apollo/client'; export const client = new ApolloClient({ link: new HttpLink({ uri: import.meta.env.VITE_GRAPHQL_URL ?? 'http://localhost:3001' }), - cache: new InMemoryCache(), + cache: new InMemoryCache({ + typePolicies: { + // Sjangre og plattformer er begge "Facet" og kan ha samme id. + // Uten dette ville de overskrevet hverandre i cachen. + Facet: { keyFields: false }, + }, + }), }); diff --git a/frontend/src/mocks/filterOptions.ts b/frontend/src/mocks/filterOptions.ts deleted file mode 100644 index d17ceff..0000000 --- a/frontend/src/mocks/filterOptions.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Midlertidige valg til dropdownene i sidebaren. - * Skal erstattes av queries mot tabellene genres og platforms. - */ -export const mockGenres = ['Action', 'Adventure', 'Indie', 'Puzzle', 'RPG', 'Shooter', 'Simulation']; - -export const mockPlatforms = ['PC', 'PlayStation 5', 'Xbox Series S/X', 'Nintendo Switch']; \ No newline at end of file From fdd920df4c83dc00fa260b8b9aaa007fa9d1db8d Mon Sep 17 00:00:00 2001 From: johannes Date: Fri, 16 Oct 2026 12:15:28 +0200 Subject: [PATCH 4/9] =?UTF-8?q?feat(frontend):=20sp=C3=B8rring=20for=20sja?= =?UTF-8?q?ngre=20og=20plattformer=20til=20filtrene?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/graphql/filterOptions.ts | 29 +++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 frontend/src/graphql/filterOptions.ts diff --git a/frontend/src/graphql/filterOptions.ts b/frontend/src/graphql/filterOptions.ts new file mode 100644 index 0000000..0b8dc9d --- /dev/null +++ b/frontend/src/graphql/filterOptions.ts @@ -0,0 +1,29 @@ +import { gql, type TypedDocumentNode } from '@apollo/client'; + +/** Ett valg i en dropdown, f.eks. en sjanger eller en plattform */ +export interface Facet { + id: string; + name: string; +} + +interface FilterOptionsData { + genres: Facet[]; + platforms: Facet[]; +} + +/** + * Henter alle sjangre og plattformer i én forespørsel til dropdownene i sidebaren. + * Listene endres sjelden, så Apollo-cachen gjør at de bare hentes én gang. + */ +export const FILTER_OPTIONS: TypedDocumentNode = gql` + query FilterOptions { + genres { + id + name + } + platforms { + id + name + } + } +`; From 07bdc0a82af8aa6973d0699857c41ad8702b9943 Mon Sep 17 00:00:00 2001 From: johannes Date: Fri, 16 Oct 2026 12:15:39 +0200 Subject: [PATCH 5/9] =?UTF-8?q?feat(frontend):=20send=20filtre=20for=20sja?= =?UTF-8?q?nger,=20plattform=20og=20=C3=A5r=20til=20backend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/graphql/games.ts | 15 +++++++++--- frontend/src/types/filters.ts | 43 +++++++++++++++++++++++++++-------- 2 files changed, 45 insertions(+), 13 deletions(-) diff --git a/frontend/src/graphql/games.ts b/frontend/src/graphql/games.ts index dc8f8e1..2dc446b 100644 --- a/frontend/src/graphql/games.ts +++ b/frontend/src/graphql/games.ts @@ -18,8 +18,17 @@ interface GamesData { }; } +/** Samme felter som input GameFilter i backend-skjemaet */ +export interface GameFilterInput { + genreIds: string[] | null; + platformIds: string[] | null; + releasedFrom: number | null; + releasedTo: number | null; +} + interface GamesVars { search: string | null; + filter: GameFilterInput; first: number; } @@ -28,8 +37,8 @@ interface GamesVars { * 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) { + query Games($search: String, $filter: GameFilter, $first: Int) { + games(search: $search, filter: $filter, first: $first) { totalCount edges { node { @@ -46,4 +55,4 @@ export const GAMES: TypedDocumentNode = gql` } } } -`; \ No newline at end of file +`; diff --git a/frontend/src/types/filters.ts b/frontend/src/types/filters.ts index 6685274..5c43b1a 100644 --- a/frontend/src/types/filters.ts +++ b/frontend/src/types/filters.ts @@ -1,16 +1,39 @@ +import type { GameFilterInput } from '../graphql/games'; + +/** + * Filtrene brukeren velger i sidebaren. Tom liste betyr "ikke filtrer på dette". + * Gjøres om til GraphQL-inputen GameFilter før de sendes til backend. + */ export interface GameFilters { - genre: string | null; - platform: string | null; - // RAWG-rating 0-5, f.eks. 4 betyr "minst 4" - minRating: number | null; + // id-er fra databasen. Tom liste betyr "ikke filtrer på dette" + genreIds: string[]; + platformIds: string[]; yearFrom: number; yearTo: number; } +// Grensene for årsslideren +export const MIN_YEAR = 1980; +export const MAX_YEAR = new Date().getFullYear(); + +// Startverdier: ingen filtre, hele årsintervallet export const defaultFilters: GameFilters = { - genre: null, - platform: null, - minRating: null, - yearFrom: 1980, - yearTo: 2025, -}; \ No newline at end of file + genreIds: [], + platformIds: [], + yearFrom: MIN_YEAR, + yearTo: MAX_YEAR, +}; + +/** + * Gjør om sidebar-filtrene til GraphQL-inputen backend forventer. + * Årene sendes bare når slideren er flyttet fra ytterkanten. Ellers ville + * spill uten utgivelsesdato forsvunnet fra lista selv om brukeren ikke har filtrert. + */ +export function toFilterInput(f: GameFilters): GameFilterInput { + return { + genreIds: f.genreIds.length ? f.genreIds : null, + platformIds: f.platformIds.length ? f.platformIds : null, + releasedFrom: f.yearFrom > MIN_YEAR ? f.yearFrom : null, + releasedTo: f.yearTo < MAX_YEAR ? f.yearTo : null, + }; +} From 4937801d90c662f3bdb2e9981321f5d8a2f58037 Mon Sep 17 00:00:00 2001 From: johannes Date: Fri, 16 Oct 2026 12:15:51 +0200 Subject: [PATCH 6/9] feat(frontend): useStickyTop-hook for sidefelt som stopper i kanten av skjermen --- frontend/src/hooks/useStickyTop.ts | 31 ++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 frontend/src/hooks/useStickyTop.ts diff --git a/frontend/src/hooks/useStickyTop.ts b/frontend/src/hooks/useStickyTop.ts new file mode 100644 index 0000000..125184b --- /dev/null +++ b/frontend/src/hooks/useStickyTop.ts @@ -0,0 +1,31 @@ +import { useEffect, type RefObject } from 'react'; + +/** + * Regner ut hvor et sticky-element skal stoppe, og legger det i CSS-variabelen --sticky-top. + * Kort element: stopper `margin` px fra toppen av skjermen. + * Høyt element: får negativ top, så det ruller med siden og stopper når bunnen + * er `margin` px fra bunnen av skjermen. Da slipper vi egen scrollbar i elementet. + * Kjører bare når høyden endres (ResizeObserver) eller vinduet endrer størrelse, ikke ved scroll. + */ +export function useStickyTop(ref: RefObject, margin = 24) { + useEffect(() => { + const el = ref.current; + if (!el) return; + + const update = () => { + const top = Math.min(margin, window.innerHeight - el.offsetHeight - margin); + el.style.setProperty('--sticky-top', `${top}px`); + }; + + update(); + // Høyden endres når en gruppe åpnes eller lukkes + const observer = new ResizeObserver(update); + observer.observe(el); + window.addEventListener('resize', update); + + return () => { + observer.disconnect(); + window.removeEventListener('resize', update); + }; + }, [ref, margin]); +} From 4fd5bb12386a531840bcc29aa0f356fc7a5d6670 Mon Sep 17 00:00:00 2001 From: johannes Date: Fri, 16 Oct 2026 12:16:05 +0200 Subject: [PATCH 7/9] =?UTF-8?q?feat(frontend):=20FilterSidebar=20med=20sam?= =?UTF-8?q?menleggbare=20grupper=20og=20=C3=A5rsslider?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../FilterSidebar/FilterSidebar.css | 171 ++++++++++++++++++ .../FilterSidebar/FilterSidebar.tsx | 129 +++++++++++++ 2 files changed, 300 insertions(+) create mode 100644 frontend/src/components/FilterSidebar/FilterSidebar.css create mode 100644 frontend/src/components/FilterSidebar/FilterSidebar.tsx diff --git a/frontend/src/components/FilterSidebar/FilterSidebar.css b/frontend/src/components/FilterSidebar/FilterSidebar.css new file mode 100644 index 0000000..7d2095f --- /dev/null +++ b/frontend/src/components/FilterSidebar/FilterSidebar.css @@ -0,0 +1,171 @@ +/* Under 1024px: filtrene ligger over resultatlista */ +.filter-sidebar { + padding: 24px 24px 0; + text-align: left; + font-family: var(--mono); + font-size: 13px; +} + +.filter-sidebar__inner { + display: flex; + flex-direction: column; + gap: 16px; +} + +.filter-sidebar h2 { + margin: 0; + font-size: 14px; +} + +.filter-sidebar fieldset { + min-width: 0; /* fieldset er ellers minst like bred som innholdet */ + margin: 0; + padding: 0; + border: 0; +} + +/* Overskriften på gruppen er klikkbar og åpner/lukker lista */ +.filter-sidebar summary { + cursor: pointer; + color: var(--text-h); +} + +/* Litt luft mellom overskriften og lista når gruppen er åpen */ +.filter-sidebar__group[open] summary { + margin-bottom: 6px; +} + +.filter-sidebar__options { + margin: 0; + padding: 0; + list-style: none; +} + +.filter-sidebar__options label { + display: flex; + align-items: center; + gap: 6px; + padding: 2px 0; + cursor: pointer; +} + +/* Slider i trykk-stil: tynn strek med Damp-symbolet som knott. + appearance: none fjerner nettleserens egen stil, så vi kan tegne den selv */ +.filter-sidebar input[type='range'] { + -webkit-appearance: none; + appearance: none; + width: 100%; + height: 20px; /* klikkflaten, litt høyere enn knotten */ + margin: 0; + /* Streken tegnes her i stedet for på track: 1px høy og 16px (én knott) kortere, + så den slutter midt i knotten når den står helt ytterst */ + background: linear-gradient(var(--text), var(--text)) center / calc(100% - 16px) 1px no-repeat; + cursor: pointer; +} + +/* Streken (Chrome, Edge, Safari) */ +.filter-sidebar input[type='range']::-webkit-slider-runnable-track { + height: 1px; + background: transparent; +} + +/* Knotten er Damp-symbolet (Chrome, Edge, Safari). + margin-top sentrerer den på streken: (1px - 16px) / 2 */ +.filter-sidebar input[type='range']::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 16px; + height: 16px; + margin-top: -7.5px; + background: url('../../assets/logo/damp-mark-on-light.svg') center / contain no-repeat; + background-color: var(--bg); /* dekker streken bak de åpne feltene i symbolet */ + border-radius: 50%; /* rund bakgrunn, så den ikke blir en firkant */ + border: 0; +} + +/* Samme for Firefox, som bruker egne navn */ +.filter-sidebar input[type='range']::-moz-range-track { + height: 1px; + background: transparent; +} + +.filter-sidebar input[type='range']::-moz-range-thumb { + width: 16px; + height: 16px; + background: url('../../assets/logo/damp-mark-on-light.svg') center / contain no-repeat; + background-color: var(--bg); /* dekker streken bak de åpne feltene i symbolet */ + border-radius: 50%; /* rund bakgrunn, så den ikke blir en firkant */ + border: 0; +} + +/* Mørkt tema: lys versjon av symbolet, som logoen i toppen */ +:root[data-theme='dark'] .filter-sidebar input[type='range']::-webkit-slider-thumb { + background-image: url('../../assets/logo/damp-mark-on-dark.svg'); +} + +:root[data-theme='dark'] .filter-sidebar input[type='range']::-moz-range-thumb { + background-image: url('../../assets/logo/damp-mark-on-dark.svg'); +} + +/* Tydelig fokusramme når slideren brukes med tastatur (tilgjengelighet) */ +.filter-sidebar input[type='range']:focus-visible { + outline: 2px solid var(--text-h); + outline-offset: 2px; +} + +/* Avkrysningsboksene i samme mørke farge i stedet for nettleserens blå */ +.filter-sidebar input[type='checkbox'] { + accent-color: var(--text-h); +} + +/* Tegningen vises bare i sidefeltet på brede skjermer */ +.filter-sidebar__drawing { + display: none; +} + +@media (min-width: 1024px) { + /* Usynlig skinne i sidefeltet, fra under logoen til bunnen av siden. + Bunnen er bunnen av , som er relative i index.css */ + .filter-sidebar { + position: absolute; + top: 160px; + bottom: 0; + left: calc(var(--gutter) / 2); + transform: translateX(-50%); + width: calc(var(--rail) - 48px); + padding: 0; + font-size: 12px; + } + + /* Filtrene ruller med siden og stopper når toppen (kort sidebar) eller + bunnen (lang sidebar) når kanten av skjermen. --sticky-top settes av useStickyTop */ + .filter-sidebar__inner { + position: sticky; + top: var(--sticky-top, 24px); + } + + /* Bakgrunn lik siden, så mix-blend-mode under har noe å blande mot. + transform på .filter-sidebar lager en egen "gruppe" som ellers blander mot gjennomsiktig */ + .filter-sidebar { + background: var(--bg); + } + + /* Tegningen nederst i sidebaren. Som CSS-bakgrunn i en media query lastes + bildet bare ned når det faktisk vises (bærekraft) */ + .filter-sidebar__drawing { + display: block; + width: 100%; + aspect-ratio: 800 / 698; + margin-top: 8px; + background: url('../../assets/drawings/tegning.webp') center / contain no-repeat; + mix-blend-mode: multiply; /* det hvite papiret i skanningen smelter inn i bakgrunnen */ + opacity: 0.85; + } + + /* Mørkt tema: inverterer tegningen så blyanten blir lys og papiret blir borte */ + :root[data-theme='dark'] .filter-sidebar__drawing { + filter: invert(1); + mix-blend-mode: screen; + opacity: 0.6; + } +} diff --git a/frontend/src/components/FilterSidebar/FilterSidebar.tsx b/frontend/src/components/FilterSidebar/FilterSidebar.tsx new file mode 100644 index 0000000..e2c2a58 --- /dev/null +++ b/frontend/src/components/FilterSidebar/FilterSidebar.tsx @@ -0,0 +1,129 @@ +import { useRef } from 'react'; +import { useStickyTop } from '../../hooks/useStickyTop'; +import type { Facet } from '../../graphql/filterOptions'; +import { defaultFilters, MAX_YEAR, MIN_YEAR, type GameFilters } from '../../types/filters'; +import './FilterSidebar.css'; + +interface FilterSidebarProps { + filters: GameFilters; + onChange: (filters: GameFilters) => void; + genres: Facet[]; + platforms: Facet[]; +} + +// Legger til id-en hvis den ikke er valgt, fjerner den hvis den er det +function toggle(ids: string[], id: string): string[] { + return ids.includes(id) ? ids.filter((x) => x !== id) : [...ids, id]; +} + +interface CheckboxGroupProps { + legend: string; + options: Facet[]; + selected: string[]; + onChange: (ids: string[]) => void; +} + +// Én sammenleggbar gruppe med avkrysningsbokser.
/ gir +// åpne/lukke uten egen state, og fungerer med tastatur og skjermleser. +function CheckboxGroup({ legend, options, selected, onChange }: CheckboxGroupProps) { + return ( +
+ + {legend} + {/* Viser hvor mange som er valgt, så det synes også når gruppen er lukket */} + {selected.length > 0 && ` (${selected.length})`} + + + {/* aria-label gir gruppen navn for skjermlesere, uten å vise teksten to ganger */} +
+
    + {options.map((o) => ( +
  • + +
  • + ))} +
+
+
+ ); +} + +// Sidebar med filtre. Som SearchBar eier den ikke state selv: +// forelderen holder filtrene, og sidebaren melder fra om endringer. +export function FilterSidebar({ filters, onChange, genres, platforms }: FilterSidebarProps) { + const innerRef = useRef(null); + useStickyTop(innerRef); + + return ( + + ); +} From a2ce346bb8d758003b5dfb67428272af854cd7e9 Mon Sep 17 00:00:00 2001 From: johannes Date: Fri, 16 Oct 2026 12:16:15 +0200 Subject: [PATCH 8/9] feat(frontend): koble FilterSidebar til resultatlista --- frontend/src/App.css | 2 +- frontend/src/App.tsx | 27 +++++++++++++++++++++++---- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/frontend/src/App.css b/frontend/src/App.css index 8a106e5..2bf280f 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -27,4 +27,4 @@ font-family: var(--mono); font-size: 13px; color: var(--text); -} \ No newline at end of file +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bd67ad9..19dd0f4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,6 +7,11 @@ import type { Suggestion } from './graphql/searchSuggestions'; import type { GameSummary, GameView } from './types/game'; import './App.css'; +import { FilterSidebar } from './components/FilterSidebar/FilterSidebar'; +import { FILTER_OPTIONS } from './graphql/filterOptions'; +import { useDebounce } from './hooks/useDebounce'; +import { defaultFilters, toFilterInput, type GameFilters } from './types/filters'; + /** * Forsiden: søk, resultatliste fra databasen og favoritter. * Favoritter og valgt visning ligger bare i minnet her og skal senere inn i state management. @@ -19,6 +24,12 @@ function App() { // 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(''); + // Filtrene fra sidebaren. Debounce så slideren ikke sender en request per steg + const [filters, setFilters] = useState(defaultFilters); + const debouncedFilters = useDebounce(filters, 300); + + // Valgene i dropdownene (sjangre og plattformer) fra databasen + const { data: options } = useQuery(FILTER_OPTIONS); // MIDLERTIDIG: det finnes ingen detaljside ennå, så valgt spill vises i lista. // Den som lager detaljsiden bytter denne linjen til navigering. @@ -27,7 +38,7 @@ function App() { // 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 }, + variables: { search: query || null, filter: toFilterInput(debouncedFilters), first: 20 }, }); const result = data ?? previousData; // forrige liste vises mens den nye lastes @@ -45,7 +56,8 @@ function App() { // 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`; + else if (result && !showFavorites) + status = `${result.games.totalCount.toLocaleString('nb-NO')} treff`; const toggleFavorite = (id: string) => { setFavorites((prev) => { @@ -66,11 +78,18 @@ function App() { onToggleFavorites={() => setShowFavorites((v) => !v)} /> + +

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

-
+