Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<GameFilters>(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.
Expand All @@ -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

Expand Down Expand Up @@ -67,6 +78,13 @@ function App() {
onToggleFavorites={() => setShowFavorites((v) => !v)}
/>

<FilterSidebar
filters={filters}
onChange={setFilters}
genres={options?.genres ?? []}
platforms={options?.platforms ?? []}
/>

<main className="main">
<div className="results-bar">
<h2 className="label">{showFavorites ? 'Favoritter' : 'Søkeresultater'}</h2>
Expand Down
8 changes: 7 additions & 1 deletion frontend/src/apollo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
},
}),
});
171 changes: 171 additions & 0 deletions frontend/src/components/FilterSidebar/FilterSidebar.css
Original file line number Diff line number Diff line change
@@ -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 <html>, 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;
}
}
129 changes: 129 additions & 0 deletions frontend/src/components/FilterSidebar/FilterSidebar.tsx
Original file line number Diff line number Diff line change
@@ -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. <details>/<summary> gir
// åpne/lukke uten egen state, og fungerer med tastatur og skjermleser.
function CheckboxGroup({ legend, options, selected, onChange }: CheckboxGroupProps) {
return (
<details className="filter-sidebar__group">
<summary>
{legend}
{/* Viser hvor mange som er valgt, så det synes også når gruppen er lukket */}
{selected.length > 0 && ` (${selected.length})`}
</summary>

{/* aria-label gir gruppen navn for skjermlesere, uten å vise teksten to ganger */}
<fieldset aria-label={legend}>
<ul className="filter-sidebar__options">
{options.map((o) => (
<li key={o.id}>
<label>
<input
type="checkbox"
checked={selected.includes(o.id)}
onChange={() => onChange(toggle(selected, o.id))}
/>
{o.name}
</label>
</li>
))}
</ul>
</fieldset>
</details>
);
}

// 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<HTMLDivElement>(null);
useStickyTop(innerRef);

return (
<aside className="filter-sidebar" aria-label="Filtre">
<div className="filter-sidebar__inner" ref={innerRef}>
<h2>Filtrer</h2>

<CheckboxGroup
legend="Sjanger"
options={genres}
selected={filters.genreIds}
onChange={(genreIds) => onChange({ ...filters, genreIds })}
/>

<CheckboxGroup
legend="Plattform"
options={platforms}
selected={filters.platformIds}
onChange={(platformIds) => onChange({ ...filters, platformIds })}
/>

{/* Sammenleggbar som sjanger og plattform. Viser valgt intervall i overskriften
når det er endret, så det synes også når gruppen er lukket */}
<details className="filter-sidebar__group">
<summary>
Utgivelsesår
{(filters.yearFrom !== MIN_YEAR || filters.yearTo !== MAX_YEAR) &&
` (${filters.yearFrom}–${filters.yearTo})`}
</summary>

<fieldset aria-label="Utgivelsesår">
<label htmlFor="filter-year-from">Fra: {filters.yearFrom}</label>
<input
id="filter-year-from"
type="range"
min={MIN_YEAR}
max={MAX_YEAR}
value={filters.yearFrom}
// Math.min hindrer at "fra" blir større enn "til"
onChange={(e) =>
onChange({ ...filters, yearFrom: Math.min(Number(e.target.value), filters.yearTo) })
}
/>

<label htmlFor="filter-year-to">Til: {filters.yearTo}</label>
<input
id="filter-year-to"
type="range"
min={MIN_YEAR}
max={MAX_YEAR}
value={filters.yearTo}
// Math.max hindrer at "til" blir mindre enn "fra"
onChange={(e) =>
onChange({ ...filters, yearTo: Math.max(Number(e.target.value), filters.yearFrom) })
}
/>
</fieldset>
</details>

<button type="button" className="text-button" onClick={() => onChange(defaultFilters)}>
nullstill filtre
</button>
{/* Pynt nederst i sidebaren. aria-hidden fordi den ikke har noe innhold for skjermlesere */}
<div className="filter-sidebar__drawing" aria-hidden="true" />
</div>
</aside>
);
}
10 changes: 5 additions & 5 deletions frontend/src/components/Logo/Logo.css
Original file line number Diff line number Diff line change
Expand Up @@ -29,21 +29,21 @@
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;
}

.logo img {
height: auto;
width: min(90px, calc((100vw - 1126px) / 2 - 48px));
width: 90px;
}
}
Loading