diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index af1d58b..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 @@ -67,6 +78,13 @@ function App() { onToggleFavorites={() => setShowFavorites((v) => !v)} /> + +

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

diff --git a/frontend/src/apollo.ts b/frontend/src/apollo.ts index dba0036..418c1c5 100644 --- a/frontend/src/apollo.ts +++ b/frontend/src/apollo.ts @@ -8,5 +8,11 @@ import { ApolloClient, HttpLink, InMemoryCache } from '@apollo/client'; export const client = new ApolloClient({ link: new HttpLink({ uri: `${import.meta.env.BASE_URL}graphql` }), - 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/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 ( + + ); +} diff --git a/frontend/src/components/Logo/Logo.css b/frontend/src/components/Logo/Logo.css index 11e182f..030f080 100644 --- a/frontend/src/components/Logo/Logo.css +++ b/frontend/src/components/Logo/Logo.css @@ -29,14 +29,14 @@ filter: invert(1); } -/* Brede skjermer: fast og midt i sidepanelet til venstre, navnet under. Må stå sist i filen */ -@media (min-width: 1320px) { +/* Brede skjermer: fast og midt i sidepanelet til venstre. Må stå sist i filen */ +@media (min-width: 1024px) { .logo { - position: fixed; + position: absolute; z-index: 10; top: 20px; /* Midten av sidepanelet. 1126px er bredden på #root i index.css */ - left: calc((100vw - 1126px) / 4); + left: calc(var(--gutter) / 2); transform: translateX(-50%); flex-direction: column; gap: 12px; @@ -44,6 +44,6 @@ .logo img { height: auto; - width: min(90px, calc((100vw - 1126px) / 2 - 48px)); + width: 90px; } } 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 + } + } +`; diff --git a/frontend/src/graphql/games.ts b/frontend/src/graphql/games.ts index 6cc96ff..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 { 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]); +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 26d5f07..d4ab213 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -15,6 +15,11 @@ --heading: system-ui, 'Segoe UI', Roboto, sans-serif; --mono: ui-monospace, Consolas, monospace; + /* Oppsett: innholdet i midten og sidefeltet til venstre (logo, filtre, tegning) */ + --content: 1126px; /* bredden på innholdet (#root) */ + --rail: 240px; /* minste bredde på sidefeltet til venstre */ + --gutter: max(var(--rail), (100vw - var(--content)) / 2); /* faktisk bredde på sidefeltet */ + font: 18px/145% var(--sans); letter-spacing: 0.18px; color-scheme: light; @@ -58,6 +63,22 @@ box-sizing: border-box; } +/* Fra 1024px: plass til sidefeltet til venstre. Innholdet er sentrert når det er plass, + ellers skyves det mot høyre så sidefeltet aldri blir smalere enn --rail */ +@media (min-width: 1024px) { + #root { + width: auto; + max-width: var(--content); + margin-left: var(--gutter); + margin-right: auto; + } + + /* Gjør at skinnen til filtrene kan gå helt til bunnen av siden (se FilterSidebar.css) */ + html { + position: relative; + } +} + body { margin: 0; } @@ -106,39 +127,6 @@ code { 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; diff --git a/frontend/src/types/filters.ts b/frontend/src/types/filters.ts new file mode 100644 index 0000000..5c43b1a --- /dev/null +++ b/frontend/src/types/filters.ts @@ -0,0 +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 { + // 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 = { + 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, + }; +}