From f38b188b8d250420614f6b25bde54d70c8325743 Mon Sep 17 00:00:00 2001 From: Johannes Bergslid Date: Tue, 10 Nov 2026 14:39:58 +0100 Subject: [PATCH 1/7] Made query to fetch top 5 games with review count as tiebreaker. Also added top 5 reviews in seed script (#88) --- .../backend/src/graphql/resolvers.ts | 21 +++++++- T04-Project-2/backend/src/graphql/schema.ts | 2 + T04-Project-2/backend/src/scripts/seed.ts | 48 +++++++++++++++++-- 3 files changed, 65 insertions(+), 6 deletions(-) diff --git a/T04-Project-2/backend/src/graphql/resolvers.ts b/T04-Project-2/backend/src/graphql/resolvers.ts index e73af77..b35bc83 100644 --- a/T04-Project-2/backend/src/graphql/resolvers.ts +++ b/T04-Project-2/backend/src/graphql/resolvers.ts @@ -14,7 +14,7 @@ import { reviews, favorites, } from "../db/schema"; -import { and, count, asc, desc, gte, ilike, inArray, lte, or, sql, eq } from "drizzle-orm"; +import { and, count, asc, desc, gte, ilike, inArray, lte, or, sql, eq, isNotNull } from "drizzle-orm"; import { GraphQLError } from "graphql"; type GameFilter = { genres?: string[]; @@ -40,6 +40,21 @@ const platformsForGame = (game: { id: number }) => db.select({ .innerJoin(platforms, eq(game_platforms.platform_id, platforms.id)) .where(eq(game_platforms.game_id, game.id)); +export const selectTopGames = (limit = 5) => db + .select({ + id: games.id, + name: games.name, + released: games.released, + background_image: games.background_image, + description: games.description, + rating: games.rating, + ratings_count: games.ratings_count, + }) + .from(games) + .where(isNotNull(games.rating)) + .orderBy(desc(games.rating), sql`${desc(games.ratings_count)} NULLS LAST`, asc(games.id)) + .limit(Math.min(Math.max(limit, 1), 20)); + export const resolvers = { Query: { games: async () => { @@ -252,6 +267,10 @@ export const resolvers = { return game ?? null; }, + topGames: async ( + _parent: unknown, + { limit = 5 }: { limit?: number } + ) => selectTopGames(limit), }, Mutation: { createUser: async ( diff --git a/T04-Project-2/backend/src/graphql/schema.ts b/T04-Project-2/backend/src/graphql/schema.ts index 31c46d5..ea296d7 100644 --- a/T04-Project-2/backend/src/graphql/schema.ts +++ b/T04-Project-2/backend/src/graphql/schema.ts @@ -26,6 +26,7 @@ export const typeDefs = /* GraphQL */ ` background_image: String description: String rating: Float + ratings_count: Int platforms: [Platform!]! } @@ -106,6 +107,7 @@ export const typeDefs = /* GraphQL */ ` gamesByIds(ids: [ID!]!): [GameSummary!]! gamesByRawgIds(ids: [ID!]!): [GameSummary!]! game(id: ID!): Game + topGames(limit: Int = 5): [GameSummary!]! } type Mutation { diff --git a/T04-Project-2/backend/src/scripts/seed.ts b/T04-Project-2/backend/src/scripts/seed.ts index 4f3013f..ae0b215 100644 --- a/T04-Project-2/backend/src/scripts/seed.ts +++ b/T04-Project-2/backend/src/scripts/seed.ts @@ -1,9 +1,10 @@ import { hashPassword } from "../auth/password"; import "dotenv/config"; import process from "node:process"; -import { inArray } from "drizzle-orm"; +import { inArray, sql } from "drizzle-orm"; import { db, pool } from "../db"; import { favorites, games, reviews, users } from "../db/schema"; +import { selectTopGames } from "../graphql/resolvers"; const USER_COUNT = 10; @@ -46,6 +47,23 @@ const reviewTexts = [ "I would happily play this again.", ]; +const topGameReviewTexts = [ + "Absolutely worth the hype. I went in with high expectations and somehow it still managed to surprise me. Easily one of the best games I've played in years.", + "Started it on a Friday evening and suddenly it was Sunday. The pacing is excellent and there's always something pulling you forward.", + "A rare game that gets better the more time you put into it. The first few hours are good, but once everything clicks it becomes something special.", + "Beautiful to look at and even better to play. You can tell a lot of care went into every little detail.", + "I've recommended this to all my friends. It's the kind of game you want to talk about with other people the moment you put the controller down.", + "The atmosphere is incredible. The music, the visuals and the overall feel come together in a way that really sticks with you.", + "Not every moment is perfect, and a couple of sections dragged a little for me. But the highs are so high that it's easy to forgive the occasional slow part.", + "Came back to this after a long break and it holds up remarkably well. Still just as fun as I remembered, maybe even more so.", + "I'm not usually a fan of this kind of game, but this one won me over completely. It's accessible enough for newcomers and deep enough for veterans.", + "Ten out of ten, no notes. Okay, maybe one note: it ended, and I wasn't ready for that.", + "What impressed me most is how confident the whole thing feels. It knows exactly what it wants to be and commits to it fully. The controls are tight, the progression feels rewarding, and there's a constant sense that the developers trusted the player to figure things out. I finished it weeks ago and I'm still thinking about some of the moments.", + "I put off playing this for a long time because I assumed it couldn't live up to the reputation. I was wrong. It's polished, generous with content and has that rare quality where every session feels meaningful, whether you play for twenty minutes or four hours. If you've been on the fence, this is your sign.", + "There's a moment about halfway through where everything you've learned comes together, and it honestly gave me chills. The game does a great job of building on its own ideas without ever feeling repetitive. Some of the side content is just as memorable as the main experience. Highly recommended for anyone who enjoys games that respect your time.", + "Played it with a friend first and then again on my own, and both experiences were great in different ways. There's a lot of replay value here if you like exploring, experimenting and finding things you missed the first time around. My only complaint is that I can't experience it for the first time again.", +]; + function randomItem(items: T[]): T { return items[Math.floor(Math.random() * items.length)]; } @@ -89,6 +107,9 @@ async function seedDatabase() { .select({ id: games.id }) .from(games); + const topGames = await selectTopGames(5); + const topGameIds = new Set(topGames.map((game) => game.id)); + const gameIds = gameRows.map((game) => game.id); await db.transaction(async (tx) => { @@ -106,13 +127,23 @@ async function seedDatabase() { throw new Error("Could not find all seeded users after insertion."); } - const reviewValues = gameIds.flatMap((gameId) => - randomSubset(seedUsers).map((user) => ({ + const topGameReviewValues = topGames.flatMap((game) => + seedUsers.map((user, index) => ({ user_id: user.id, - game_id: gameId, - description: randomItem(reviewTexts), + game_id: game.id, + description: topGameReviewTexts[(index + game.id) % topGameReviewTexts.length] ?? "Great game!", })), ); + + const reviewValues = gameIds + .filter((gameId) => !topGameIds.has(gameId)) + .flatMap((gameId) => + randomSubset(seedUsers).map((user) => ({ + user_id: user.id, + game_id: gameId, + description: randomItem(reviewTexts), + })), + ); const favoriteValues = gameIds.flatMap((gameId) => randomSubset(seedUsers).map((user) => ({ @@ -122,8 +153,15 @@ async function seedDatabase() { ); await tx.insert(reviews).values(reviewValues).onConflictDoNothing(); + + await tx.insert(reviews).values(topGameReviewValues).onConflictDoUpdate({ + target: [reviews.user_id, reviews.game_id], + set: { description: sql`excluded.description` }, + }); + await tx.insert(favorites).values(favoriteValues).onConflictDoNothing(); + console.log(`Seeded ${topGameReviewValues.length} reviews for top ${topGames.length} games.`); console.log(`Seeded ${seedUsers.length} users.`); console.log(`Seeded up to ${reviewValues.length} reviews.`); console.log(`Seeded up to ${favoriteValues.length} favorites.`); From 57d03f20f3b0ebab42ce0f945c99f505cf4c4409 Mon Sep 17 00:00:00 2001 From: Johannes Bergslid Date: Tue, 10 Nov 2026 14:54:02 +0100 Subject: [PATCH 2/7] Added API function and query options for fetching top 5 games (#88) --- T04-Project-2/frontend/src/api/games.ts | 39 +++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/T04-Project-2/frontend/src/api/games.ts b/T04-Project-2/frontend/src/api/games.ts index 173bf2c..85f857b 100644 --- a/T04-Project-2/frontend/src/api/games.ts +++ b/T04-Project-2/frontend/src/api/games.ts @@ -5,6 +5,7 @@ import type { GameSearchOptions } from "./filters"; export type GameSearchResult = Pick; export type GameCardSummary = Pick; export type FeaturedGameSummary = GameCardSummary & Pick & {platforms: Pick[];}; +export type TopGameSummary = FeaturedGameSummary & Pick; export type GameReview = { id: string; @@ -358,6 +359,39 @@ async function fetchFavoriteGameSummaries(ids: readonly string[]): Promise { + + const response = await fetch(import.meta.env.VITE_API_URL, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + query: ` + query TopGames($limit: Int) { + topGames(limit: $limit) { + id name released background_image description rating ratings_count + platforms { id name } + } + } + `, + variables: { limit }, + }), + }); + + if (!response.ok) { + throw new Error(`HTTP error: ${response.status}`); + } + + const result = await response.json(); + + if (result.errors?.length) { + throw new Error(result.errors[0].message); + } + + return result.data.topGames; +} + export function fetchGamesByIds(ids: readonly string[]): Promise { return fetchGameSummariesByIds(ids, "id name released background_image"); } @@ -396,6 +430,11 @@ export const gameQueryOptions = { queryFn: () => fetchFeaturedGamesByIds(ids), staleTime: GAME_QUERY_STALE_TIME, }), + top: (limit = 5) => queryOptions({ + queryKey: ["games", "top", limit] as const, + queryFn: () => fetchTopGames(limit), + staleTime: GAME_QUERY_STALE_TIME, + }), }; export const gameReviewQueryOptions = { From 1742f2ab746a4a771358154ce729cee2ef093060 Mon Sep 17 00:00:00 2001 From: Johannes Bergslid Date: Tue, 10 Nov 2026 16:07:05 +0100 Subject: [PATCH 3/7] Top 5 games now uses real data, links to the details page, and selects the reviewed game on hover/focus or a review button on touch screens (#88) --- .../frontend/src/components/Top5Games.css | 46 +++++++++- .../frontend/src/components/Top5Games.tsx | 84 +++++++++++++------ 2 files changed, 102 insertions(+), 28 deletions(-) diff --git a/T04-Project-2/frontend/src/components/Top5Games.css b/T04-Project-2/frontend/src/components/Top5Games.css index 6b7289e..6506d0c 100644 --- a/T04-Project-2/frontend/src/components/Top5Games.css +++ b/T04-Project-2/frontend/src/components/Top5Games.css @@ -25,14 +25,58 @@ .top-5-item { position: relative; +} + +.top-5-item { + position: relative; +} + +.top-5-show-reviews { + position: absolute; + top: 50%; + right: 0.5rem; + transform: translateY(-50%); + + display: flex; + align-items: center; + justify-content: center; + width: 2.75rem; + height: 2.75rem; + padding: 0; + + color: var(--subtext); + font-size: 1.25rem; + background-color: var(--button-bg-dark); + border: 1px solid var(--border-color-light); + border-radius: var(--border-radius-s); + cursor: pointer; + transition: color 150ms ease, background-color 150ms ease, border-color 150ms ease; +} +.has-review-button .top-5-description { + padding-right: 3.75rem; +} + +.top-5-show-reviews:active { + transform: translateY(-50%) scale(0.94); +} +.top-5-show-reviews[aria-pressed="true"] { + color: var(--dark-text); + background-color: var(--primary-color); + border-color: var(--primary-color); +} + +.top-5-link { + position: relative; background-color: var(--top-5-bg); display: grid; + color: var(--text); + text-decoration: none; grid-template-columns: 1fr 3fr; width: 100%; border-radius: var(--border-radius-s); cursor: pointer; } -.top-5-item:hover { +.top-5-link:hover { background-color: rgb(38, 38, 38); } diff --git a/T04-Project-2/frontend/src/components/Top5Games.tsx b/T04-Project-2/frontend/src/components/Top5Games.tsx index f8565b4..9d0a727 100644 --- a/T04-Project-2/frontend/src/components/Top5Games.tsx +++ b/T04-Project-2/frontend/src/components/Top5Games.tsx @@ -1,44 +1,74 @@ -import type { FC } from 'react' -import { top5MockGames } from '../data/top5MockGames' import './Top5Games.css' import { SectionHeading } from './SectionHeading' -import { StarIcon, CalendarBlankIcon } from '@phosphor-icons/react' +import { StarIcon, CalendarBlankIcon, ChatCircleTextIcon } from '@phosphor-icons/react' import logo from '../assets/GamingGoatLogo.webp' import { ReviewPanel } from './ReviewPanel' import { useMediaQuery } from './useMediaQuery' +import { useQuery } from '@tanstack/react-query' +import { gameQueryOptions } from '../api/games' +import { useState } from 'react' +import { Link } from 'react-router-dom' -type Top5GamesProps = { - gameIds?: readonly string[]; -} +export const Top5Games = () => { + const { data: games = [], isPending, error } = useQuery(gameQueryOptions.top(5)); + const [selectedGameId, setSelectedGameId] = useState(null); + const selectedGame = games.find((game) => game.id === selectedGameId) ?? games[0]; + + const isWide = useMediaQuery('(min-width: 900px)'); + const canHover = useMediaQuery('(hover: hover)'); + const showPanel = isWide; -export const Top5Games: FC = () => { - // Matches the 900px breakpoint in index.css. On narrow screens the review panel - // isn't rendered at all, so its autoscroll loop doesn't run either. - const isWide = useMediaQuery('(min-width: 900px)') return (
    - {top5MockGames.map((game, index) => ( -
  1. - {index + 1} -
    - -
    -
    -
    -

    {game.name}

    -

    - - {game.released ? `Released ${game.released}` : 'Release date unknown'} + {games.map((game, index) => ( +

  2. + setSelectedGameId(game.id) : undefined} + onFocus={showPanel ? () => setSelectedGameId(game.id) : undefined} + > + {index + 1} +
    + +
    +
    +
    +

    {game.name}

    +

    + + {game.released ? `Released ${game.released}` : 'Release date unknown'} +

    +
    +

    + {game.rating ? Math.round(game.rating * 10) /10 : "No ratings"} +

    -

    - {game.rating ? Math.round(game.rating * 10) /10 : "No ratings"} - -

    -
+ + + {showPanel && !canHover && ( + + )} + ))} From 347cf3436b0843ec0285599b79193ecb0ea5b071 Mon Sep 17 00:00:00 2001 From: Johannes Bergslid Date: Wed, 11 Nov 2026 00:06:55 +0100 Subject: [PATCH 4/7] Review panel shows reviews for the selected top 5 game, with loading states and delayed autoscroll start (#88) --- .../frontend/src/components/ReviewCard.tsx | 1 - .../frontend/src/components/ReviewPanel.tsx | 41 +++++++---- .../frontend/src/components/Top5Games.css | 11 ++- .../frontend/src/components/Top5Games.tsx | 24 +++++- .../frontend/src/components/useAutoScroll.ts | 3 +- .../frontend/src/data/top5MockGames.ts | 73 ------------------- 6 files changed, 58 insertions(+), 95 deletions(-) delete mode 100644 T04-Project-2/frontend/src/data/top5MockGames.ts diff --git a/T04-Project-2/frontend/src/components/ReviewCard.tsx b/T04-Project-2/frontend/src/components/ReviewCard.tsx index 48fac37..506bffd 100644 --- a/T04-Project-2/frontend/src/components/ReviewCard.tsx +++ b/T04-Project-2/frontend/src/components/ReviewCard.tsx @@ -1,4 +1,3 @@ - type ReviewCardProps = { username: string text: string diff --git a/T04-Project-2/frontend/src/components/ReviewPanel.tsx b/T04-Project-2/frontend/src/components/ReviewPanel.tsx index 901cbff..3b44ad3 100644 --- a/T04-Project-2/frontend/src/components/ReviewPanel.tsx +++ b/T04-Project-2/frontend/src/components/ReviewPanel.tsx @@ -1,33 +1,42 @@ import { StarIcon } from '@phosphor-icons/react' import { ReviewCard } from './ReviewCard' import { LOOP_COPY_ATTRIBUTE, useAutoScroll } from './useAutoScroll' +import { useQuery } from '@tanstack/react-query' +import { gameReviewQueryOptions, type TopGameSummary } from '../api/games' -const mockReviews = [ - { id: 'review-1', username: 'GoatGuy4', rating: 5, text: "This game changed my life! I feel like it's not worth spending time IRL any more. Amazing graphics, amazing story and most of all, AMAZING GOATS! A clear 5 out of 5 and an absolute no-brainer" }, - { id: 'review-2', username: 'KingGoatz', rating: 4.5, text: 'Epic game' }, - { id: 'review-3', username: 'monsterfighter0', rating: 4.5, text: 'Definitely the most realisitc goat simulation available right now.' }, - { id: 'review-4', username: 'HighlandHerder', rating: 3.5, text: "Great fun for the first few hours, but the physics get a bit wild once you unlock the rocket hooves. Still, nothing beats headbutting a mailbox off a cliff. Would love a proper co-op mode in a future update." }, - { id: 'review-5', username: 'nanny_g0at', rating: 4, text: 'Bleat-tastic.' }, -] +type ReviewPanelProps = { + game: Pick +} -export const ReviewPanel = () => { +export const ReviewPanel = ({ game }: ReviewPanelProps) => { const { ref: scrollRef, isLooping } = useAutoScroll() + const { data: reviews = [], isPending, error } = useQuery(gameReviewQueryOptions.byGameId(game.id)) return (
- {/* TODO: Implement logic for fetching a review to display here */}

Community reviews

-

Goat Simulator Delux

+

{game.name}

- 4.7 + {game.rating ? Math.round(game.rating * 10) / 10 : 'No ratings'}

What the herd thinks

- {mockReviews.map((r) => )} + {isPending &&

Loading reviews…

} + {error &&

Could not load reviews.

} + {!isPending && !error && reviews.length === 0 && ( +

No reviews yet. Be the first!

+ )} + {reviews.map((r) => + + )} {isLooping && [1, 2].map((copy) => (
{ aria-hidden="true" inert > - {mockReviews.map((r) => - + {reviews.map((r) => + )}
))} diff --git a/T04-Project-2/frontend/src/components/Top5Games.css b/T04-Project-2/frontend/src/components/Top5Games.css index 6506d0c..e77ec0c 100644 --- a/T04-Project-2/frontend/src/components/Top5Games.css +++ b/T04-Project-2/frontend/src/components/Top5Games.css @@ -29,6 +29,12 @@ .top-5-item { position: relative; + background-color: var(--top-5-bg); + border-radius: var(--border-radius-s); +} + +.top-5-item.selected { + background-color: rgb(38, 38, 38); } .top-5-show-reviews { @@ -67,17 +73,16 @@ .top-5-link { position: relative; - background-color: var(--top-5-bg); display: grid; color: var(--text); text-decoration: none; grid-template-columns: 1fr 3fr; width: 100%; - border-radius: var(--border-radius-s); cursor: pointer; } .top-5-link:hover { - background-color: rgb(38, 38, 38); + background-color: var(--bg-hover); + border-radius: var(--border-radius-s); } .position { diff --git a/T04-Project-2/frontend/src/components/Top5Games.tsx b/T04-Project-2/frontend/src/components/Top5Games.tsx index 9d0a727..8d2cbfb 100644 --- a/T04-Project-2/frontend/src/components/Top5Games.tsx +++ b/T04-Project-2/frontend/src/components/Top5Games.tsx @@ -8,6 +8,7 @@ import { useQuery } from '@tanstack/react-query' import { gameQueryOptions } from '../api/games' import { useState } from 'react' import { Link } from 'react-router-dom' +import { GameLoading } from './GameLoading' export const Top5Games = () => { const { data: games = [], isPending, error } = useQuery(gameQueryOptions.top(5)); @@ -18,14 +19,31 @@ export const Top5Games = () => { const canHover = useMediaQuery('(hover: hover)'); const showPanel = isWide; + const heading = + + if (isPending || error || games.length === 0) { + return ( +
+ {heading} + +
+ ) + } + return (
- + {heading}
    {games.map((game, index) => (
  1. {
  2. ))}
- {isWide && } + {showPanel && }
) diff --git a/T04-Project-2/frontend/src/components/useAutoScroll.ts b/T04-Project-2/frontend/src/components/useAutoScroll.ts index 0818c91..8ba956a 100644 --- a/T04-Project-2/frontend/src/components/useAutoScroll.ts +++ b/T04-Project-2/frontend/src/components/useAutoScroll.ts @@ -16,7 +16,7 @@ export function useAutoScroll() { let lastTime: number | null = null; const resumeDelay = 1500; - let paused = false; + let paused = true; let resumeTimer: number | undefined; let looping = false @@ -67,6 +67,7 @@ export function useAutoScroll() { }; el.addEventListener("scroll", onScroll, { passive: true }); + resumeLater(); let frame = requestAnimationFrame(function step(time) { const loopHeight = measureLoopHeight(); diff --git a/T04-Project-2/frontend/src/data/top5MockGames.ts b/T04-Project-2/frontend/src/data/top5MockGames.ts deleted file mode 100644 index e7e3532..0000000 --- a/T04-Project-2/frontend/src/data/top5MockGames.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { FeaturedGameSummary } from "../api/games"; - -// Temporary mock data for designing the Top 5 section layout. -// The entries cover edge cases on purpose: a very long title, a missing image, -// a missing rating, a missing release date, and many vs. few platforms. -export const top5MockGames: FeaturedGameSummary[] = [ - { - id: "mock-1", - name: "Goat Simulator Deluxe", - released: "2023-04-12", - background_image: "https://media.rawg.io/media/games/a44/a444a7628bdb49b24d06a7672f805814.jpg", - description: - "

Climb mountains, headbutt fences and cause chaos across a sprawling open world. A sandbox adventure where every cliff is a challenge and every barn is a target.

", - rating: 4.71, - platforms: [ - { id: "4", name: "PC" }, - { id: "187", name: "PlayStation 5" }, - { id: "186", name: "Xbox Series S/X" }, - { id: "7", name: "Nintendo Switch" }, - ], - }, - { - id: "mock-2", - name: "The Legendary Chronicles of the Mountain Herd: Rise of the Eternal Shepherd", - released: "2021-11-03", - background_image: "https://media.rawg.io/media/crop/600/400/games/618/618c2031a07bbff6b4f611f10b6bcdbc.jpg", - description: - "

An epic role-playing game spanning three kingdoms, dozens of companions and over a hundred hours of story. Lead your herd through war, famine and ancient prophecy as you uncover the truth behind the Eternal Shepherd and decide the fate of the highlands.

", - rating: 4.58, - platforms: [ - { id: "4", name: "PC" }, - { id: "18", name: "PlayStation 4" }, - { id: "1", name: "Xbox One" }, - { id: "5", name: "macOS" }, - { id: "6", name: "Linux" }, - ], - }, - { - id: "mock-3", - name: "Hoof Racer", - released: "2024-08-30", - background_image: null, - description: "

Fast-paced arcade racing on cliffs.

", - rating: 4.32, - platforms: [{ id: "4", name: "PC" }], - }, - { - id: "mock-4", - name: "Pasture Tactics", - released: null, - background_image: "https://picsum.photos/seed/goat-4/1280/720", - description: null, - rating: 4.05, - platforms: [ - { id: "3", name: "iOS" }, - { id: "21", name: "Android" }, - ], - }, - { - id: "mock-5", - name: "Billy & Nanny", - released: "2019-02-14", - background_image: "https://picsum.photos/seed/goat-5/1280/720", - description: - "

A cozy co-op puzzle game about two goats finding their way home. Solve hand-drawn puzzles together on the couch or online.

", - rating: null, - platforms: [ - { id: "4", name: "PC" }, - { id: "7", name: "Nintendo Switch" }, - { id: "171", name: "Web" }, - ], - }, -]; From c4a87597d98fd1c79207886261755bab343f457d Mon Sep 17 00:00:00 2001 From: Johannes Bergslid Date: Wed, 11 Nov 2026 00:18:49 +0100 Subject: [PATCH 5/7] Fixed responsiveness for top 5 game cards (#88) --- T04-Project-2/frontend/src/components/ReviewPanel.tsx | 1 - T04-Project-2/frontend/src/components/Top5Games.css | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/T04-Project-2/frontend/src/components/ReviewPanel.tsx b/T04-Project-2/frontend/src/components/ReviewPanel.tsx index 3b44ad3..d80e539 100644 --- a/T04-Project-2/frontend/src/components/ReviewPanel.tsx +++ b/T04-Project-2/frontend/src/components/ReviewPanel.tsx @@ -22,7 +22,6 @@ export const ReviewPanel = ({ game }: ReviewPanelProps) => { {game.rating ? Math.round(game.rating * 10) / 10 : 'No ratings'}

-

What the herd thinks

{isPending &&

Loading reviews…

} diff --git a/T04-Project-2/frontend/src/components/Top5Games.css b/T04-Project-2/frontend/src/components/Top5Games.css index e77ec0c..93b2f29 100644 --- a/T04-Project-2/frontend/src/components/Top5Games.css +++ b/T04-Project-2/frontend/src/components/Top5Games.css @@ -223,7 +223,7 @@ } @media (max-width: 768px) { - .top-5-item { + .top-5-link { grid-template-columns: 1fr; gap: 0.5rem; } From ad15c43d3830b532d628a0f918049b3e0dd8abdf Mon Sep 17 00:00:00 2001 From: Johannes Bergslid Date: Wed, 11 Nov 2026 00:42:56 +0100 Subject: [PATCH 6/7] Added optional limit to fetchGameReviews so you can limit the amount of reviews displayed in the review panel (#88) --- T04-Project-2/backend/src/graphql/resolvers.ts | 9 ++++++--- T04-Project-2/backend/src/graphql/schema.ts | 2 +- T04-Project-2/frontend/src/api/games.ts | 13 +++++++++---- .../frontend/src/components/ReviewPanel.tsx | 2 +- 4 files changed, 17 insertions(+), 9 deletions(-) diff --git a/T04-Project-2/backend/src/graphql/resolvers.ts b/T04-Project-2/backend/src/graphql/resolvers.ts index b35bc83..a751d70 100644 --- a/T04-Project-2/backend/src/graphql/resolvers.ts +++ b/T04-Project-2/backend/src/graphql/resolvers.ts @@ -78,13 +78,13 @@ export const resolvers = { username: users.username, }).from(users); }, - reviews: async (_parent: unknown, { gameId }: { gameId: string }) => { + reviews: async (_parent: unknown, { gameId, limit }: { gameId: string; limit?: number | null }) => { const parsedGameId = Number(gameId); if (!Number.isSafeInteger(parsedGameId) || parsedGameId <= 0) { throw new Error(`Invalid game ID: ${gameId}`); } - return db.select({ + const query = db.select({ user_id: reviews.user_id, game_id: reviews.game_id, description: reviews.description, @@ -93,7 +93,10 @@ export const resolvers = { }).from(reviews) .innerJoin(users, eq(reviews.user_id, users.id)) .where(eq(reviews.game_id, parsedGameId)) - .orderBy(desc(reviews.published_time)); + .orderBy(desc(reviews.published_time)) + .$dynamic(); + + return limit == null ? query : query.limit(Math.min(Math.max(limit, 1), 50)); }, favorites: async () => { return db.select().from(favorites); diff --git a/T04-Project-2/backend/src/graphql/schema.ts b/T04-Project-2/backend/src/graphql/schema.ts index ea296d7..716e02d 100644 --- a/T04-Project-2/backend/src/graphql/schema.ts +++ b/T04-Project-2/backend/src/graphql/schema.ts @@ -92,7 +92,7 @@ export const typeDefs = /* GraphQL */ ` publishers: [Publisher!]! platforms: [Platform!]! users: [User!]! - reviews(gameId: ID!): [Review!]! + reviews(gameId: ID!, limit: Int): [Review!]! favorites: [Favorite!]! reviewCount(gameId: ID!): Int! favoriteCount(gameId: ID!): Int! diff --git a/T04-Project-2/frontend/src/api/games.ts b/T04-Project-2/frontend/src/api/games.ts index 85f857b..b194682 100644 --- a/T04-Project-2/frontend/src/api/games.ts +++ b/T04-Project-2/frontend/src/api/games.ts @@ -106,7 +106,7 @@ export async function fetchGame(id: string): Promise { return result.data.game; } -export async function fetchGameReviews(gameId: string): Promise { +export async function fetchGameReviews(gameId: string, limit?: number): Promise { const response = await fetch(import.meta.env.VITE_API_URL, { method: "POST", headers: { @@ -114,8 +114,8 @@ export async function fetchGameReviews(gameId: string): Promise { }, body: JSON.stringify({ query: ` - query GameReviews($gameId: ID!) { - reviews(gameId: $gameId) { + query GameReviews($gameId: ID!, $limit: Int) { + reviews(gameId: $gameId, limit: $limit) { user_id game_id description @@ -124,7 +124,7 @@ export async function fetchGameReviews(gameId: string): Promise { } } `, - variables: { gameId }, + variables: { gameId, limit }, }), }); @@ -448,6 +448,11 @@ export const gameReviewQueryOptions = { queryFn: () => fetchGameReviewCount(gameId), staleTime: GAME_QUERY_STALE_TIME, }), + latestByGameId: (gameId: string, limit: number) => queryOptions({ + queryKey: ["reviews", "by-game-id", gameId, "latest", limit] as const, + queryFn: () => fetchGameReviews(gameId, limit), + staleTime: GAME_QUERY_STALE_TIME, + }), }; export const gameFavoriteQueryOptions = { diff --git a/T04-Project-2/frontend/src/components/ReviewPanel.tsx b/T04-Project-2/frontend/src/components/ReviewPanel.tsx index d80e539..5f10d51 100644 --- a/T04-Project-2/frontend/src/components/ReviewPanel.tsx +++ b/T04-Project-2/frontend/src/components/ReviewPanel.tsx @@ -10,7 +10,7 @@ type ReviewPanelProps = { export const ReviewPanel = ({ game }: ReviewPanelProps) => { const { ref: scrollRef, isLooping } = useAutoScroll() - const { data: reviews = [], isPending, error } = useQuery(gameReviewQueryOptions.byGameId(game.id)) + const { data: reviews = [], isPending, error } = useQuery(gameReviewQueryOptions.latestByGameId(game.id, 5)) return (
From 77fd862208dd96cb6413bce6f92ee204a2047122 Mon Sep 17 00:00:00 2001 From: Johannes Bergslid Date: Wed, 11 Nov 2026 12:31:39 +0100 Subject: [PATCH 7/7] Removed duplicate style (#88) --- T04-Project-2/frontend/src/components/Top5Games.css | 4 ---- 1 file changed, 4 deletions(-) diff --git a/T04-Project-2/frontend/src/components/Top5Games.css b/T04-Project-2/frontend/src/components/Top5Games.css index 93b2f29..7471467 100644 --- a/T04-Project-2/frontend/src/components/Top5Games.css +++ b/T04-Project-2/frontend/src/components/Top5Games.css @@ -23,10 +23,6 @@ padding: 0; } -.top-5-item { - position: relative; -} - .top-5-item { position: relative; background-color: var(--top-5-bg);