diff --git a/T04-Project-2/backend/src/graphql/resolvers.ts b/T04-Project-2/backend/src/graphql/resolvers.ts index af39cc5..dc577e1 100644 --- a/T04-Project-2/backend/src/graphql/resolvers.ts +++ b/T04-Project-2/backend/src/graphql/resolvers.ts @@ -30,6 +30,15 @@ const sortColumns = { playtime: games.playtime, }; +const platformsForGame = (game: { id: number }) => db.select({ + id: platforms.id, + name: platforms.name, + description: platforms.description, +}) + .from(game_platforms) + .innerJoin(platforms, eq(game_platforms.platform_id, platforms.id)) + .where(eq(game_platforms.game_id, game.id)); + export const resolvers = { Query: { games: async () => { @@ -170,6 +179,7 @@ export const resolvers = { released: games.released, background_image: games.background_image, description: games.description, + rating: games.rating, }) .from(games) .where(inArray(games.id, gameIds)); @@ -199,6 +209,7 @@ export const resolvers = { released: games.released, background_image: games.background_image, description: games.description, + rating: games.rating, }) .from(games) .where(inArray(games.rawg_id, gameIds)); @@ -363,13 +374,9 @@ export const resolvers = { .from(game_publishers) .innerJoin(publishers, eq(game_publishers.publisher_id, publishers.id)) .where(eq(game_publishers.game_id, game.id)), - platforms: (game: { id: number }) => db.select({ - id: platforms.id, - name: platforms.name, - description: platforms.description, - }) - .from(game_platforms) - .innerJoin(platforms, eq(game_platforms.platform_id, platforms.id)) - .where(eq(game_platforms.game_id, game.id)), + platforms: platformsForGame + }, + GameSummary: { + platforms: platformsForGame, }, }; diff --git a/T04-Project-2/backend/src/graphql/schema.ts b/T04-Project-2/backend/src/graphql/schema.ts index 0d55350..49a35b0 100644 --- a/T04-Project-2/backend/src/graphql/schema.ts +++ b/T04-Project-2/backend/src/graphql/schema.ts @@ -25,6 +25,8 @@ export const typeDefs = /* GraphQL */ ` released: String background_image: String description: String + rating: Float + platforms: [Platform!]! } type Developer { diff --git a/T04-Project-2/frontend/package-lock.json b/T04-Project-2/frontend/package-lock.json index 389d82c..8c5a95e 100644 --- a/T04-Project-2/frontend/package-lock.json +++ b/T04-Project-2/frontend/package-lock.json @@ -15,6 +15,7 @@ "embla-carousel-wheel-gestures": "^8.1.0", "react": "^19.2.8", "react-dom": "^19.2.8", + "react-icons": "^5.7.0", "react-router-dom": "^7.18.4" }, "devDependencies": { @@ -3645,6 +3646,15 @@ "react": "^19.3.0" } }, + "node_modules/react-icons": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.7.0.tgz", + "integrity": "sha512-LBLy340Rzqy6+/yVhZKT3B/QpP1BZaesGqasf09HPOBzRarcDIFH0WwXlXQfE7q7ipxK4MSiC5DIBWURCny6fw==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", diff --git a/T04-Project-2/frontend/package.json b/T04-Project-2/frontend/package.json index 9cbe590..a2858a4 100644 --- a/T04-Project-2/frontend/package.json +++ b/T04-Project-2/frontend/package.json @@ -20,6 +20,7 @@ "embla-carousel-wheel-gestures": "^8.1.0", "react": "^19.2.8", "react-dom": "^19.2.8", + "react-icons": "^5.7.0", "react-router-dom": "^7.18.4" }, "devDependencies": { diff --git a/T04-Project-2/frontend/src/api/games.ts b/T04-Project-2/frontend/src/api/games.ts index 9896b65..b78b467 100644 --- a/T04-Project-2/frontend/src/api/games.ts +++ b/T04-Project-2/frontend/src/api/games.ts @@ -1,10 +1,10 @@ -import type { Game } from "../typeDefs"; +import type { Game, Platform } from "../typeDefs"; import { infiniteQueryOptions, queryOptions } from "@tanstack/react-query"; import type { GameSearchOptions } from "./filters"; export type GameSearchResult = Pick; export type GameCardSummary = Pick; -export type FeaturedGameSummary = GameCardSummary & Pick; +export type FeaturedGameSummary = GameCardSummary & Pick & {platforms: Pick[];}; export type GameReview = { id: string; @@ -370,7 +370,7 @@ export function fetchGamesByIds(ids: readonly string[]): Promise { - return fetchGameSummariesByIds(ids, "id name released background_image description"); + return fetchGameSummariesByIds(ids, "id name released background_image description rating platforms { id name }"); } const GAME_QUERY_STALE_TIME = 5 * 60 * 1000; diff --git a/T04-Project-2/frontend/src/components/FeaturedCarousel.css b/T04-Project-2/frontend/src/components/FeaturedCarousel.css index ebf16bf..7563523 100644 --- a/T04-Project-2/frontend/src/components/FeaturedCarousel.css +++ b/T04-Project-2/frontend/src/components/FeaturedCarousel.css @@ -20,8 +20,8 @@ .featured-favorite { position: absolute; z-index: 2; - top: 0.75rem; - right: 0.75rem; + top: 1.5rem; + right: 1.2rem; display: grid; width: 2.75rem; aspect-ratio: 1; @@ -47,9 +47,9 @@ .image-info-container { display: grid; - grid-template-columns: minmax(0, 1.8fr) minmax(0, 1fr); + grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr); width: 100%; - min-height: clamp(24rem, 36vw, 32rem); + min-height: clamp(24rem, 32vw, 32rem); min-width: 0; box-sizing: border-box; border-radius: var(--border-radius); @@ -65,38 +65,65 @@ .game-info { min-width: 0; + height: 100%; box-sizing: border-box; background-color: var(--secondary-bg); + gap: 0.75rem; display: flex; flex-direction: column; - justify-content: center; padding: clamp(1.25rem, 3vw, 3rem); } .game-title { margin: 0; line-height: 1.15; - font-size: clamp(1.3rem, 3vw, 2rem); + font-size: clamp(1.3rem, 2.8vw, 3rem); overflow-wrap: anywhere; color: var(--primary-color); } +.release-rating-container { + display: flex; + gap: 1.5rem; + color: var(--subtext); +} .game-release-info { color: var(--subtext); + margin: 0; + display: flex; + align-items: center; + gap: 0.3rem; +} + +.featured-rating { + font-size: 1rem; + margin: 0; + font-weight: bold; + display: flex; + gap: 0.3rem; + color: var(--text); +} + +.featured-star { + color: var(--primary-color); + font-size: 1.2rem; } .game-info-description { display: -webkit-box; - max-width: 42ch; overflow: hidden; margin: 0.5rem 0 0; color: var(--subtext); font-size: 1rem; - line-height: 1.5; + line-height: 1.6; -webkit-box-orient: vertical; - -webkit-line-clamp: 7; - line-clamp: 7; + -webkit-line-clamp: 5; + line-clamp: 5; +} + +.featured-platforms { + font-size: clamp(1rem, 2vw, 1.5rem); } .games-container { @@ -113,6 +140,20 @@ height: 100%; object-fit: cover; } + +.info-button-container { + display: flex; + flex-direction: column; + justify-content: space-between; + height: 100%; +} + +.featured-button { + background-color: var(--primary-color); + margin-top: 1rem; + font-size: clamp(1rem, 1.4vw, 1.15rem); + color: var(--dark-text); +} .featured-image[data-role = 'hidden'] { display: none; } @@ -199,6 +240,16 @@ } } +@media (max-width: 768px) { + .game-info-description { + -webkit-line-clamp: 4; + line-clamp: 4; + } + .release-rating-container { + gap: 0.8rem; + } +} + @media (prefers-reduced-motion: reduce) { .featured-image[data-role = 'current'][data-direction] { animation: fade-in 0.4s ease both; diff --git a/T04-Project-2/frontend/src/components/FeaturedCarousel.tsx b/T04-Project-2/frontend/src/components/FeaturedCarousel.tsx index 33ee5ba..700eaae 100644 --- a/T04-Project-2/frontend/src/components/FeaturedCarousel.tsx +++ b/T04-Project-2/frontend/src/components/FeaturedCarousel.tsx @@ -1,5 +1,5 @@ import './FeaturedCarousel.css' -import { CaretLeftIcon, CaretRightIcon, Star } from "@phosphor-icons/react" +import { CaretLeftIcon, CaretRightIcon, Star, CalendarBlankIcon } from "@phosphor-icons/react" import { Button } from "./Button" import { useCallback, useEffect, useState } from "react" import { useQuery } from "@tanstack/react-query" @@ -8,6 +8,9 @@ import { gameQueryOptions } from "../api/games" import { Link } from "react-router-dom" import { useFavorites } from "./useFavorites" import { GameLoading } from "./GameLoading" +import { PlatformDisplay } from './PlatformDisplay' +import { ButtonContent } from './ButtonContent' +import { StarIcon } from '@phosphor-icons/react/dist/ssr' type FeaturedCarouselProps = { gameIds: readonly string[]; @@ -103,10 +106,29 @@ export const FeaturedCarousel = ({gameIds}: FeaturedCarouselProps) => {

{currentGameData.name}

-

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

-

- {description || 'No description is available for this game yet.'} -

+ +
+

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

+ | +

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

+
+
+

+ {description || 'No description is available for this game yet.'} +

+
{userId && ( diff --git a/T04-Project-2/frontend/src/components/FeaturedGames.css b/T04-Project-2/frontend/src/components/FeaturedGames.css index f9f094f..b2bc3c6 100644 --- a/T04-Project-2/frontend/src/components/FeaturedGames.css +++ b/T04-Project-2/frontend/src/components/FeaturedGames.css @@ -3,8 +3,8 @@ margin-top: -0rem; } .featured-games { - position: relative; /* lets ::before be positioned inside the section */ - isolation: isolate; /* keeps z-index: -1 inside the section instead of behind the page */ + position: relative; + isolation: isolate; } .featured-games::before { diff --git a/T04-Project-2/frontend/src/components/FilterDropdown.css b/T04-Project-2/frontend/src/components/FilterDropdown.css index b69219a..31f284a 100644 --- a/T04-Project-2/frontend/src/components/FilterDropdown.css +++ b/T04-Project-2/frontend/src/components/FilterDropdown.css @@ -55,7 +55,7 @@ font-size: 0.8rem; } .filter-dropdown-option:hover { - background-color: rgba(48, 48, 48, 0.346); + background-color: var(--bg-hover); } .filter-checkbox { diff --git a/T04-Project-2/frontend/src/components/FilterSection.css b/T04-Project-2/frontend/src/components/FilterSection.css index b07af0e..4270a69 100644 --- a/T04-Project-2/frontend/src/components/FilterSection.css +++ b/T04-Project-2/frontend/src/components/FilterSection.css @@ -1,4 +1,3 @@ -/* the gap moves into the body so a closed section doesn't leave empty space */ .filter-section-container { gap: 0; } @@ -43,7 +42,6 @@ display: flex; flex-direction: column; gap: 0.5rem; - /* room for the slider thumbs, which stick out past the track */ padding: 0.75rem 0 0.5rem; } diff --git a/T04-Project-2/frontend/src/components/GameCard.css b/T04-Project-2/frontend/src/components/GameCard.css index f74328e..810fd37 100644 --- a/T04-Project-2/frontend/src/components/GameCard.css +++ b/T04-Project-2/frontend/src/components/GameCard.css @@ -45,7 +45,7 @@ width: 100%; aspect-ratio: 3 / 2; overflow: hidden; - border: 1px solid rgba(255, 255, 255, 0.14); + border: 1px solid var(--border-color-light); border-radius: 1.25rem; background: #21171e; box-shadow: 0 0.5rem 1.5rem rgba(0, 0, 0, 0.28); diff --git a/T04-Project-2/frontend/src/components/GameSection.css b/T04-Project-2/frontend/src/components/GameSection.css index e5323d4..14440de 100644 --- a/T04-Project-2/frontend/src/components/GameSection.css +++ b/T04-Project-2/frontend/src/components/GameSection.css @@ -3,10 +3,6 @@ margin-top: 2rem; } -.game-category { - margin-left: 1.5rem; -} - .game-section > .heading-container + .game-category { padding-top: 1rem; } diff --git a/T04-Project-2/frontend/src/components/GameSection.tsx b/T04-Project-2/frontend/src/components/GameSection.tsx index 0d9ff43..50f0968 100644 --- a/T04-Project-2/frontend/src/components/GameSection.tsx +++ b/T04-Project-2/frontend/src/components/GameSection.tsx @@ -2,18 +2,23 @@ import type { GameCategory } from "../data/games" import { GameCardCarousel } from "./GameCardCarousel" import './GameSection.css' import { SectionHeading } from "./SectionHeading" +import { Top5Games } from './Top5Games' type GameSectionProps = { gameCategories: readonly GameCategory[], - heading: string, - highlight: string, } -export const GameSection = ({gameCategories, heading, highlight}: GameSectionProps) => { +export const GameSection = ({gameCategories}: GameSectionProps) => { return (
- - {gameCategories.map((category) => ( + {gameCategories.slice(0, 2).map((category) => ( +
+ + +
+ ))} + + {gameCategories.slice(2, 4).map((category) => (
diff --git a/T04-Project-2/frontend/src/components/HomePage.tsx b/T04-Project-2/frontend/src/components/HomePage.tsx index 333be8a..7d8729d 100644 --- a/T04-Project-2/frontend/src/components/HomePage.tsx +++ b/T04-Project-2/frontend/src/components/HomePage.tsx @@ -8,7 +8,7 @@ function HomePage() { <> - + ) } diff --git a/T04-Project-2/frontend/src/components/LoginPage.tsx b/T04-Project-2/frontend/src/components/LoginPage.tsx index 9ca053d..52da3d3 100644 --- a/T04-Project-2/frontend/src/components/LoginPage.tsx +++ b/T04-Project-2/frontend/src/components/LoginPage.tsx @@ -71,8 +71,8 @@ function LoginPage({ onClose, onAuthenticated }: LoginPageProps) {

{isCreatingAccount ? 'Create your account' : 'Welcome back'}

{isCreatingAccount - ? 'Join the GamingGoat community.' - : 'Log in to continue to your gaming community.'} + ? 'Join the herd of GamingGoats today!' + : 'Log in to interact with the GamingGoat herd.'}

{isCreatingAccount ? ( @@ -161,7 +161,7 @@ function LoginPage({ onClose, onAuthenticated }: LoginPageProps) { setIsCreatingAccount((current) => !current); }} > - {isCreatingAccount ? 'Already have an account? Log in' : 'Create account'} + {isCreatingAccount ? 'Already part of the herd? Log in' : 'Create account'} diff --git a/T04-Project-2/frontend/src/components/PlatformDisplay.css b/T04-Project-2/frontend/src/components/PlatformDisplay.css new file mode 100644 index 0000000..d1200bf --- /dev/null +++ b/T04-Project-2/frontend/src/components/PlatformDisplay.css @@ -0,0 +1,5 @@ +.platform-display { + display: flex; + gap: 0.6rem; + color: var(--text); +} \ No newline at end of file diff --git a/T04-Project-2/frontend/src/components/PlatformDisplay.tsx b/T04-Project-2/frontend/src/components/PlatformDisplay.tsx new file mode 100644 index 0000000..47259be --- /dev/null +++ b/T04-Project-2/frontend/src/components/PlatformDisplay.tsx @@ -0,0 +1,39 @@ +import type { IconType } from "react-icons"; +import { FaWindows, FaPlaystation, FaXbox, FaApple, FaLinux, FaAndroid, FaGamepad } from "react-icons/fa"; +import { BsNintendoSwitch, BsGlobe } from "react-icons/bs"; +import { MdPhoneIphone } from "react-icons/md"; +import type { Platform } from "../typeDefs"; +import './PlatformDisplay.css' + +const rules: [RegExp, IconType][] = [ + [/^pc$/i, FaWindows], + [/playstation|psp|ps vita/i, FaPlaystation], + [/xbox/i, FaXbox], + [/nintendo|wii|game ?boy|gamecube|snes|nes/i, BsNintendoSwitch], + [/ios/i, MdPhoneIphone], + [/macos|mac|apple/i, FaApple], + [/linux/i, FaLinux], + [/android/i, FaAndroid], + [/web/i, BsGlobe], +]; + +function getPlatformIcon(name: string): IconType { + return rules.find(([platform]) => platform.test(name))?.[1] ?? FaGamepad; +} + + +type PlatformDisplayProps = { + platforms: Pick[] + className?: string; +} + +export const PlatformDisplay = ({platforms, className}: PlatformDisplayProps) => { + const icons = [...new Map(platforms.map((p) => [getPlatformIcon(p.name), p])).entries()]; + return ( +
+ {icons.map(([Icon, p]) => ( + + ))} +
+ ) +} \ No newline at end of file diff --git a/T04-Project-2/frontend/src/components/ReviewCard.tsx b/T04-Project-2/frontend/src/components/ReviewCard.tsx new file mode 100644 index 0000000..48fac37 --- /dev/null +++ b/T04-Project-2/frontend/src/components/ReviewCard.tsx @@ -0,0 +1,15 @@ + +type ReviewCardProps = { + username: string + text: string +} + + +export const ReviewCard = ( {username, text}: ReviewCardProps) => { + return ( +
+

{username}

+

{text}

+
+ ) +} \ No newline at end of file diff --git a/T04-Project-2/frontend/src/components/ReviewPanel.tsx b/T04-Project-2/frontend/src/components/ReviewPanel.tsx new file mode 100644 index 0000000..901cbff --- /dev/null +++ b/T04-Project-2/frontend/src/components/ReviewPanel.tsx @@ -0,0 +1,49 @@ +import { StarIcon } from '@phosphor-icons/react' +import { ReviewCard } from './ReviewCard' +import { LOOP_COPY_ATTRIBUTE, useAutoScroll } from './useAutoScroll' + +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.' }, +] + +export const ReviewPanel = () => { + const { ref: scrollRef, isLooping } = useAutoScroll() + + return ( +
+ {/* TODO: Implement logic for fetching a review to display here */} +

Community reviews

+
+

Goat Simulator Delux

+

+ + 4.7 +

+
+

What the herd thinks

+
+
+ {mockReviews.map((r) => )} + + {isLooping && [1, 2].map((copy) => ( + + ))} +
+
+
+ ) +} diff --git a/T04-Project-2/frontend/src/components/SectionHeading.css b/T04-Project-2/frontend/src/components/SectionHeading.css index ab2ed0f..cb0d01f 100644 --- a/T04-Project-2/frontend/src/components/SectionHeading.css +++ b/T04-Project-2/frontend/src/components/SectionHeading.css @@ -1,11 +1,19 @@ +.main-heading-container { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin: 1rem 0; +} + .heading-container { display: flex; align-items: center; gap: 0.5rem; - margin: 1rem 0; width: 100%; + + position: relative; } -.heading-container[data-header-line = 'true'] { +.main-heading-container[data-header-line = 'true'] { padding-bottom: 0.5rem; border-bottom: 1px solid var(--border-color); } diff --git a/T04-Project-2/frontend/src/components/SectionHeading.tsx b/T04-Project-2/frontend/src/components/SectionHeading.tsx index ca9aa57..322fe46 100644 --- a/T04-Project-2/frontend/src/components/SectionHeading.tsx +++ b/T04-Project-2/frontend/src/components/SectionHeading.tsx @@ -4,21 +4,30 @@ type SectionHeadingProps = { text: string highlight?: string headerLine?: boolean + subtext?: string } -export const SectionHeading = ({text, highlight, headerLine}: SectionHeadingProps) => { +export const SectionHeading = ({text, highlight, headerLine, subtext}: SectionHeadingProps) => { const textParts = text.split(new RegExp(`(${highlight})`, 'i')); return ( -
- -

- {textParts.map((part, i) => ( - part.toLowerCase() === highlight?.toLowerCase() - ? {part} - : part - ))} -

+
+
+ +

+ {textParts.map((part, i) => ( + part.toLowerCase() === highlight?.toLowerCase() + ? {part} + : part + ))} +

+
+ {subtext ? ( +

+ {subtext} +

+ ): "" + }
) } \ No newline at end of file diff --git a/T04-Project-2/frontend/src/components/StarDisplay.css b/T04-Project-2/frontend/src/components/StarDisplay.css new file mode 100644 index 0000000..e2504ff --- /dev/null +++ b/T04-Project-2/frontend/src/components/StarDisplay.css @@ -0,0 +1,14 @@ +.star-container-small { + display: flex; + align-items: center; + gap: 0.3rem; +} + +.small { + font-size: clamp(0.8rem, 1.3vw, 1rem); + cursor: default; +} +.small { + font-size: clamp(0.8rem, 1.3vw, 1rem); + cursor: default; +} \ No newline at end of file diff --git a/T04-Project-2/frontend/src/components/StarDisplay.tsx b/T04-Project-2/frontend/src/components/StarDisplay.tsx new file mode 100644 index 0000000..cc9b238 --- /dev/null +++ b/T04-Project-2/frontend/src/components/StarDisplay.tsx @@ -0,0 +1,23 @@ +import { StarIcon } from "@phosphor-icons/react" +import './StarDisplay.css' + +type StarDisplayProps = { + rating: number +} + +export const StarDisplay = ( { rating }: StarDisplayProps) => { + return ( +
+ {Array.from({ length : 5}).map((_, index) => ( +
+ + + + +
+ ))} +
+ ) +} \ No newline at end of file diff --git a/T04-Project-2/frontend/src/components/Top5Games.css b/T04-Project-2/frontend/src/components/Top5Games.css new file mode 100644 index 0000000..6b7289e --- /dev/null +++ b/T04-Project-2/frontend/src/components/Top5Games.css @@ -0,0 +1,190 @@ +.top-5-games { + display: flex; + flex-direction: column; + margin-bottom: 5rem; +} + +.top-5-content { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 1rem; +} + +.top-5-content.list-only { + grid-template-columns: 1fr; +} + +.top-5-list { + display: flex; + flex-direction: column; + gap: 0.5rem; + list-style-type: none; + margin: 0; + padding: 0; +} + +.top-5-item { + position: relative; + background-color: var(--top-5-bg); + display: grid; + 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); +} + +.position { + position: absolute; + top: -10px; + left: -15px; + + display: flex; + align-items: center; + justify-content: center; + + color: var(--dark-text); + font-weight: bold; + font-size: clamp(0.8rem, 1.5vw, 1rem); + + width: 1.8rem; + height: 1.8rem; + border-radius: var(--border-radius-s); + background-color: var(--primary-color); +} +.first { + background-color: gold; + box-shadow: 0px 0px 10px gold; +} + +.top-5-image { + display: block; + width: 100%; + height: auto; + object-fit: cover; + object-position: center; + align-self: center; + aspect-ratio: 2 / 0.75; +} +.image-wrapper { + border-radius: var(--border-radius-s); + overflow: hidden; +} + +.top-5-description { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0 1rem; +} +.top-5-heading { + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + line-clamp: 2; + margin: 0; + font-size: clamp(0.8rem, 1.5vw, 1rem); + line-height: 1.15; +} + +.top-5-release { + font-size: clamp(0.6rem, 1.5vw, 0.8rem); +} + +.name-release { + max-width: 70%; + display: flex; + flex-direction: column; + gap: 0.3rem; +} + +.top-5-rating { + height: 100%; + display: flex; + align-items: center; + font-size: clamp(0.8rem, 1.5vw, 1rem);; +} +.top-5-star { + font-size: clamp(1.2rem, 2vw, 1.4rem);;; + color: var(--primary-color); +} + +.review-display { + background-color: var(--top-5-bg); + border-radius: var(--border-radius-s); + + box-sizing: border-box; + padding: 1.25rem; + height: 100%; + + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.top-text { + text-transform: uppercase; + font-size: clamp(0.6rem, 1.2vw, 0.8rem); +} + +.heading-section { + margin-top: -0.75rem; + display: flex; + justify-content: space-between; + align-items: center; + padding-bottom: 0.5rem; + border-bottom: 2px solid var(--border-color-light); +} + + +.scroll-wrapper { + position: relative; + height: 100%; +} + +.review-container { + position: absolute; + inset: 0; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 1rem; + + scrollbar-width: none; +} + +.review-loop-copy { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.top-5-review { + background-color: rgb(38, 38, 38); + padding: 0.75rem; + border-radius: var(--border-radius-s); +} + +.top-5-review-text { + font-size: clamp(0.6rem, 1.5vw, 0.8rem); + margin-top: 0.3rem; +} + +@media (max-width: 768px) { + .top-5-item { + grid-template-columns: 1fr; + gap: 0.5rem; + } + .name-release { + gap: 0; + } + .top-5-list { + gap: 1rem; + } + .top-5-rating { + align-items:start + } +} diff --git a/T04-Project-2/frontend/src/components/Top5Games.tsx b/T04-Project-2/frontend/src/components/Top5Games.tsx new file mode 100644 index 0000000..61a8c9c --- /dev/null +++ b/T04-Project-2/frontend/src/components/Top5Games.tsx @@ -0,0 +1,48 @@ +import { top5MockGames } from '../data/top5MockGames' +import './Top5Games.css' +import { SectionHeading } from './SectionHeading' +import { StarIcon, CalendarBlankIcon } from '@phosphor-icons/react' +import logo from '../assets/GamingGoatLogo.webp' +import { ReviewPanel } from './ReviewPanel' +import { useMediaQuery } from './useMediaQuery' + +type Top5GamesProps = { + gameIds?: readonly string[]; +} + +export const Top5Games = ({ gameIds }: Top5GamesProps) => { + // 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'} +

    +
    +

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

    +
    +
  2. + ))} +
+ {isWide && } +
+
+ ) +} \ No newline at end of file diff --git a/T04-Project-2/frontend/src/components/useAutoScroll.ts b/T04-Project-2/frontend/src/components/useAutoScroll.ts new file mode 100644 index 0000000..0818c91 --- /dev/null +++ b/T04-Project-2/frontend/src/components/useAutoScroll.ts @@ -0,0 +1,121 @@ +import { useEffect, useRef, useState } from "react"; + +export const LOOP_COPY_ATTRIBUTE = "data-loop-copy" + +export function useAutoScroll() { + const ref = useRef(null); + const [isLooping, setIsLooping] = useState(false); + + useEffect(() => { + const el = ref.current; + if (!el) return; + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; + + const speed = 15; + let position = el.scrollTop; + let lastTime: number | null = null; + + const resumeDelay = 1500; + let paused = false; + let resumeTimer: number | undefined; + + let looping = false + + const measureLoopHeight = (): number | null => { + const copy = el.querySelector(`[${LOOP_COPY_ATTRIBUTE}]`) + if(!copy) return null; + return copy.getBoundingClientRect().top - el.getBoundingClientRect().top + el.scrollTop; + }; + + const wrap = (value: number, loopHeight: number): number => { + const maxScroll = el.scrollHeight - el.clientHeight; + const start = Math.max(0, (maxScroll - loopHeight) / 2); + let wrapped = value; + while (wrapped >= start + loopHeight) wrapped -= loopHeight; + while (wrapped < start) wrapped += loopHeight; + return wrapped; + }; + + const pause = () => { + window.clearTimeout(resumeTimer); + paused = true; + } + + const resumeLater = () => { + window.clearTimeout(resumeTimer); + resumeTimer = window.setTimeout(() => {paused = false;}, resumeDelay) + } + + const pauseBriefly = () => { pause(); resumeLater(); }; + const onFocusOut = (event: FocusEvent) => { + if (!el.contains(event.relatedTarget as Node | null)) resumeLater(); + }; + + el.addEventListener("pointerenter", pause); + el.addEventListener("pointerleave", resumeLater); + el.addEventListener("focusin", pause); + el.addEventListener("focusout", onFocusOut); + el.addEventListener("wheel", pauseBriefly, {passive: true}); + el.addEventListener("touchmove", pauseBriefly, {passive: true}) + + const onScroll = () => { + if (!looping) return; + const loopHeight = measureLoopHeight(); + if (loopHeight === null || loopHeight <= 0) return; + const wrapped = wrap(el.scrollTop, loopHeight); + if (Math.abs(wrapped - el.scrollTop) > 0.5) el.scrollTop = wrapped; + }; + el.addEventListener("scroll", onScroll, { passive: true }); + + + let frame = requestAnimationFrame(function step(time) { + const loopHeight = measureLoopHeight(); + const gap = parseFloat(getComputedStyle(el).rowGap) || 0; + const originalsHeight = loopHeight === null ? el.scrollHeight : loopHeight - gap; + const shouldLoop = originalsHeight > el.clientHeight + 2; + + if (shouldLoop !== looping) { + looping = shouldLoop; + setIsLooping(shouldLoop); + } + if (looping && loopHeight !== null && loopHeight > 0) { + const wrapped = wrap(el.scrollTop, loopHeight); + if (Math.abs(wrapped - el.scrollTop) > 0.5) { + el.scrollTop = wrapped; + position = wrapped; + } + } + + if(paused) { + position = el.scrollTop; + lastTime = null; + } else if (!looping || loopHeight === null || loopHeight <= 0) { + lastTime = null; + } else { + if (lastTime !== null) { + position += (speed * (time - lastTime)) / 1000; + } + lastTime = time; + position = wrap(position, loopHeight); + el.scrollTop = position; + } + + frame = requestAnimationFrame(step) + }) + + return () => { + cancelAnimationFrame(frame); + + el.removeEventListener("pointerenter", pause); + el.removeEventListener("pointerleave", resumeLater); + el.removeEventListener("focusin", pause); + el.removeEventListener("focusout", onFocusOut); + el.removeEventListener("wheel", pauseBriefly); + el.removeEventListener("touchmove", pauseBriefly); + el.removeEventListener("scroll", onScroll); + window.clearTimeout(resumeTimer); + }; + },[]); + + return { ref, isLooping }; +} \ No newline at end of file diff --git a/T04-Project-2/frontend/src/components/useMediaQuery.ts b/T04-Project-2/frontend/src/components/useMediaQuery.ts new file mode 100644 index 0000000..7d170de --- /dev/null +++ b/T04-Project-2/frontend/src/components/useMediaQuery.ts @@ -0,0 +1,12 @@ +import { useSyncExternalStore } from "react"; + +export function useMediaQuery(query: string): boolean { + return useSyncExternalStore( + (onChange) => { + const media = window.matchMedia(query); + media.addEventListener("change", onChange); + return () => media.removeEventListener("change", onChange); + }, + () => window.matchMedia(query).matches, + ); +} diff --git a/T04-Project-2/frontend/src/data/games.ts b/T04-Project-2/frontend/src/data/games.ts index 6339744..ed52b82 100644 --- a/T04-Project-2/frontend/src/data/games.ts +++ b/T04-Project-2/frontend/src/data/games.ts @@ -270,19 +270,20 @@ export const gameCategories = [ { name: "Indie games", highlight: "games", gameIds: indieGameIds }, { name: "Adventure games", highlight: "games", gameIds: adventureGameIds }, { name: "RPG games", highlight: "games", gameIds: rpgGameIds }, - { name: "Strategy games", highlight: "games", gameIds: strategyGameIds }, - { name: "Shooter games", highlight: "games", gameIds: shooterGameIds }, - { name: "Casual games", highlight: "games", gameIds: casualGameIds }, - { name: "Simulation games", highlight: "games", gameIds: simulationGameIds }, - { name: "Puzzle games", highlight: "games", gameIds: puzzleGameIds }, - { name: "Arcade games", highlight: "games", gameIds: arcadeGameIds }, - { name: "Platformer games", highlight: "games", gameIds: platformerGameIds }, - { name: "Massively Multiplayer games", highlight: "games", gameIds: massivelyMultiplayerGameIds }, - { name: "Racing games", highlight: "games", gameIds: racingGameIds }, - { name: "Sports games", highlight: "games", gameIds: sportsGameIds }, - { name: "Fighting games", highlight: "games", gameIds: fightingGameIds }, - { name: "Family games", highlight: "games", gameIds: familyGameIds }, - { name: "Board Games games", highlight: "games", gameIds: boardGamesGameIds }, - { name: "Card games", highlight: "games", gameIds: cardGameIds }, - { name: "Educational games", highlight: "games", gameIds: educationalGameIds }, + // { name: "Strategy games", highlight: "games", gameIds: strategyGameIds }, + // { name: "Shooter games", highlight: "games", gameIds: shooterGameIds }, + // { name: "Casual games", highlight: "games", gameIds: casualGameIds }, + // { name: "Simulation games", highlight: "games", gameIds: simulationGameIds }, + // { name: "Puzzle games", highlight: "games", gameIds: puzzleGameIds }, + // { name: "Arcade games", highlight: "games", gameIds: arcadeGameIds }, + // { name: "Platformer games", highlight: "games", gameIds: platformerGameIds }, + // { name: "Massively Multiplayer games", highlight: "games", gameIds: massivelyMultiplayerGameIds }, + // { name: "Racing games", highlight: "games", gameIds: racingGameIds }, + // { name: "Sports games", highlight: "games", gameIds: sportsGameIds }, + // { name: "Fighting games", highlight: "games", gameIds: fightingGameIds }, + // { name: "Family games", highlight: "games", gameIds: familyGameIds }, + // { name: "Board Games games", highlight: "games", gameIds: boardGamesGameIds }, + // { name: "Card games", highlight: "games", gameIds: cardGameIds }, + // { name: "Educational games", highlight: "games", gameIds: educationalGameIds }, ] as const; + diff --git a/T04-Project-2/frontend/src/data/top5MockGames.ts b/T04-Project-2/frontend/src/data/top5MockGames.ts new file mode 100644 index 0000000..e7e3532 --- /dev/null +++ b/T04-Project-2/frontend/src/data/top5MockGames.ts @@ -0,0 +1,73 @@ +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" }, + ], + }, +]; diff --git a/T04-Project-2/frontend/src/index.css b/T04-Project-2/frontend/src/index.css index ef9ae91..64b5956 100644 --- a/T04-Project-2/frontend/src/index.css +++ b/T04-Project-2/frontend/src/index.css @@ -3,6 +3,8 @@ --dark-text: rgb(18, 16, 16); --subtext: #a09f9f; --bg: rgb(18, 16, 16); + --bg-hover: rgba(48, 48, 48, 0.346); + --top-5-bg: rgb(26, 26, 26); --secondary-bg: rgba(32, 32, 32, 0.305); --hero-bg: var(--bg); --nav-bg: rgba(20, 18, 18, 0.757); @@ -16,10 +18,11 @@ --primary-color: #19d24a; --primary-color-dim: #1e7e38; --border-color: rgb(11, 59, 11); + --border-color-light: rgba(255, 255, 255, 0.14); /* Shared horizontal spacing so every section's content lines up. The max() keeps content from growing wider than --content-max-width on very wide screens. */ - --content-max-width: 100rem; + --content-max-width: 75rem; --page-gutter: max(clamp(1.5rem, 4vw, 5rem), (100% - var(--content-max-width)) / 2); font: 18px/145% var(--sans); @@ -40,6 +43,24 @@ body { margin: 0; } +h3 { + font-size: clamp(1rem, 1.6vw, 1.3rem); + margin: 0; +} +h4 { + font-size: clamp(0.8rem, 1.3vw, 1rem); + margin: 0; +} + +.text-highlight { + color: var(--primary-color); +} +.subtext { + color: var(--subtext); + font-size: clamp(0.8rem, 1.3vw, 1rem); + margin: 0; +} + @media (prefers-reduced-motion: no-preference) { html { scroll-behavior: smooth; @@ -50,7 +71,3 @@ body { [id] { scroll-margin-top: 8rem; } - -.long { - height: 2000px; -} \ No newline at end of file diff --git a/T04-Project-2/frontend/tests/components/FeaturedCarousel.test.tsx b/T04-Project-2/frontend/tests/components/FeaturedCarousel.test.tsx index 3bcd032..3cb09e8 100644 --- a/T04-Project-2/frontend/tests/components/FeaturedCarousel.test.tsx +++ b/T04-Project-2/frontend/tests/components/FeaturedCarousel.test.tsx @@ -10,8 +10,8 @@ import { FavoritesProvider } from '../../src/components/FavoritesProvider' const apiUrl = 'http://localhost:4000/graphql' const games = [ - { id: '1', name: 'Goat Quest', released: '2020-01-02', background_image: null, description: '

Explore as a goat.

' }, - { id: '2', name: 'Goat Racing', released: null, background_image: null, description: null }, + { id: '1', name: 'Goat Quest', released: '2020-01-02', background_image: null, description: '

Explore as a goat.

', rating: 4.2, platforms: [{ id: '4', name: 'PC' }] }, + { id: '2', name: 'Goat Racing', released: null, background_image: null, description: null, rating: null, platforms: [] }, ] const server = setupServer( http.post(apiUrl, async () => {