diff --git a/.gitignore b/.gitignore index db127d7..b07455c 100644 --- a/.gitignore +++ b/.gitignore @@ -22,10 +22,9 @@ dist-ssr *.njsproj *.sln *.sw? -*.env # Coverage Coverage/ #Env file -.env +.env \ No newline at end of file diff --git a/src/components/passivegamecard.tsx b/src/components/passivegamecard.tsx index 110c881..8e0762a 100644 --- a/src/components/passivegamecard.tsx +++ b/src/components/passivegamecard.tsx @@ -9,14 +9,20 @@ import type { PassiveGameData } from '../types/game' type Props = { game: PassiveGameData + rank?: number } -export default function PassiveGameCard({ game }: Props) { +export default function PassiveGameCard({ game, rank }: Props) { const isOnSale = game.salePercent !== undefined && game.salePercent > 0 && game.salePercent <= 100 return ( - + + {rank !== undefined && ( + + #{rank} + + )}
{isOnSale ? `-${game.salePercent}%` : ''}
- {game.price.toFixed(2)} + {game.price.toFixed(2)} $ diff --git a/src/css/mainpage.css b/src/css/mainpage.css index b981a05..7a286c9 100644 --- a/src/css/mainpage.css +++ b/src/css/mainpage.css @@ -5,38 +5,324 @@ } .main-page { + display: flex; + flex: 1; + flex-direction: column; + gap: 2rem; width: min(calc(100% - 3rem), 68rem); margin: 0 auto; padding: 1.75rem 0 clamp(2rem, 8vh, 4rem); } -.featured-games-title { +.main-page-section-heading { display: flex; - align-items: center; min-height: 2.5rem; - margin: 0 0 1.5rem; + align-items: center; + margin-bottom: 0.9rem; +} + +.main-page-section { + margin-inline: 3.25rem; +} + +.main-page-section-heading h1, +.main-page-section-heading h2 { + margin: 0; font-size: 2rem; line-height: 1.2; } +.main-page-section-content.MuiCard-root { + box-sizing: border-box; + width: 100%; + min-width: 0; + padding: clamp(1rem, 3vw, 1.75rem); +} + .featured-games-grid { + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); + grid-template-rows: repeat(2, auto); + align-items: stretch; + column-gap: 1.25rem; + row-gap: 0.875rem; +} + +.featured-games-primary { + grid-row: span 2; + min-height: 0; +} + +.featured-game-card { + position: relative; + width: 100%; + min-width: 0; +} + +.featured-game-card > .passiveGameCard { + width: 100%; +} + +.featured-games-primary > .passiveGameCard { + height: 100%; + min-height: 0; +} + +.featured-games-primary.featured-game-card .passiveGameCardAction { + flex: 1 1 auto; + height: 100%; + min-height: 0; +} + +.featured-games-primary.featured-game-card .passiveGameCardMedia { + flex: 1 1 auto; + min-height: 0; + height: auto; +} + +.featured-game-card .passiveGameCardContent.MuiCardContent-root { + padding-bottom: 0.25rem; +} + +.genre-controls { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 1.25rem; +} + +.genre-autocomplete { + width: min(100%, var(--genre-filter-width, 11rem)); +} + +.genre-autocomplete .MuiOutlinedInput-root { + background-color: var(--primary-background-color); +} + +.genre-autocomplete .MuiOutlinedInput-notchedOutline { + border-color: var(--primary-component-color); +} + +.genre-autocomplete .MuiOutlinedInput-root:hover .MuiOutlinedInput-notchedOutline, +.genre-autocomplete .MuiOutlinedInput-root.Mui-focused .MuiOutlinedInput-notchedOutline { + border-color: var(--primary-accent-color); +} + +.genre-games-row { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); + align-items: stretch; + gap: 1.25rem; +} + +.genre-see-more { + display: flex; + justify-content: flex-end; + margin: -0.5rem 0 0.5rem; +} + +.genre-see-more .MuiButton-root { + color: var(--primary-text-color); +} + +.genre-carousel-controls { + display: flex; + width: 100%; + gap: 1rem; + margin-top: 1.5rem; +} + +.genre-carousel-controls .MuiButton-root { + flex: 1 1 0; + min-width: 0; + min-height: 3.5rem; + background-color: var(--primary-component-color); + color: var(--primary-text-color); + font-size: 1.125rem; +} + +.genre-carousel-controls .MuiButton-root:hover { + background-color: var(--primary-accent-color); +} + +.genre-carousel-controls .MuiButton-root.Mui-disabled { + background-color: var(--secondary-component-color); + color: rgb(0 0 0 / 45%); +} + +.popular-reviews-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-items: stretch; gap: 1.25rem; } +.popular-review { + display: flex; + flex-direction: column; + min-height: 10rem; + padding: 1.1rem; + border: 1px solid var(--primary-component-color); + border-radius: 0.75rem; + background-color: var(--primary-background-color); + box-shadow: 0 2px 8px rgb(0 0 0 / 8%); +} + +.popular-review-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.popular-review-game, +.popular-review-author { + display: block; +} + +.popular-review-game { + font-size: 1.1rem; + font-weight: 700; +} + +.popular-review-author { + margin-top: 0.25rem; + color: #46545b; + font-size: 0.85rem; +} + +.popular-review-rating { + flex: 0 0 auto; + padding: 0.35rem 0.55rem; + border-radius: 0.5rem; + background-color: var(--secondary-component-color); + font-size: 0.85rem; + font-weight: 700; +} + +.popular-review-text { + margin: 1rem 0; + line-height: 1.55; +} + +.popular-review-link { + width: fit-content; + margin-top: auto; + color: #005d8c; + font-weight: 700; + text-decoration-thickness: 1px; + text-underline-offset: 0.2em; +} + +.popular-review-link:focus-visible { + outline: 2px solid var(--secondary-background-color); + outline-offset: 3px; +} + +@media (max-width: 56rem) { + .genre-games-row { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .featured-games-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-rows: auto; + row-gap: 1.25rem; + } + + .featured-games-primary { + grid-column: 1 / -1; + grid-row: auto; + height: auto; + } + + .featured-games-primary > .passiveGameCard { + height: auto; + } + + .featured-games-primary.featured-game-card .passiveGameCardAction { + flex: 0 0 auto; + height: auto; + min-height: 0; + } + + .featured-games-primary.featured-game-card .passiveGameCardMedia { + flex: 0 0 auto; + aspect-ratio: 16 / 9; + } + + .featured-games-primary.featured-game-card .passiveGameCardAction { + flex: 0 0 auto; + height: auto; + min-height: 0; + } + + .featured-games-primary.featured-game-card .passiveGameCardMedia { + flex: 0 0 auto; + } +} + +@media (min-width: 56.01rem) { + .featured-game-secondary .passiveGameCardMedia { + aspect-ratio: 2 / 1; + } + + .featured-game-secondary .passiveGameCardContent.MuiCardContent-root { + flex-basis: 3.5rem; + padding-inline: 0.75rem; + } + + .featured-game-secondary .GameTitle { + height: 1.8em; + font-size: 1rem; + } + + .featured-game-secondary .passiveGameCardTitle { + font-size: 1rem; + } + + .featured-game-secondary .GamePrice { + flex-direction: row; + align-items: center; + gap: 0.5rem; + } +} + @media (max-width: 43.75rem) { .main-page { width: min(calc(100% - 2rem), 32rem); - padding: 1.25rem 0 clamp(1.5rem, 6vh, 2.5rem); + padding: clamp(1.5rem, 6vh, 2.5rem) 0; + } + + .main-page-section { + margin-inline: 0; } - .featured-games-title { + .main-page-section-heading h1, + .main-page-section-heading h2 { font-size: 1.75rem; } .featured-games-grid { + grid-template-columns: minmax(0, 1fr); + } + + .featured-game-card > .passiveGameCard { + width: min(100%, 22rem); + margin-inline: auto; + } + + .genre-games-row { + grid-template-columns: 1fr; + } + + .popular-reviews-grid { grid-template-columns: 1fr; - gap: 0.875rem; + } +} + +@media (max-width: 30rem) { + .genre-autocomplete { + width: 100%; } } diff --git a/src/css/passivegamecard.css b/src/css/passivegamecard.css index b30c15b..2760566 100644 --- a/src/css/passivegamecard.css +++ b/src/css/passivegamecard.css @@ -1,45 +1,101 @@ .passiveGameCard { width: min(100%, 22rem); + justify-self: center; + display: flex; + flex-direction: column; + overflow: hidden; + container-type: inline-size; background-color: var(--primary-background-color); color: var(--primary-text-color); box-shadow: 2px 3px 6px var(--box-shadow-color) !important; + transition: + transform 180ms ease, + box-shadow 180ms ease; +} + +.rankedPassiveGameCard { + position: relative; +} + +.passiveGameCardRank { + position: absolute; + z-index: 1; + top: 0; + left: 0; + padding: 0.5rem 0.75rem; + border-radius: 0 0 0.75rem 0; + background-color: rgb(255 255 255 / 85%); + font-size: 1.1rem; + font-weight: 700; + pointer-events: none; } -.passiveGameCardAction { +.passiveGameCard:hover, +.passiveGameCard:focus-within { + transform: translateY(-4px); + box-shadow: 0 8px 18px rgb(0 0 0 / 20%) !important; +} + +.passiveGameCardAction.MuiCardActionArea-root { + display: flex; + flex: 0 0 auto; + flex-direction: column; + align-items: stretch; width: 100%; } .passiveGameCardMedia { - height: 18vh; - min-height: 10rem; - max-height: 15rem; + flex: 0 0 auto; + width: 100%; + aspect-ratio: 16 / 9; object-fit: cover; + object-position: center; } .passiveGameCardContent.MuiCardContent-root { - display: flex !important; - flex-direction: row !important; - justify-content: space-between !important; - align-items: center !important; - gap: 1rem; - padding: 1rem; + box-sizing: border-box; + flex: 0 0 3.5rem; + min-height: 0; + display: grid !important; + grid-template-columns: minmax(0, 1fr) auto; + align-items: flex-start !important; + gap: 0.5rem; + padding: 0.25rem 1rem; background-color: var(--primary-component-color); color: var(--primary-text-color); } .GameTitle { - flex: 1 1 auto; + position: relative; min-width: 0; + height: 2.2em; + overflow: hidden; + font-size: 1.25rem; text-align: left; } +.GameTitle::after { + position: absolute; + right: 0; + bottom: 0; + left: 0; + height: 0.6em; + background: linear-gradient( + to bottom, + transparent, + color-mix(in srgb, var(--primary-component-color) 90%, transparent) + ); + content: ''; + pointer-events: none; +} + .GamePrice { display: flex; - flex: 0 0 auto; + min-width: 0; + align-self: flex-start; align-items: center; justify-content: flex-end; gap: 0.5rem; - margin-left: auto; text-align: right; white-space: nowrap; } @@ -69,9 +125,34 @@ font-size: 1.25rem; font-weight: 600; line-height: 1.3; + overflow-wrap: normal; + word-break: normal; } .passiveGameCardPrice { margin: 0; color: var(--primary-text-color); } + +@container (max-width: 18rem) { + .passiveGameCardContent.MuiCardContent-root { + flex-basis: 4rem; + } + + .GamePrice { + flex-direction: column-reverse; + align-items: flex-end; + gap: 0.125rem; + } +} + +@media (prefers-reduced-motion: reduce) { + .passiveGameCard { + transition: none; + } + + .passiveGameCard:hover, + .passiveGameCard:focus-within { + transform: none; + } +} diff --git a/src/pages/mainpage.tsx b/src/pages/mainpage.tsx index 15cf303..ff14a13 100644 --- a/src/pages/mainpage.tsx +++ b/src/pages/mainpage.tsx @@ -2,20 +2,312 @@ import '../css/mainpage.css' import Header from '../components/header' import Footer from '../components/footer' import PassiveGameCard from '../components/passivegamecard' -import { games } from '../api/games' +import { WishlistCard } from '../components/WishlistCard' +import { getGames } from '../api/gameClient' +import type { Game } from '../backend/steam' +import { Link } from 'react-router-dom' +import type { CSSProperties } from 'react' +import { useEffect, useMemo, useState } from 'react' +import Autocomplete from '@mui/material/Autocomplete' +import Button from '@mui/material/Button' +import TextField from '@mui/material/TextField' +import useMediaQuery from '@mui/material/useMediaQuery' + +type PageGame = { + id: number + name: string + price: number + genre: string[] + image: string + salePercent: number +} + +function toPageGame(game: Game): PageGame { + const price = Number(game.price) + const salePercent = Number(game.discount) + const genres = + typeof game.genre === 'string' + ? game.genre + .split(',') + .map((genre) => genre.trim()) + .filter(Boolean) + : [] + + return { + id: game.appid, + name: game.name, + price: Number.isFinite(price) ? price : 0, + genre: genres, + image: game.img ?? '', + salePercent: Number.isFinite(salePercent) ? salePercent : 0, + } +} + +type GenreGamesSectionProps = { + title: string + games: PageGame[] + genres: string[] + isLoading: boolean + discountedOnly?: boolean + showSeeMore?: boolean +} + +function GenreGamesSection({ + title, + games, + genres, + isLoading, + discountedOnly = false, + showSeeMore = false, +}: GenreGamesSectionProps) { + const [selectedGenre, setSelectedGenre] = useState('') + const [firstGameIndex, setFirstGameIndex] = useState(0) + const isPhone = useMediaQuery('(max-width: 43.75rem)') + const isTablet = useMediaQuery('(max-width: 56rem)') + const visibleGameCount = isPhone ? 1 : isTablet ? 2 : 3 + const activeGenre = genres.includes(selectedGenre) ? selectedGenre : (genres[0] ?? '') + const longestGenreLength = Math.max(0, ...genres.map((genre) => genre.length)) + const genreGames = games + .filter( + (game) => + game.genre.includes(activeGenre) && + (!discountedOnly || (game.salePercent > 0 && game.salePercent <= 100)), + ) + .slice(0, 10) + const maxStartIndex = Math.max(0, genreGames.length - visibleGameCount) + const startIndex = Math.min(firstGameIndex, maxStartIndex) + const visibleGames = genreGames.slice(startIndex, startIndex + visibleGameCount) + + const selectGenre = (genre: string) => { + setSelectedGenre(genre) + setFirstGameIndex(0) + } + + return ( +
+
+

{title}

+
+ +
+ { + selectGenre(genre ?? '') + }} + disabled={genres.length === 0} + renderInput={(params) => ( + + )} + /> +
+ {showSeeMore && ( +
+ {/* Will add current genre and discount filter that links to SearchPage here */} + +
+ )} +
+ {isLoading ? ( +

Loading games...

+ ) : visibleGames.length > 0 ? ( + visibleGames.map((game) => ) + ) : ( +

No games found for this genre.

+ )} +
+
+ + +
+
+
+ ) +} export default function MainPage() { + const [databaseGames, setDatabaseGames] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + let cancelled = false + + getGames() + .then((loadedGames) => { + if (!cancelled) setDatabaseGames(loadedGames) + }) + .catch((fetchError: unknown) => { + if (!cancelled) { + setError( + fetchError instanceof Error ? fetchError.message : 'Could not load games', + ) + } + }) + .finally(() => { + if (!cancelled) setIsLoading(false) + }) + + return () => { + cancelled = true + } + }, []) + + const pageGames = useMemo(() => databaseGames.map(toPageGame), [databaseGames]) + const featuredGames = pageGames.slice(0, 3) + const genresToFeature = useMemo( + () => [...new Set(pageGames.flatMap((game) => game.genre))].sort(), + [pageGames], + ) + // Temporary placeholder reviews to showcase future functionality, + // attached to fetched games, without adding review data to the actual game cards. + const reviewSamples = [ + { + gameIndex: 0, + author: 'Reviewer1', + rating: '4 / 5', + text: 'A beautiful new world to get lost in, with wonderful, precise combat and secrets around every corner!', + }, + { + gameIndex: 1, + author: 'Reviewer2', + rating: '3 / 5', + text: 'The best kind of co-op chaos! Every trip feels like a story you will be telling for a while.', + }, + ] + const recentReviews = reviewSamples.flatMap((review) => { + const game = pageGames[review.gameIndex] + return game ? [{ ...review, game }] : [] + }) + return (
-
-

Featured Games

-
- {games.slice(0, 3).map((game) => ( - - ))} + {error && ( +

+ Could not load games: {error} +

+ )} +
+
+

Top 3 Games

+
+ + {isLoading ? ( +

Loading games...

+ ) : featuredGames.length === 0 ? ( +

No games found.

+ ) : ( +
+ {featuredGames[0] && ( +
+ +
+ )} + {featuredGames[1] && ( +
+ +
+ )} + {featuredGames[2] && ( +
+ +
+ )} +
+ )} +
+
+ + + + +
+
+

Recent reviews

+ + {isLoading ? ( +

Loading reviews...

+ ) : ( +
+ {recentReviews.map((review) => ( +
+
+
+ + {review.game.name} + + + Review by {review.author} + +
+ + {review.rating} + +
+

“{review.text}”

+ + See this game + +
+ ))} +
+ )} +