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
30 changes: 26 additions & 4 deletions T04-Project-2/backend/src/graphql/resolvers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand All @@ -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 () => {
Expand All @@ -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,
Expand All @@ -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);
Expand Down Expand Up @@ -252,6 +270,10 @@ export const resolvers = {

return game ?? null;
},
topGames: async (
_parent: unknown,
{ limit = 5 }: { limit?: number }
) => selectTopGames(limit),
},
Mutation: {
createUser: async (
Expand Down
4 changes: 3 additions & 1 deletion T04-Project-2/backend/src/graphql/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export const typeDefs = /* GraphQL */ `
background_image: String
description: String
rating: Float
ratings_count: Int
platforms: [Platform!]!
}
Expand Down Expand Up @@ -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!
Expand All @@ -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 {
Expand Down
48 changes: 43 additions & 5 deletions T04-Project-2/backend/src/scripts/seed.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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<T>(items: T[]): T {
return items[Math.floor(Math.random() * items.length)];
}
Expand Down Expand Up @@ -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) => {
Expand All @@ -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) => ({
Expand All @@ -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.`);
Expand Down
52 changes: 48 additions & 4 deletions T04-Project-2/frontend/src/api/games.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { GameSearchOptions } from "./filters";
export type GameSearchResult = Pick<Game, "id" | "name" | "released" | "rating" | "background_image" | "ratings_count" | "slug" | "playtime">;
export type GameCardSummary = Pick<Game, "id" | "name" | "released" | "background_image">;
export type FeaturedGameSummary = GameCardSummary & Pick<Game, "description" | "rating"> & {platforms: Pick<Platform, "id" | "name">[];};
export type TopGameSummary = FeaturedGameSummary & Pick<Game, "ratings_count">;

export type GameReview = {
id: string;
Expand Down Expand Up @@ -105,16 +106,16 @@ export async function fetchGame(id: string): Promise<Game | null> {
return result.data.game;
}

export async function fetchGameReviews(gameId: string): Promise<GameReview[]> {
export async function fetchGameReviews(gameId: string, limit?: number): Promise<GameReview[]> {
const response = await fetch(import.meta.env.VITE_API_URL, {
method: "POST",
headers: {
"content-type": "application/json",
},
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
Expand All @@ -123,7 +124,7 @@ export async function fetchGameReviews(gameId: string): Promise<GameReview[]> {
}
}
`,
variables: { gameId },
variables: { gameId, limit },
}),
});

Expand Down Expand Up @@ -358,6 +359,39 @@ async function fetchFavoriteGameSummaries(ids: readonly string[]): Promise<GameC
return result.data.gamesByIds;
}

async function fetchTopGames(limit: number): Promise<TopGameSummary[]> {

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<GameCardSummary[]> {
return fetchGameSummariesByIds<GameCardSummary>(ids, "id name released background_image");
}
Expand Down Expand Up @@ -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 = {
Expand All @@ -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 = {
Expand Down
1 change: 0 additions & 1 deletion T04-Project-2/frontend/src/components/ReviewCard.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@

type ReviewCardProps = {
username: string
text: string
Expand Down
42 changes: 27 additions & 15 deletions T04-Project-2/frontend/src/components/ReviewPanel.tsx
Original file line number Diff line number Diff line change
@@ -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<TopGameSummary, 'id' | 'name' | 'rating'>
}

export const ReviewPanel = () => {
export const ReviewPanel = ({ game }: ReviewPanelProps) => {
const { ref: scrollRef, isLooping } = useAutoScroll<HTMLDivElement>()
const { data: reviews = [], isPending, error } = useQuery(gameReviewQueryOptions.latestByGameId(game.id, 5))

return (
<div className='review-display'>
{/* TODO: Implement logic for fetching a review to display here */}
<p className='subtext top-text'>Community reviews</p>
<div className='heading-section'>
<h3 className='review-display-heading'> Goat Simulator Delux</h3>
<h3 className='review-display-heading'>{game.name}</h3>
<p className='featured-rating top-5-rating'>
<StarIcon className='top-5-star' weight='fill'/>
4.7
{game.rating ? Math.round(game.rating * 10) / 10 : 'No ratings'}
</p>
</div>
<p className='subtext'>What the herd thinks</p>
<div className='scroll-wrapper'>
<div className='review-container' ref={scrollRef}>
{mockReviews.map((r) => <ReviewCard key={r.id} {...r}/>)}
{isPending && <p className='subtext'>Loading reviews…</p>}
{error && <p className='subtext' role='alert'>Could not load reviews.</p>}
{!isPending && !error && reviews.length === 0 && (
<p className='subtext'>No reviews yet. Be the first!</p>
)}
{reviews.map((r) =>
<ReviewCard
key={r.id}
username={r.authorName}
text={r.content}
/>
)}

{isLooping && [1, 2].map((copy) => (
<div
Expand All @@ -37,8 +45,12 @@ export const ReviewPanel = () => {
aria-hidden="true"
inert
>
{mockReviews.map((r) =>
<ReviewCard key={`copy-${copy}-${r.id}`} {...r}/>
{reviews.map((r) =>
<ReviewCard
key={`copy-${copy}-${r.id}`}
username={r.authorName}
text={r.content}
/>
)}
</div>
))}
Expand Down
Loading