diff --git a/package-lock.json b/package-lock.json index d4a2083..529494e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@emotion/styled": "^11.14.1", "@material/card": "^14.0.0", "@mui/material": "^9.4.0", - "dotenv": "^18.0.5", + "dotenv": "^18.0.7", "graphql": "^16.14.2", "lucide-react": "^1.48.0", "pg": "^8.23.1", @@ -341,6 +341,7 @@ "version": "7.29.7", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -641,6 +642,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -685,6 +687,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } @@ -739,6 +742,7 @@ "node_modules/@emotion/react": { "version": "11.14.0", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -776,6 +780,7 @@ "node_modules/@emotion/styled": { "version": "11.14.1", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -1565,9 +1570,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1585,9 +1587,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1605,9 +1604,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1625,9 +1621,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1645,9 +1638,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1665,9 +1655,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1823,8 +1810,7 @@ "node_modules/@types/aria-query": { "version": "5.0.4", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/chai": { "version": "5.2.3", @@ -1890,6 +1876,7 @@ "node_modules/@types/react": { "version": "19.3.0", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1898,6 +1885,7 @@ "version": "19.3.0", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.3.0" } @@ -1948,6 +1936,7 @@ "version": "8.70.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.70.1", "@typescript-eslint/types": "8.70.1", @@ -2162,6 +2151,7 @@ "integrity": "sha512-3ffHBEi8DOOBLwIGBhOBZbRfYFYWjMUuxZicONdhuFEFEG5AVsMOSrVeuROskqnqpbOmEJwxM2eTVZ9N3a1t+A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/istanbul-lib-coverage": "^1.0.0", @@ -2258,6 +2248,7 @@ "version": "8.18.0", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2292,7 +2283,6 @@ "version": "5.0.1", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -2301,7 +2291,6 @@ "version": "5.2.0", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -2470,6 +2459,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.11.23", "caniuse-lite": "^1.0.30001810", @@ -2749,8 +2739,33 @@ "node_modules/dom-accessibility-api": { "version": "0.5.16", "dev": true, + "license": "MIT" + }, + + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", "license": "MIT", - "peer": true + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/dotenv": { + "version": "18.0.7", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-18.0.7.tgz", + "integrity": "sha512-QEvMEF8ca3GNZJJnAbOgxpj5ULhBvnMcURp+XKrtvqFNPgjBvYoswQ0Jl2cO7JzCjxZjJhr9ZuIEhlrRxMft/Q==", + "license": "BSD-2-Clause", + "bin": { + "dotenv": "dist/index.cjs" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } }, "node_modules/dunder-proto": { "version": "1.0.1", @@ -2846,6 +2861,7 @@ "version": "10.11.0", "dev": true, "license": "MIT", + "peer": true, "workspaces": [ "packages/*" ], @@ -3240,6 +3256,7 @@ "node_modules/graphql": { "version": "16.14.2", "license": "MIT", + "peer": true, "engines": { "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } @@ -3565,6 +3582,7 @@ "version": "5.6.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@keyv/serialize": "^1.1.1" } @@ -3722,9 +3740,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3746,9 +3761,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3770,9 +3782,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3794,9 +3803,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3916,7 +3922,6 @@ "version": "1.5.0", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -4223,6 +4228,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz", "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.14.1", "pg-pool": "^3.14.0", @@ -4421,7 +4427,6 @@ "version": "27.5.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -4498,6 +4503,7 @@ "node_modules/react": { "version": "19.3.0", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -4505,6 +4511,7 @@ "node_modules/react-dom": { "version": "19.3.0", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.28.0" }, @@ -4515,8 +4522,7 @@ "node_modules/react-is": { "version": "17.0.2", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-router": { "version": "7.18.4", @@ -5048,6 +5054,7 @@ "version": "6.0.3", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5146,6 +5153,7 @@ "version": "8.3.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", @@ -5224,6 +5232,7 @@ "integrity": "sha512-7MQrx9pDv5aHiUcovIb/70Ys3tgtkUVgCtledvKdCmEO+/1Dicq5ZqoSxOW034m03oqC+oHOKui2dM6qtMLoJg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/mocker": "5.0.3", @@ -5437,6 +5446,7 @@ "version": "4.6.5", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index f8ffc15..d9b5d14 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "@emotion/styled": "^11.14.1", "@material/card": "^14.0.0", "@mui/material": "^9.4.0", - "dotenv": "^18.0.5", + "dotenv": "^18.0.7", "graphql": "^16.14.2", "lucide-react": "^1.48.0", "pg": "^8.23.1", diff --git a/src/api/gameClient.ts b/src/api/gameClient.ts index 5fb9f35..349fc4e 100644 --- a/src/api/gameClient.ts +++ b/src/api/gameClient.ts @@ -4,7 +4,81 @@ interface GraphQLResponse { data?: T errors?: { message: string }[] } + +const CACHE_TTL_MS = 12 * 60 * 60 * 1000 +const GAME_PAGE_SIZE = 500 + +interface CachedGames { + fetchedAt: number + games: Game[] +} + +function isGame(value: unknown): value is Game { + if (typeof value !== 'object' || value === null) return false + + const game = value as Record + return ( + typeof game.appid === 'number' && + typeof game.name === 'string' && + typeof game.price === 'string' && + typeof game.initialprice === 'string' && + typeof game.discount === 'string' && + typeof game.img === 'string' && + typeof game.genre === 'string' && + typeof game.description === 'string' + ) +} + +function isCachedGames(value: unknown): value is CachedGames { + if (typeof value !== 'object' || value === null) return false + + const cache = value as Record + return ( + typeof cache.fetchedAt === 'number' && + Array.isArray(cache.games) && + cache.games.every(isGame) + ) +} + +function gamesCacheKey(limit: number, offset: number): string { + return `games:v1:${limit}:${offset}` +} + +function readCachedGames(key: string): Game[] | null { + try { + const rawCache = globalThis.localStorage?.getItem(key) + if (!rawCache) return null + + const cache: unknown = JSON.parse(rawCache) + const age = Date.now() - (isCachedGames(cache) ? cache.fetchedAt : 0) + if (isCachedGames(cache) && age >= 0 && age < CACHE_TTL_MS) { + return cache.games + } + + globalThis.localStorage.removeItem(key) + } catch (error) { + console.warn('Unable to read games from localStorage; fetching from the database.', error) + } + + return null +} + +function writeCachedGames(key: string, games: Game[]): void { + try { + globalThis.localStorage?.setItem( + key, + JSON.stringify({ fetchedAt: Date.now(), games } satisfies CachedGames), + ) + } catch (error) { + console.warn('Unable to cache games in localStorage.', error) + } +} + async function fetchGamesFromDB(limit: number, offset: number): Promise { + const cacheKey = gamesCacheKey(limit, offset) + const cachedGames = readCachedGames(cacheKey) + if (cachedGames) return cachedGames + const response = await fetch('http://it2810-10.idi.ntnu.no:3001/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -38,9 +112,25 @@ async function fetchGamesFromDB(limit: number, offset: number): Promise if (errors?.length) throw new Error(errors[0].message) if (!data) throw new Error('No data returned') + writeCachedGames(cacheKey, data.games) return data.games } +export function getGames(): Promise { + return fetchAllGames() +} + +async function fetchAllGames(): Promise { + const games: Game[] = [] + + for (let offset = 0; ; offset += GAME_PAGE_SIZE) { + const page = await fetchGamesFromDB(GAME_PAGE_SIZE, offset) + games.push(...page) + + if (page.length < GAME_PAGE_SIZE) return games + } +} + export default fetchGamesFromDB //This is an example of how to fetch information diff --git a/src/css/gamepage.css b/src/css/gamepage.css index 4e76ae9..71e5c2f 100644 --- a/src/css/gamepage.css +++ b/src/css/gamepage.css @@ -6,7 +6,7 @@ .game-page { box-sizing: border-box; - width: min(calc(100% - 3rem), 68rem); + width: min(calc(100% - 3rem), 75rem); margin: 0 auto; padding-left: 3.25rem; } diff --git a/src/css/pageheading.css b/src/css/pageheading.css index c4db99e..5786b55 100644 --- a/src/css/pageheading.css +++ b/src/css/pageheading.css @@ -4,7 +4,7 @@ align-items: center; justify-content: space-between; gap: 1.5rem; - width: min(calc(100% - 3rem), 68rem); + width: min(calc(100% - 3rem), 75rem); margin: 1.75rem auto 1.5rem; } @@ -75,4 +75,4 @@ .pageHeadingActions { max-width: 100%; } -} \ No newline at end of file +} diff --git a/src/css/profilepage.css b/src/css/profilepage.css index c949517..5b216a3 100644 --- a/src/css/profilepage.css +++ b/src/css/profilepage.css @@ -1,6 +1,6 @@ .profileContent { box-sizing: border-box; - width: min(calc(100% - 3rem), 68rem); + width: min(calc(100% - 3rem), 75rem); margin: 0 auto; padding-left: 3.25rem; } diff --git a/src/css/searchpage.css b/src/css/searchpage.css index a1ebca4..b7be06b 100644 --- a/src/css/searchpage.css +++ b/src/css/searchpage.css @@ -74,3 +74,22 @@ background-color: var(--secondary-component-color); color: var(--primary-accent-color); } + +.games-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 4rem; + padding: 2rem; + align-self: center; + width: min(calc(100% - 3rem), 68rem); +} + +.pageinationSearch { + align-self: center; + padding-bottom: 1rem; +} + +.pageinationSearchTop { + margin-top: 1.5rem; + align-self: center; +} diff --git a/src/pages/searchpage.tsx b/src/pages/searchpage.tsx index 1a6f30e..6ebfc43 100644 --- a/src/pages/searchpage.tsx +++ b/src/pages/searchpage.tsx @@ -1,10 +1,13 @@ import '../css/searchpage.css' import Header from '../components/header' import Footer from '../components/footer' -import { TextField, Button, ButtonGroup } from '@mui/material' +import { TextField, Button, ButtonGroup, Pagination } from '@mui/material' import { Funnel } from 'lucide-react' -import { useState } from 'react' +import { useMemo, useState, useEffect } from 'react' import PageHeading from '../components/pageheading' +import PassiveGameCard from '../components/passivegamecard' +import { getGames } from '../api/gameClient' +import type { Game } from '../backend/steam' export function SearchPage() { const [isFilterActive, setIsFilterActive] = useState(false) @@ -21,6 +24,40 @@ export function SearchPage() { const toggleFilterButton = (index: number) => { setActiveFilterButton((activeIndex) => (activeIndex === index ? null : index)) } + const [games, setGames] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + let cancelled = false + + getGames() + .then((loadedGames) => { + if (!cancelled) setGames(loadedGames) + }) + .catch((err: unknown) => { + if (!cancelled) { + setError(err instanceof Error ? err.message : 'Could not load games') + } + }) + .finally(() => { + if (!cancelled) setIsLoading(false) + }) + + return () => { + cancelled = true + } + }, []) + + //decides games per page and makes a constant for the pagination + const gamesPerPage = 12 + const pageCount = Math.max(1, Math.ceil(games.length / gamesPerPage)) + const [page, setPage] = useState(1) + + const visibleGames = useMemo(() => { + const start = (page - 1) * gamesPerPage + return games.slice(start, start + gamesPerPage) + }, [games, page]) return (
@@ -71,6 +108,49 @@ export function SearchPage() {
)} + { + setPage(value) + }} + /> + {error &&

Could not load games: {error}

} + {isLoading ? ( +

Loading games...

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

No games found.

+ ) : ( +
+ {visibleGames.map((game) => { + const price = Number(game.price) + const salePercent = Number(game.discount) + + return ( + + ) + })} +
+ )} + { + setPage(value) + window.scrollTo({ top: 0, behavior: 'smooth' }) + }} + />