-
Utvalgt fra topplisten
+
+ {rating != null ? 'Utvalgt fra topplisten' : 'Utvalgt film'}
+
{movie.title}
- {year}
- {genreNames.join(' / ')}
+ {movie.releaseYear && {movie.releaseYear}}
+ {movie.genres.map(g => g.name).join(' / ')}
-
- {DICE[rounded - 1]}
-
+ {rating != null && (
+
+ {DICE[Math.round(rating) - 1]}
+
+ )}
-
Terningkast {movie.average_rating.toFixed(1)}
-
Basert på {movie.review_count} anmeldelser
+
+ {rating != null ? `Terningkast ${rating.toFixed(1)}` : 'Ingen anmeldelser ennå'}
+
+
+ {rating != null
+ ? `Basert på ${movie.reviewCount} anmeldelser`
+ : 'Bli den første som gir terningkast!'}
+
-
- «{review.text}»
-
-
-
{/* Høyre: plakat */}
-

+ {movie.posterPath && (
+

+ )}
);
}
diff --git a/FrameRate/src/components/search/MovieList.tsx b/FrameRate/src/components/search/MovieList.tsx
new file mode 100644
index 0000000..c31d067
--- /dev/null
+++ b/FrameRate/src/components/search/MovieList.tsx
@@ -0,0 +1,44 @@
+import { posterUrl, type MovieSummary } from '../../graphql/movies';
+
+interface MovieListProps {
+ movies: MovieSummary[];
+ /** true mens nye resultater lastes, så lista kan tones ned. */
+ busy?: boolean;
+}
+
+/** Liste med filmkort: plakat, tittel, år, sjangre og terningkast. */
+export function MovieList({ movies, busy = false }: MovieListProps) {
+ return (
+
+ {movies.map(movie => (
+ -
+
+ {movie.posterPath ? (
+
+ ) : (
+
+ )}
+ {movie.title}
+
+
+ {[movie.releaseYear, movie.genres.map(g => g.name).join(', ')]
+ .filter(Boolean)
+ .join(' · ')}
+
+
+ {movie.averageRating != null
+ ? `Terningkast ${movie.averageRating.toFixed(1)}`
+ : 'Ingen anmeldelser ennå'}
+
+
+ ))}
+
+ );
+}
diff --git a/FrameRate/src/components/search/MovieSearch.tsx b/FrameRate/src/components/search/MovieSearch.tsx
new file mode 100644
index 0000000..dcf5f5b
--- /dev/null
+++ b/FrameRate/src/components/search/MovieSearch.tsx
@@ -0,0 +1,62 @@
+import { useId, useState } from 'react';
+import { useQuery } from '@apollo/client/react';
+import { SEARCH_MOVIES } from '../../graphql/movies';
+import { useDebouncedValue } from '../../hooks/useDebouncedValue';
+import { MovieList } from './MovieList';
+
+const PAGE_SIZE = 20;
+
+/** Søkefelt med resultatliste. Søket går mot vår egen database via GraphQL. */
+export function MovieSearch() {
+ const inputId = useId();
+ const [input, setInput] = useState('');
+ // Venter 300 ms etter siste tastetrykk før vi søker, så vi ikke sender en forespørsel per bokstav.
+ const search = useDebouncedValue(input.trim(), 300);
+
+ const { data, previousData, loading, error } = useQuery(SEARCH_MOVIES, {
+ variables: { search, limit: PAGE_SIZE, offset: 0 },
+ skip: search === '',
+ });
+
+ // Mens et nytt søk lastes, vises forrige resultat i stedet for at lista blinker tom.
+ const result = (data ?? previousData)?.searchMovies;
+
+ return (
+
+ );
+}
diff --git a/FrameRate/src/data/mockData.ts b/FrameRate/src/data/mockData.ts
deleted file mode 100644
index faf7b09..0000000
--- a/FrameRate/src/data/mockData.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-// Midlertidige data i samme format som TMDB.
-// Byttes ut med data fra vår egen GraphQL-backend senere.
-
-// Sjangernavn for genre_ids. Hentes fra TMDB /genre/movie/list ved import.
-export const genres: Record
= {
- 28: 'Action',
- 18: 'Drama',
- 80: 'Krim',
- 9648: 'Mysterium',
- 53: 'Thriller',
-};
-
-export const featuredMovie = {
- // --- Fra TMDB ---
- id: 343611,
- title: 'Jack Reacher: Never Go Back',
- original_title: 'Jack Reacher: Never Go Back',
- original_language: 'en',
- overview:
- 'Jack Reacher must uncover the truth behind a major government conspiracy in order to clear his name. On the run as a fugitive from the law, Reacher uncovers a potential secret from his past that could change his life forever.',
- release_date: '2016-10-19',
- genre_ids: [53, 28, 80, 18, 9648],
- poster_path: '/IfB9hy4JH1eH6HEfIgIGORXi5h.jpg',
- backdrop_path: '/4ynQYtSEuU5hyipcGkfD6ncwtwz.jpg',
- popularity: 26.818468,
- vote_average: 4.19,
- vote_count: 201,
- adult: false,
- video: false,
-
- // --- Fra /movie/{id} ved import ---
- runtime: 118,
- director: 'Edward Zwick',
-
- // --- Fra våre egne anmeldelser ---
- average_rating: 4.3,
- review_count: 12,
-};
-
-export const featuredReview = {
- text: 'Solid action, men historien er mer forutsigbar enn den første filmen.',
- author: 'henrik',
- date: '24. september 2026',
-};
diff --git a/FrameRate/src/graphql/movies.ts b/FrameRate/src/graphql/movies.ts
new file mode 100644
index 0000000..d697807
--- /dev/null
+++ b/FrameRate/src/graphql/movies.ts
@@ -0,0 +1,87 @@
+import { gql } from '@apollo/client';
+import type { TypedDocumentNode } from '@apollo/client';
+
+// GraphQL-spørringer for filmer, med TypeScript-typer for svarene.
+// Feltene må stemme med backend/src/schema.ts.
+
+export type MovieSort =
+ 'POPULARITY_DESC' | 'RATING_DESC' | 'RELEASE_DESC' | 'RELEASE_ASC' | 'TITLE_ASC';
+
+/** Feltene vi trenger for å vise en film i en liste eller på forsiden. */
+export interface MovieSummary {
+ id: number;
+ title: string;
+ releaseYear: number | null;
+ posterPath: string | null;
+ averageRating: number | null;
+ reviewCount: number;
+ genres: { id: number; name: string }[];
+}
+
+export interface SearchMoviesData {
+ searchMovies: {
+ totalCount: number;
+ movies: MovieSummary[];
+ };
+}
+
+export interface SearchMoviesVariables {
+ search?: string;
+ genreIds?: number[];
+ yearFrom?: number;
+ yearTo?: number;
+ minRating?: number;
+ sort?: MovieSort;
+ limit?: number;
+ offset?: number;
+}
+
+const MOVIE_SUMMARY_FIELDS = gql`
+ fragment MovieSummaryFields on Movie {
+ id
+ title
+ releaseYear
+ posterPath
+ averageRating
+ reviewCount
+ genres {
+ id
+ name
+ }
+ }
+`;
+
+export const SEARCH_MOVIES: TypedDocumentNode = gql`
+ ${MOVIE_SUMMARY_FIELDS}
+ query SearchMovies(
+ $search: String
+ $genreIds: [Int!]
+ $yearFrom: Int
+ $yearTo: Int
+ $minRating: Float
+ $sort: MovieSort
+ $limit: Int
+ $offset: Int
+ ) {
+ searchMovies(
+ search: $search
+ genreIds: $genreIds
+ yearFrom: $yearFrom
+ yearTo: $yearTo
+ minRating: $minRating
+ sort: $sort
+ limit: $limit
+ offset: $offset
+ ) {
+ totalCount
+ movies {
+ ...MovieSummaryFields
+ }
+ }
+ }
+`;
+
+/** Plakat-URL hos TMDB. Bruk minste størrelse som ser bra ut, det sparer data (bærekraft). */
+export function posterUrl(posterPath: string, size: 'w92' | 'w185' | 'w342' | 'w500') {
+ return `https://image.tmdb.org/t/p/${size}${posterPath}`;
+}
diff --git a/FrameRate/src/hooks/useDebouncedValue.ts b/FrameRate/src/hooks/useDebouncedValue.ts
new file mode 100644
index 0000000..b487f1d
--- /dev/null
+++ b/FrameRate/src/hooks/useDebouncedValue.ts
@@ -0,0 +1,17 @@
+import { useEffect, useState } from 'react';
+
+/**
+ * Gir tilbake `value`, men først når den har stått uendret i `delayMs` millisekunder.
+ * Brukes i søket så vi ikke sender en forespørsel for hvert tastetrykk.
+ */
+export function useDebouncedValue(value: T, delayMs = 300): T {
+ const [debounced, setDebounced] = useState(value);
+
+ useEffect(() => {
+ const timer = setTimeout(() => setDebounced(value), delayMs);
+ // Skriver brukeren igjen før tiden er ute, avbrytes den forrige timeren.
+ return () => clearTimeout(timer);
+ }, [value, delayMs]);
+
+ return debounced;
+}
diff --git a/FrameRate/src/index.css b/FrameRate/src/index.css
index d242f05..1add0a5 100644
--- a/FrameRate/src/index.css
+++ b/FrameRate/src/index.css
@@ -373,6 +373,88 @@ body {
background: rgba(242, 182, 48, 0.1);
}
+/* ---------- Søk ---------- */
+.search {
+ max-width: 75rem;
+ padding: 2rem 1.5rem 4rem;
+}
+
+.search-heading {
+ margin: 0 0 1rem;
+ font-family: Georgia, serif;
+ font-size: 2rem;
+}
+
+.search-input {
+ box-sizing: border-box;
+ width: 100%;
+ max-width: 36rem;
+ padding: 0.8rem 1rem;
+ border: 1px solid #3a3e48;
+ border-radius: 8px;
+ background: #15171c;
+ color: white;
+ font: inherit;
+ font-size: 1.1rem;
+}
+
+.search-input::placeholder {
+ color: #8a8f99;
+}
+
+.search-status {
+ min-height: 1.5em; /* holder plassen, så lista ikke hopper når teksten dukker opp */
+ color: #a0a4ad;
+}
+
+/* ---------- Filmkort ---------- */
+.movie-list {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
+ gap: 1.5rem;
+ margin: 1rem 0 0;
+ padding: 0;
+ list-style: none;
+ transition: opacity 0.2s;
+}
+
+.movie-list[aria-busy='true'] {
+ opacity: 0.6;
+}
+
+.movie-card-link {
+ display: block;
+ color: white;
+ text-decoration: none;
+}
+
+.movie-card-link:hover .movie-card-title {
+ color: #f2b630;
+}
+
+.movie-card-poster {
+ display: block;
+ width: 100%;
+ height: auto;
+ aspect-ratio: 2 / 3;
+ border-radius: 8px;
+ background: #15171c;
+ object-fit: cover;
+}
+
+.movie-card-title {
+ display: block;
+ margin-top: 0.5rem;
+ font-weight: bold;
+}
+
+.movie-card-meta,
+.movie-card-rating {
+ margin: 0.25rem 0 0;
+ color: #a0a4ad;
+ font-size: 0.9rem;
+}
+
/* ---------- Footer ---------- */
.footer {
padding: 2rem 1.5rem;
@@ -437,11 +519,22 @@ body {
/* ---------- Tastaturfokus ---------- */
a:focus-visible,
-button:focus-visible {
+button:focus-visible,
+input:focus-visible {
outline: 3px solid #f2b630;
outline-offset: 3px;
}
+/* Skjult på skjermen, men lest opp av skjermlesere (f.eks. etiketter på skjemafelt). */
+.visually-hidden {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+}
+
/* ---------- Mindre bevegelse ---------- */
@media (prefers-reduced-motion: reduce) {
.reel,
@@ -454,4 +547,8 @@ button:focus-visible {
.header-nav:focus-within .spotlight {
animation: none;
}
+
+ .movie-list {
+ transition: none;
+ }
}
diff --git a/FrameRate/src/main.tsx b/FrameRate/src/main.tsx
index df655ea..e636c4a 100644
--- a/FrameRate/src/main.tsx
+++ b/FrameRate/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/client';
import './index.css';
import App from './App.tsx';
createRoot(document.getElementById('root')!).render(
-
+
+
+
);
diff --git a/backend/src/resolvers.ts b/backend/src/resolvers.ts
index 184058f..d0a4a44 100644
--- a/backend/src/resolvers.ts
+++ b/backend/src/resolvers.ts
@@ -33,7 +33,8 @@ const MAX_LIMIT = 50;
// m.id til slutt gjør rekkefølgen stabil, så "Vis flere" ikke gir duplikater.
const ORDER_BY: Record = {
POPULARITY_DESC: 'm.popularity DESC, m.id',
- RATING_DESC: 'm.average_rating DESC NULLS LAST, m.review_count DESC, m.id',
+ // Likt terningkast (eller ingen anmeldelser ennå): flest anmeldelser, så mest populær.
+ RATING_DESC: 'm.average_rating DESC NULLS LAST, m.review_count DESC, m.popularity DESC, m.id',
RELEASE_DESC: 'm.release_date DESC NULLS LAST, m.id',
RELEASE_ASC: 'm.release_date ASC NULLS LAST, m.id',
TITLE_ASC: 'm.title ASC, m.id',
diff --git a/docs/AI_Deklarasjon/AI_Deklarasjon.md b/docs/AI_Deklarasjon/AI_Deklarasjon.md
index a48d097..25351d4 100644
--- a/docs/AI_Deklarasjon/AI_Deklarasjon.md
+++ b/docs/AI_Deklarasjon/AI_Deklarasjon.md
@@ -62,3 +62,19 @@ Her dokumenterer vi bruk av KI-verktøy i prosjektet, med én del per pull reque
- Bestemt hva guiden skal inneholde og hvem den er for (utviklere, ikke vanlige brukere), og at gruppas nøkkel ikke skal deles
- Testet stegene på macOS
+
+## PR #24 – Apollo Client, søk og ekte film på forsiden (`feat/apollo-search`)
+
+**Verktøy:** Claude Code
+**Brukt av:** Akhil
+
+### Hva KI ble brukt til
+
+- Fikk hjelp til å kode med Claude Code. Den forklarte også koden underveis og en del ting jeg ikke kunne fra før, som Apollo Client, debounce og hvordan man gjør søk tilgjengelig for skjermlesere
+- Brukt til oppsett av Apollo Client (#6), søket (#8) og å koble forsiden til backenden (#7), pluss CSS for søket
+
+### Hva jeg har gjort selv
+
+- Valgte hvilke issues vi skulle ta og i hvilken rekkefølge
+- Gikk gjennom gjøremålene i hver issue og sjekket at alt var med
+- Testet alt i nettleseren