diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bfcc93e5..69a97a45 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,7 @@ jobs: lint-and-test: runs-on: self-hosted strategy: + fail-fast: false matrix: workspace: [frontend, backend] defaults: @@ -18,15 +19,23 @@ jobs: steps: - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24.21.0 + - uses: pnpm/action-setup@v4 with: version: 11.25.0 - - uses: actions/setup-node@v4 + - name: Get pnpm store path + id: pnpm-store + run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + + - uses: actions/cache@v4 with: - node-version: 24.21.0 - cache: pnpm - cache-dependency-path: ${{ matrix.workspace }}/pnpm-lock.yaml + path: ${{ steps.pnpm-store.outputs.path }} + key: pnpm-${{ runner.os }}-${{ matrix.workspace }}-${{ hashFiles(format('{0}/pnpm-lock.yaml', matrix.workspace)) }} + restore-keys: pnpm-${{ runner.os }}-${{ matrix.workspace }}- - name: Install dependencies run: pnpm install --frozen-lockfile diff --git a/backend/.env.example b/backend/.env.example index 68799a7d..453b8c8a 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -6,4 +6,4 @@ TUNNEL_PORT=5433 # backend server env vars DATABASE_URL=postgresql://webapp:@localhost:5433/gamesdb PORT=3001 -JWT_TOKEN= \ No newline at end of file +#JWT_TOKEN= diff --git a/frontend/index.html b/frontend/index.html index 78357eeb..63465482 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,10 +1,23 @@ - + Damp + +
diff --git a/frontend/src/App.css b/frontend/src/App.css index d6c7c9a0..8a106e5a 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,182 +1,30 @@ -.counter { - font-size: 16px; - padding: 5px 10px; - border-radius: 5px; - color: var(--accent); - background: var(--accent-bg); - border: 2px solid transparent; - transition: border-color 0.3s; - margin-bottom: 24px; - - &:hover { - border-color: var(--accent-border); - } - &:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; - } -} - -.hero { - position: relative; - - .base, - .framework, - .vite { - inset-inline: 0; - margin: 0 auto; - } - - .base { - width: 170px; - position: relative; - z-index: 0; - } - - .framework, - .vite { - position: absolute; - } - - .framework { - z-index: 1; - top: 34px; - height: 28px; - transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) scale(1.4); - } - - .vite { - z-index: 0; - top: 107px; - height: 26px; - width: auto; - transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) scale(0.8); - } -} - -#center { - display: flex; - flex-direction: column; - gap: 25px; - place-content: center; - place-items: center; - flex-grow: 1; - - @media (max-width: 1024px) { - padding: 32px 20px 24px; - gap: 18px; - } -} - -#next-steps { - display: flex; - border-top: 1px solid var(--border); +/* Hovedinnholdet under toppfeltet */ +.main { + padding: 24px; text-align: left; - - & > div { - flex: 1 1 0; - padding: 32px; - @media (max-width: 1024px) { - padding: 24px 20px; - } - } - - .icon { - margin-bottom: 16px; - width: 22px; - height: 22px; - } - - @media (max-width: 1024px) { - flex-direction: column; - text-align: center; - } -} - -#docs { - border-right: 1px solid var(--border); - - @media (max-width: 1024px) { - border-right: none; - border-bottom: 1px solid var(--border); - } } -#next-steps ul { - list-style: none; - padding: 0; +/* Linjen over resultatene: overskrift til venstre, valg av visning til høyre */ +.results-bar { display: flex; - gap: 8px; - margin: 32px 0 0; - - .logo { - height: 18px; - } - - a { - color: var(--text-h); - font-size: 16px; - border-radius: 6px; - background: var(--social-bg); - display: flex; - padding: 6px 12px; - align-items: center; - gap: 8px; - text-decoration: none; - transition: box-shadow 0.3s; - - &:hover { - box-shadow: var(--shadow); - } - .button-icon { - height: 18px; - width: 18px; - } - } - - @media (max-width: 1024px) { - margin-top: 20px; - flex-wrap: wrap; - justify-content: center; - - li { - flex: 1 1 calc(50% - 8px); - } - - a { - width: 100%; - justify-content: center; - box-sizing: border-box; - } - } -} - -#spacer { - height: 88px; - border-top: 1px solid var(--border); - @media (max-width: 1024px) { - height: 48px; - } + align-items: baseline; + justify-content: space-between; + padding-bottom: 8px; + border-bottom: 1px solid var(--border); } -.ticks { - position: relative; - width: 100%; - - &::before, - &::after { - content: ''; - position: absolute; - top: -4.5px; - border: 5px solid transparent; - } - - &::before { - left: 0; - border-left-color: var(--border); - } - &::after { - right: 0; - border-right-color: var(--border); - } -} +.results-bar__views { + display: flex; + gap: 16px; + font-family: var(--mono); + font-size: 14px; +} + +/* Antall treff under overskriften, i samme monospace som menyen */ +.results-count { + min-height: 1.5em; /* holder plassen, så lista ikke hopper når teksten kommer */ + margin: 8px 0 0; + 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 f657f66c..bd67ad93 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,27 +1,51 @@ import { useState } from 'react'; +import { useQuery } from '@apollo/client/react'; import { GameList } from './components/GameList/GameList'; -import { mockGames } from './mocks/games'; -import { SearchBar } from './components/SearchBar/SearchBar'; -import { useDebounce } from './hooks/useDebounce'; -import { Logo} from './components/Logo/Logo'; +import { Header } from './components/Header/Header'; +import { GAMES } from './graphql/games'; +import type { Suggestion } from './graphql/searchSuggestions'; +import type { GameSummary, GameView } from './types/game'; +import './App.css'; /** - * Midlertidig forside: viser spillene med mockdata til GraphQL er klart. + * Forsiden: søk, resultatliste fra databasen og favoritter. * 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' | 'grid'>('list'); + const [view, setView] = useState('list'); + const [showFavorites, setShowFavorites] = useState(false); - // Det brukeren skriver nå, og det vi faktisk søker på etter 300 ms pause - const [input, setInput] = useState(''); - const query = useDebounce(input.trim(), 300); + // 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(''); - // MIDLERTIDIG: filtrerer mockdata på klienten for å kunne teste søket. - // Når GraphQL er klart, sendes `query` til backend og filtreres i databasen. - const games = mockGames.filter((g) => - g.name.toLowerCase().includes(query.toLowerCase()), - ); + // 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); + + // 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 }, + }); + const result = data ?? previousData; // forrige liste vises mens den nye lastes + + // Backend gir sjangre som objekter ({ name }), spillkortet forventer bare navnene + const allGames: GameSummary[] = + result?.games.edges.map(({ node }) => ({ + ...node, + genres: node.genres.map((g) => g.name), + })) ?? []; + + // MIDLERTIDIG: favoritter filtreres blant spillene som er lastet inn. + // Når favoritter lagres i databasen, bør de hentes derfra i stedet. + const games = showFavorites ? allGames.filter((g) => favorites.has(g.id)) : allGames; + + // 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`; const toggleFavorite = (id: string) => { setFavorites((prev) => { @@ -33,29 +57,68 @@ function App() { }; return ( -
- {/* NY: logoen øverst til høyre */} - -

Søkeresultater

- - {/* Bytter mellom visning 1 (liste) og visning 2 (rutenett) */} -
- - -
- - +
setShowFavorites((v) => !v)} /> -
+ +
+
+

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

+ +
+ + + + +
+
+ +

+ {status} +

+ {error &&

Kunne ikke hente spill: {error.message}

} + + +
+ ); } -export default App; +export default App; \ No newline at end of file diff --git a/frontend/src/apollo.ts b/frontend/src/apollo.ts new file mode 100644 index 00000000..a4532adb --- /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(), +}); diff --git a/frontend/src/assets/drawings/tegning.webp b/frontend/src/assets/drawings/tegning.webp new file mode 100644 index 00000000..1ac44aa3 Binary files /dev/null and b/frontend/src/assets/drawings/tegning.webp differ diff --git a/frontend/src/components/GameCard/GameCard.css b/frontend/src/components/GameCard/GameCard.css index 781fc6f8..fe798b45 100644 --- a/frontend/src/components/GameCard/GameCard.css +++ b/frontend/src/components/GameCard/GameCard.css @@ -169,3 +169,60 @@ margin-top: 6px; } } + + /* ===== Visning 3: utstillingsrad (1 per rad, stort bilde til venstre) ===== */ +.game-card--showcase { + grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr); + gap: 28px; + padding: 28px 0; +} + +.game-card--showcase .game-card__body { + gap: 6px; + padding-right: 0; /* knappen ligger nederst her, ikke ved tittelen */ +} + +.game-card--showcase .game-card__title { + font-size: 26px; +} + +/* Store tall med små etiketter over, som i trykk */ +.game-card--showcase .game-card__scores { + gap: 32px; + margin: 16px 0; +} + +.game-card--showcase .game-card__scores div { + flex-direction: column; + gap: 2px; +} + +.game-card--showcase .game-card__scores dt { + font-family: var(--mono); + font-size: 12px; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.game-card--showcase .game-card__scores dd { + font-size: 28px; + line-height: 1.1; +} + +/* Favoritt-knappen nederst i tekstkolonnen i stedet for oppe i hjørnet */ +.game-card--showcase .game-card__favorite { + position: relative; + top: auto; + right: auto; + align-self: flex-start; + margin-top: auto; +} + +/* Smal skjerm: bildet over teksten */ +@media (max-width: 640px) { + .game-card--showcase { + grid-template-columns: 1fr; + gap: 14px; + } +} + diff --git a/frontend/src/components/GameCard/GameCard.tsx b/frontend/src/components/GameCard/GameCard.tsx index 690148d2..c9216450 100644 --- a/frontend/src/components/GameCard/GameCard.tsx +++ b/frontend/src/components/GameCard/GameCard.tsx @@ -1,4 +1,4 @@ -import type { GameSummary } from '../../types/game'; +import type { GameSummary, GameView } from '../../types/game'; import './GameCard.css'; interface GameCardProps { @@ -6,16 +6,17 @@ interface GameCardProps { isFavorite: boolean; onToggleFavorite: (id: string) => void; /** 'list' = visning 1 (rad), 'grid' = visning 2 (oppsummert rute) */ - view?: 'list' | 'grid'; + view?: GameView; } //Viser ett spill i resultatlista. Lenken i tittelen dekker hele kortet (via CSS), så hele kortet er klikkbart, men skjermlesere hører bare én lenke per kort. export function GameCard({ game, isFavorite, onToggleFavorite, view = 'list' }: GameCardProps) { const year = game.released?.slice(0, 4) ?? null; - const meta = [year, game.genres.slice(0, view === 'grid' ? 1 : 2).join(', ')] - .filter(Boolean) - .join(' · '); + // Rutenett: 1 sjanger, liste: 2, utstillingsrad: alle (undefined = ingen grense) + const maxGenres = view === 'grid' ? 1 : view === 'list' ? 2 : undefined; + const meta = [year, game.genres.slice(0, maxGenres).join(', ')].filter(Boolean).join(' · '); + return (
@@ -47,7 +48,7 @@ export function GameCard({ game, isFavorite, onToggleFavorite, view = 'list' }:
Rating
{game.rating != null ? `${game.rating.toFixed(1)} / 5` : 'Ingen'}
- {view === 'list' && ( + {view !== 'grid' && (
Metacritic
{game.metacritic ?? 'Ingen'}
diff --git a/frontend/src/components/GameList/GameList.tsx b/frontend/src/components/GameList/GameList.tsx index 2cc8e6ff..ef6b9449 100644 --- a/frontend/src/components/GameList/GameList.tsx +++ b/frontend/src/components/GameList/GameList.tsx @@ -1,4 +1,4 @@ -import type { GameSummary } from '../../types/game'; +import type { GameSummary, GameView } from '../../types/game'; import { GameCard } from '../GameCard/GameCard'; import './GameList.css'; @@ -6,7 +6,7 @@ interface GameListProps { games: GameSummary[]; favorites: Set; onToggleFavorite: (id: string) => void; - view: 'list' | 'grid'; + view: GameView; } /** diff --git a/frontend/src/components/GameSearch/GameSearch.css b/frontend/src/components/GameSearch/GameSearch.css new file mode 100644 index 00000000..f66f023d --- /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; +} diff --git a/frontend/src/components/GameSearch/GameSearch.tsx b/frontend/src/components/GameSearch/GameSearch.tsx new file mode 100644 index 00000000..d753ebf9 --- /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'} +

+ )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/Header/Header.css b/frontend/src/components/Header/Header.css new file mode 100644 index 00000000..b686e9e7 --- /dev/null +++ b/frontend/src/components/Header/Header.css @@ -0,0 +1,33 @@ +.header { + display: flex; + align-items: center; + gap: 24px; + padding: 16px 24px; + border-bottom: 1px solid var(--border); +} + +.header__search { + flex: 1; +} + +/* Menyen til høyre, i monospace som på clemenbe.no */ +.header__nav { + display: flex; + gap: 20px; + margin-left: auto; + font-family: var(--mono); + font-size: 15px; + white-space: nowrap; +} + +/* Mobil: logo og meny på første linje, søket på hele andre linje */ +@media (max-width: 600px) { + .header { + flex-wrap: wrap; + gap: 12px 20px; + } + .header__search { + order: 3; + flex-basis: 100%; + } +} diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx new file mode 100644 index 00000000..ad0f70bb --- /dev/null +++ b/frontend/src/components/Header/Header.tsx @@ -0,0 +1,45 @@ +import { Logo } from '../Logo/Logo'; +import { GameSearch } from '../GameSearch/GameSearch'; +import type { Suggestion } from '../../graphql/searchSuggestions'; +import { ThemeToggle } from '../ThemeToggle/ThemeToggle'; +import './Header.css'; + +interface HeaderProps { + onSearch: (term: string) => void; + onSelectGame: (game: Suggestion) => void; + favoritesCount: number; + showFavorites: boolean; + onToggleFavorites: () => void; +} + +/** + * Toppfeltet: logo, søk og meny (favoritter og tema), med søket øverst som på RAWG. + * Ingen bokser, bare tekst og en tynn linje under. + */ +export function Header({ + onSearch, + onSelectGame, + favoritesCount, + showFavorites, + onToggleFavorites, +}: HeaderProps) { + return ( +
+ +
+ +
+ +
+ ); +} diff --git a/frontend/src/components/Logo/Logo.css b/frontend/src/components/Logo/Logo.css index a91c291b..008f1125 100644 --- a/frontend/src/components/Logo/Logo.css +++ b/frontend/src/components/Logo/Logo.css @@ -1,37 +1,38 @@ -/* Smale skjermer: øverst til venstre, scroller vekk så den ikke dekker innholdet */ +/* Smale skjermer: første element i toppfeltet (Header) */ .logo { - position: absolute; - top: 20px; - left: 20px; - z-index: 10; + flex-shrink: 0; } .logo img { display: block; - height: 88px; + height: 44px; width: auto; } -@media (max-width: 600px) { - .logo img { - height: 56px; /* Mindre logo på mobil */ - } +/* Viser riktig logo for valgt tema. display: none skjuler den andre også for skjermlesere */ +.logo .logo__img--dark { + display: none; +} +:root[data-theme='dark'] .logo .logo__img--light { + display: none; +} +:root[data-theme='dark'] .logo .logo__img--dark { + display: block; } /* Brede skjermer: fast og midt i sidepanelet til venstre. Må stå sist i filen */ @media (min-width: 1320px) { .logo { position: fixed; + z-index: 10; top: 20px; - /* Midten av sidepanelet. 1126px er bredden på #root i index.css; endres den, må den endres her også */ + /* Midten av sidepanelet. 1126px er bredden på #root i index.css */ left: calc((100vw - 1126px) / 4); - /* Flytter logoen halvparten av sin egen bredde til venstre, så midten av logoen havner midt i panelet */ transform: translateX(-50%); } .logo img { height: auto; - /* Større: opptil 160px bred, men aldri bredere enn panelet minus 24px luft på hver side */ width: min(90px, calc((100vw - 1126px) / 2 - 48px)); } -} \ No newline at end of file +} diff --git a/frontend/src/components/Logo/Logo.tsx b/frontend/src/components/Logo/Logo.tsx index 24eb3dcd..baa3d283 100644 --- a/frontend/src/components/Logo/Logo.tsx +++ b/frontend/src/components/Logo/Logo.tsx @@ -2,12 +2,12 @@ import logoOnDark from '../../assets/logo/damp-logo-stacked-on-dark.svg'; import logoOnLight from '../../assets/logo/damp-logo-stacked-on-light.svg'; import './Logo.css'; -/** Damp-logoen (symbol med navnet under). Bytter versjon etter lyst/mørkt tema. */ +/** Damp-logoen (symbol med navnet under). CSS viser riktig versjon for valgt tema. */ export function Logo() { return ( - - - Damp - +
+ Damp + Damp +
); } diff --git a/frontend/src/components/SearchBar/SearchBar.css b/frontend/src/components/SearchBar/SearchBar.css index 949cd4b3..231755bf 100644 --- a/frontend/src/components/SearchBar/SearchBar.css +++ b/frontend/src/components/SearchBar/SearchBar.css @@ -11,12 +11,27 @@ border: 0; } +/* Bare en linje under, som et skjemafelt på papir */ .search-bar__input { width: 100%; - padding: 0.6rem 0.8rem; + padding: 0.4rem 0; font: inherit; - border: 1px solid #767676; - border-radius: 0; + color: var(--text-h); + background: transparent; + border: 0; + border-bottom: 1px solid var(--text); + border-radius: 0; -webkit-appearance: textfield; appearance: textfield; } + +.search-bar__input::placeholder { + color: var(--text); +} + +/* Fokus: linjen blir mørkere og dobbelt så tykk, uten at noe flytter seg */ +.search-bar__input:focus-visible { + outline: none; + border-bottom-color: var(--text-h); + box-shadow: 0 1px 0 var(--text-h); +} diff --git a/frontend/src/components/ThemeToggle/ThemeToggle.tsx b/frontend/src/components/ThemeToggle/ThemeToggle.tsx new file mode 100644 index 00000000..b26ba3ad --- /dev/null +++ b/frontend/src/components/ThemeToggle/ThemeToggle.tsx @@ -0,0 +1,32 @@ +import { useEffect, useState } from 'react'; + +type Theme = 'light' | 'dark'; + +/** + * Knapp som bytter mellom lyst og mørkt tema. Første gang brukes temaet scriptet + * i index.html har satt (systemets innstilling), deretter huskes valget i localStorage. + */ +export function ThemeToggle() { + const [theme, setTheme] = useState(() => + document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light', + ); + + // Oppdaterer (som CSS-en leser) og husker valget + useEffect(() => { + document.documentElement.dataset.theme = theme; + try { + localStorage.setItem('theme', theme); + } catch { + // localStorage kan være blokkert (f.eks. privat modus), temaet virker likevel + } + }, [theme]); + + const next = theme === 'dark' ? 'light' : 'dark'; + + return ( + // Teksten sier hva knappen gjør, så skjermlesere og seende får samme informasjon + + ); +} diff --git a/frontend/src/graphql/games.ts b/frontend/src/graphql/games.ts new file mode 100644 index 00000000..dc8f8e1e --- /dev/null +++ b/frontend/src/graphql/games.ts @@ -0,0 +1,49 @@ +import { gql, type TypedDocumentNode } from '@apollo/client'; + +/** Ett spill slik backend sender det til resultatlista */ +interface GameNode { + id: string; + name: string; + released: string | null; + backgroundImage: string | null; + rating: number | null; + metacritic: number | null; + genres: { name: string }[]; +} + +interface GamesData { + games: { + totalCount: number; + edges: { node: GameNode }[]; + }; +} + +interface GamesVars { + search: string | null; + first: number; +} + +/** + * Henter én side med spill til hovedlista. Uten søk gir backend de mest populære. + * 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) { + totalCount + edges { + node { + id + name + released + backgroundImage + rating + metacritic + genres { + name + } + } + } + } + } +`; \ No newline at end of file diff --git a/frontend/src/graphql/searchSuggestions.ts b/frontend/src/graphql/searchSuggestions.ts new file mode 100644 index 00000000..bd52ffb6 --- /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 + } + } + } + } +`; diff --git a/frontend/src/index.css b/frontend/src/index.css index f12902e2..26d5f072 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,14 +1,15 @@ :root { - --text: #6b6375; - --text-h: #08060d; - --bg: #fff; - --border: #e5e4e7; - --code-bg: #f4f3ec; - --accent: #aa3bff; - --accent-bg: rgba(170, 59, 255, 0.1); - --accent-border: rgba(170, 59, 255, 0.5); - --social-bg: rgba(244, 243, 236, 0.5); - --shadow: rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; + /* Lyst tema: varmt papirhvitt */ + --text: #5c554c; + --text-h: #1f1b16; + --bg: #f8f5ee; + --border: #e3ddd1; + --code-bg: #efeae0; + --accent: #2c6370; /* fra Damp-logoen */ + --accent-bg: rgba(44, 99, 112, 0.1); + --accent-border: rgba(44, 99, 112, 0.5); + --social-bg: rgba(239, 234, 224, 0.5); + --shadow: rgba(31, 27, 22, 0.1) 0 10px 15px -3px, rgba(31, 27, 22, 0.05) 0 4px 6px -2px; --sans: system-ui, 'Segoe UI', Roboto, sans-serif; --heading: system-ui, 'Segoe UI', Roboto, sans-serif; @@ -16,7 +17,7 @@ font: 18px/145% var(--sans); letter-spacing: 0.18px; - color-scheme: light dark; + color-scheme: light; color: var(--text); background: var(--bg); font-synthesis: none; @@ -29,23 +30,20 @@ } } -@media (prefers-color-scheme: dark) { - :root { - --text: #9ca3af; - --text-h: #f3f4f6; - --bg: #16171d; - --border: #2e303a; - --code-bg: #1f2028; - --accent: #c084fc; - --accent-bg: rgba(192, 132, 252, 0.15); - --accent-border: rgba(192, 132, 252, 0.5); - --social-bg: rgba(47, 48, 58, 0.5); - --shadow: rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px; - } - - #social .button-icon { - filter: invert(1) brightness(2); - } +/* Mørkt tema. data-theme settes på av scriptet i index.html og av ThemeToggle */ +:root[data-theme='dark'] { + color-scheme: dark; + /* Nøytral mørk med et lite grønt stikk */ + --text: #a9aeab; + --text-h: #e8ebe9; + --bg: #191b1a; + --border: #2f3331; + --code-bg: #232625; + --accent: #6fa3ae; /* fra Damp-logoen (mørk versjon) */ + --accent-bg: rgba(111, 163, 174, 0.15); + --accent-border: rgba(111, 163, 174, 0.5); + --social-bg: rgba(35, 38, 37, 0.5); + --shadow: rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px; } #root { @@ -107,3 +105,67 @@ code { padding: 4px 8px; 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; + font: inherit; + color: var(--text); + background: none; + border: 0; + cursor: pointer; + text-underline-offset: 4px; +} +.text-button:hover, +.text-button[aria-pressed='true'] { + color: var(--text-h); + text-decoration: underline; +} +.text-button:focus-visible { + outline: 2px solid var(--text-h); + outline-offset: 4px; +} + +/* Seksjonsoverskrift i trykk-stil: små, sperrede versaler (som WRITING på clemenbe.no) */ +.label { + margin: 0; + font-family: var(--mono); + font-size: 13px; + font-weight: 500; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--text-h); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 2239905c..d97a9d9c 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( - + + + , ); diff --git a/frontend/src/types/game.ts b/frontend/src/types/game.ts index 0829d119..62107b8d 100644 --- a/frontend/src/types/game.ts +++ b/frontend/src/types/game.ts @@ -11,3 +11,6 @@ export interface GameSummary { metacritic: number | null; genres: string[]; } + +/** 'list' = visning 1 (rad), 'grid' = visning 2 (rute), 'showcase' = visning 3 (utstillingsrad) */ +export type GameView = 'list' | 'grid' | 'showcase'; diff --git a/scripts/db-tunnel.sh b/scripts/db-tunnel.sh index 908c0089..9589d04a 100755 --- a/scripts/db-tunnel.sh +++ b/scripts/db-tunnel.sh @@ -3,7 +3,7 @@ set -euo pipefail cd "$(dirname "$0")/.." set -a -source db/.env +source backend/.env set +a if lsof -i ":$TUNNEL_PORT" >/dev/null 2>&1; then