From f0cf22ff045b1081acf686f314eb98bae6b2714b Mon Sep 17 00:00:00 2001 From: Clemens Beese Date: Tue, 13 Oct 2026 11:18:42 +0200 Subject: [PATCH 1/7] feat(frontend): Apollo-klient mot GraphQL-backend --- frontend/src/apollo.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 frontend/src/apollo.ts diff --git a/frontend/src/apollo.ts b/frontend/src/apollo.ts new file mode 100644 index 0000000..a4532ad --- /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(), +}); From e77fb058a825f040ca9b17ac34ef4451c193e674 Mon Sep 17 00:00:00 2001 From: Clemens Beese Date: Tue, 13 Oct 2026 11:18:42 +0200 Subject: [PATCH 2/7] feat(frontend): ApolloProvider rundt appen --- frontend/src/main.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 2239905..d97a9d9 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( - + + + , ); From 336d28c1bd46e4a285aee4508093a5eb0b41675b Mon Sep 17 00:00:00 2001 From: Clemens Beese Date: Tue, 13 Oct 2026 11:18:42 +0200 Subject: [PATCH 3/7] =?UTF-8?q?feat(frontend):=20sp=C3=B8rring=20for=20s?= =?UTF-8?q?=C3=B8keforslag=20(6=20spill,=20bare=20navn=20og=20=C3=A5r)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/graphql/searchSuggestions.ts | 34 +++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 frontend/src/graphql/searchSuggestions.ts diff --git a/frontend/src/graphql/searchSuggestions.ts b/frontend/src/graphql/searchSuggestions.ts new file mode 100644 index 0000000..bd52ffb --- /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 + } + } + } + } +`; From cd839e020a147483184e3abdf1207a7bb0ebd0f0 Mon Sep 17 00:00:00 2001 From: Clemens Beese Date: Tue, 13 Oct 2026 11:18:42 +0200 Subject: [PATCH 4/7] style(frontend): GameSearch med forslagsliste i trykk-stil --- .../src/components/GameSearch/GameSearch.css | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 frontend/src/components/GameSearch/GameSearch.css diff --git a/frontend/src/components/GameSearch/GameSearch.css b/frontend/src/components/GameSearch/GameSearch.css new file mode 100644 index 0000000..f66f023 --- /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; +} From 569bee68c451588e58cb4700396af16823899419 Mon Sep 17 00:00:00 2001 From: Clemens Beese Date: Tue, 13 Oct 2026 11:18:42 +0200 Subject: [PATCH 5/7] feat(frontend): GameSearch med forslag fra serveren (combobox) --- .../src/components/GameSearch/GameSearch.tsx | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 frontend/src/components/GameSearch/GameSearch.tsx diff --git a/frontend/src/components/GameSearch/GameSearch.tsx b/frontend/src/components/GameSearch/GameSearch.tsx new file mode 100644 index 0000000..d753ebf --- /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'} +

+ )} +
+ )} +
+ ); +} From 6ea1d93ddb4968676cf1c960a1a8027d719b7c57 Mon Sep 17 00:00:00 2001 From: Clemens Beese Date: Tue, 13 Oct 2026 11:18:42 +0200 Subject: [PATCH 6/7] feat(frontend): bruk GameSearch i Header --- frontend/src/components/Header/Header.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx index d62656b..ad0f70b 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -1,11 +1,12 @@ import { Logo } from '../Logo/Logo'; -import { SearchBar } from '../SearchBar/SearchBar'; +import { GameSearch } from '../GameSearch/GameSearch'; +import type { Suggestion } from '../../graphql/searchSuggestions'; import { ThemeToggle } from '../ThemeToggle/ThemeToggle'; import './Header.css'; interface HeaderProps { - search: string; - onSearchChange: (value: string) => void; + onSearch: (term: string) => void; + onSelectGame: (game: Suggestion) => void; favoritesCount: number; showFavorites: boolean; onToggleFavorites: () => void; @@ -16,8 +17,8 @@ interface HeaderProps { * Ingen bokser, bare tekst og en tynn linje under. */ export function Header({ - search, - onSearchChange, + onSearch, + onSelectGame, favoritesCount, showFavorites, onToggleFavorites, @@ -26,7 +27,7 @@ export function Header({
- +