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
3 changes: 2 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
"@apollo/client": "^4.3.1",
"graphql": "^17.0.2",
"react": "^19.2.8",
"react-dom": "^19.2.8"
"react-dom": "^19.2.8",
"react-router": "^8.4.0"
},
"devDependencies": {
"@babel/core": "^7.29.7",
Expand Down
172 changes: 106 additions & 66 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { useState } from 'react';
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router';
import { useQuery } from '@apollo/client/react';
import { GameList } from './components/GameList/GameList';
import { Header } from './components/Header/Header';
import { GameDetail } from './pages/GameDetail/GameDetail';
import { GAMES } from './graphql/games';
import type { Suggestion } from './graphql/searchSuggestions';
import type { GameSummary, GameView } from './types/game';
Expand All @@ -13,13 +15,17 @@ import { useDebounce } from './hooks/useDebounce';
import { defaultFilters, toFilterInput, type GameFilters } from './types/filters';

/**
* Forsiden: søk, resultatliste fra databasen og favoritter.
* Rammen rundt hele appen: toppfeltet og sidene (forsiden med filtre og resultatliste,
* og detaljsiden for ett spill). Søk, filtre, visning og favoritter ligger her, så de
* huskes når man går fra detaljsiden tilbake til lista.
* Favoritter og valgt visning ligger bare i minnet her og skal senere inn i state management.
*/
function App() {
const [favorites, setFavorites] = useState<Set<string>>(new Set());
const [view, setView] = useState<GameView>('list');
const [showFavorites, setShowFavorites] = useState(false);
const navigate = useNavigate();
const onHome = useLocation().pathname === '/';

// 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
Expand All @@ -28,17 +34,33 @@ function App() {
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);
// Valgene i dropdownene (sjangre og plattformer) fra databasen.
// Filtrene vises bare på forsiden, så de hentes ikke på detaljsiden.
const { data: options } = useQuery(FILTER_OPTIONS, { skip: !onHome });

// 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);
// Valgt forslag i søkefeltet åpner detaljsiden til spillet
const handleSelectGame = (game: Suggestion) => navigate(`/game/${game.id}`);

// Søkeresultater og favoritter vises på forsiden, så fra detaljsiden går vi dit
const handleSearch = (term: string) => {
setQuery(term);
if (!onHome) navigate('/');
};
const handleToggleFavorites = () => {
if (onHome) {
setShowFavorites((v) => !v);
} else {
setShowFavorites(true);
navigate('/');
}
};

// 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.
// Tomt søk (null) gir de mest populære spillene. Hoppes over på detaljsiden, der lista
// ikke vises. Tilbake på forsiden kommer lista fra Apollo-cachen uten ny forespørsel.
const { data, previousData, loading, error } = useQuery(GAMES, {
variables: { search: query || null, filter: toFilterInput(debouncedFilters), first: 20 },
skip: !onHome,
});
const result = data ?? previousData; // forrige liste vises mens den nye lastes

Expand Down Expand Up @@ -71,71 +93,89 @@ function App() {
return (
<>
<Header
onSearch={setQuery}
onSearch={handleSearch}
onSelectGame={handleSelectGame}
favoritesCount={favorites.size}
showFavorites={showFavorites}
onToggleFavorites={() => setShowFavorites((v) => !v)}
showFavorites={showFavorites && onHome}
onToggleFavorites={handleToggleFavorites}
/>

<FilterSidebar
filters={filters}
onChange={setFilters}
genres={options?.genres ?? []}
platforms={options?.platforms ?? []}
/>
<Routes>
<Route
path="/"
element={
<>
<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>

<div role="group" aria-label="Visning" className="results-bar__views">
<span aria-hidden="true">visning</span>
<button
type="button"
className="text-button"
aria-pressed={view === 'list'}
aria-label="Visning 1: liste"
title="Liste"
onClick={() => setView('list')}
>
1
</button>
<button
type="button"
className="text-button"
aria-pressed={view === 'grid'}
aria-label="Visning 2: rutenett"
title="Rutenett"
onClick={() => setView('grid')}
>
2
</button>
<button
type="button"
className="text-button"
aria-pressed={view === 'showcase'}
aria-label="Visning 3: utstilling"
title="Utstilling"
onClick={() => setView('showcase')}
>
3
</button>
</div>
</div>

<p role="status" className="results-count">
{status}
</p>
{error && <p role="alert">Kunne ikke hente spill: {error.message}</p>}

<GameList
games={games}
view={view}
favorites={favorites}
onToggleFavorite={toggleFavorite}
/>
</main>
</>
}
/>

<main className="main">
<div className="results-bar">
<h2 className="label">{showFavorites ? 'Favoritter' : 'Søkeresultater'}</h2>

<div role="group" aria-label="Visning" className="results-bar__views">
<span aria-hidden="true">visning</span>
<button
type="button"
className="text-button"
aria-pressed={view === 'list'}
aria-label="Visning 1: liste"
title="Liste"
onClick={() => setView('list')}
>
1
</button>
<button
type="button"
className="text-button"
aria-pressed={view === 'grid'}
aria-label="Visning 2: rutenett"
title="Rutenett"
onClick={() => setView('grid')}
>
2
</button>
<button
type="button"
className="text-button"
aria-pressed={view === 'showcase'}
aria-label="Visning 3: utstilling"
title="Utstilling"
onClick={() => setView('showcase')}
>
3
</button>
</div>
</div>

<p role="status" className="results-count">
{status}
</p>
{error && <p role="alert">Kunne ikke hente spill: {error.message}</p>}

<GameList
games={games}
view={view}
favorites={favorites}
onToggleFavorite={toggleFavorite}
{/* Egen side for hvert spill, f.eks. /project2/game/3498 */}
<Route
path="/game/:id"
element={<GameDetail favorites={favorites} onToggleFavorite={toggleFavorite} />}
/>
</main>

{/* Ukjente adresser sendes til forsiden */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</>
);
}
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/components/GameCard/GameCard.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Link } from 'react-router';
import type { GameSummary, GameView } from '../../types/game';
import './GameCard.css';

Expand Down Expand Up @@ -37,7 +38,8 @@ export function GameCard({ game, isFavorite, onToggleFavorite, view = 'list' }:

<div className="game-card__body">
<h3 className="game-card__title">
<a href={`/game/${game.id}`}>{game.name}</a>
{/* Link i stedet for <a>: bytter side uten å laste hele appen på nytt, og tar med /project2 */}
<Link to={`/game/${game.id}`}>{game.name}</Link>
</h3>

{meta && <p className="game-card__meta">{meta}</p>}
Expand Down
70 changes: 70 additions & 0 deletions frontend/src/graphql/game.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { gql, type TypedDocumentNode } from '@apollo/client';

/** Sjanger, plattform, utvikler eller utgiver. Bare navnet trengs på detaljsiden. */
interface FacetName {
name: string;
}

/** Alt detaljsiden viser om ett spill */
export interface GameDetails {
id: string;
name: string;
description: string | null;
released: string | null;
website: string | null;
metacritic: number | null;
rating: number | null;
ratingsCount: number;
playtime: number | null;
esrbRating: string | null;
backgroundImage: string | null;
screenshots: string[];
genres: FacetName[];
platforms: FacetName[];
developers: FacetName[];
publishers: FacetName[];
}

interface GameData {
game: GameDetails | null;
}

interface GameVars {
id: string;
}

/**
* Henter ett spill til detaljsiden. Backend gir null hvis id-en ikke finnes.
* Apollo cacher svaret på id, så å åpne samme spill igjen gir ingen ny forespørsel.
* Sjangre, plattformer osv. hentes uten id, siden siden bare viser navnene.
*/
export const GAME: TypedDocumentNode<GameData, GameVars> = gql`
query Game($id: ID!) {
game(id: $id) {
id
name
description
released
website
metacritic
rating
ratingsCount
playtime
esrbRating
backgroundImage
screenshots
genres {
name
}
platforms {
name
}
developers {
name
}
publishers {
name
}
}
}
`;
13 changes: 10 additions & 3 deletions frontend/src/main.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router';
import { ApolloProvider } from '@apollo/client/react';
import { client } from './apollo';
import './index.css';
import App from './App.tsx';

// Appen ligger under /project2/ både lokalt og på VM-en (se vite.config.ts), så alle
// ruter og lenker regnes fra der. Skråstreken på slutten fjernes, så /project2 også matcher.
const basename = import.meta.env.BASE_URL.replace(/\/$/, '');

createRoot(document.getElementById('root')!).render(
<StrictMode>
<ApolloProvider client={client}>
<App />
</ApolloProvider>
<BrowserRouter basename={basename}>
<ApolloProvider client={client}>
<App />
</ApolloProvider>
</BrowserRouter>
</StrictMode>,
);
Loading