diff --git a/T04-Project-2/backend/src/graphql/resolvers.ts b/T04-Project-2/backend/src/graphql/resolvers.ts index e73af77..a751d70 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 () => { @@ -63,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, @@ -78,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); @@ -252,6 +270,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..716e02d 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!]! } @@ -91,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! @@ -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.`); diff --git a/T04-Project-2/frontend/src/api/games.ts b/T04-Project-2/frontend/src/api/games.ts index 173bf2c..b194682 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; @@ -105,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: { @@ -113,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 @@ -123,7 +124,7 @@ export async function fetchGameReviews(gameId: string): Promise { } } `, - variables: { gameId }, + variables: { gameId, limit }, }), }); @@ -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 = { @@ -409,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/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..5f10d51 100644 --- a/T04-Project-2/frontend/src/components/ReviewPanel.tsx +++ b/T04-Project-2/frontend/src/components/ReviewPanel.tsx @@ -1,33 +1,41 @@ 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.latestByGameId(game.id, 5)) 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 6b7289e..7471467 100644 --- a/T04-Project-2/frontend/src/components/Top5Games.css +++ b/T04-Project-2/frontend/src/components/Top5Games.css @@ -26,14 +26,59 @@ .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 { + 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; 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 { - background-color: rgb(38, 38, 38); +.top-5-link:hover { + background-color: var(--bg-hover); + border-radius: var(--border-radius-s); } .position { @@ -174,7 +219,7 @@ } @media (max-width: 768px) { - .top-5-item { + .top-5-link { grid-template-columns: 1fr; gap: 0.5rem; } diff --git a/T04-Project-2/frontend/src/components/Top5Games.tsx b/T04-Project-2/frontend/src/components/Top5Games.tsx index f8565b4..8d2cbfb 100644 --- a/T04-Project-2/frontend/src/components/Top5Games.tsx +++ b/T04-Project-2/frontend/src/components/Top5Games.tsx @@ -1,48 +1,96 @@ -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' +import { GameLoading } from './GameLoading' -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; + + const heading = + + if (isPending || error || games.length === 0) { + return ( +
+ {heading} + +
+ ) + } -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 (
- + {heading}
    - {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 && ( + + )} + ))} - {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" }, - ], - }, -];