diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..a362d123d3c0ccf1b789b77a69bc42bf4e29b613 --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +# Example .env file + +# Database for connecting to Prisma +DATABASE_URL="file:./dev.db" + +# Some URLs +TWITCH_AUTH_BASE_URL="https://id.twitch.tv/oauth2" +IGDB_BASE_URL="https://api.igdb.com/v4" +IGDB_IMG_BASE_URL="https://images.igdb.com/igdb/image/upload" + +# For Authentication +TWITCH_CLIENT_ID="imdb_client_id" +TWITCH_CLIENT_SECRET="imdb_auth_id" \ No newline at end of file diff --git a/.env.sample b/.env.sample deleted file mode 100644 index 6db1037eb2892069bf175c2c0fb52da0788431e8..0000000000000000000000000000000000000000 --- a/.env.sample +++ /dev/null @@ -1,14 +0,0 @@ -# Environment variables declared in this file are automatically made available to Prisma. -# See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema - -# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB. -# See the documentation for all the connection string options: https://pris.ly/d/connection-strings - -DATABASE_URL="postgresql://johndoe:randompassword@localhost:5432/mydb?schema=public" - -TWITCH_AUTH_BASE_URL="https://id.twitch.tv/oauth2" -IGDB_BASE_URL="https://api.igdb.com/v4" -IGDB_IMG_BASE_URL="https://images.igdb.com/igdb/image/upload" - -TWITCH_CLIENT_ID="imdb_client_id" -TWITCH_CLIENT_SECRET="imdb_auth_id" \ No newline at end of file diff --git a/.gitignore b/.gitignore index 45c1abce864b4fd390a16f4a8650a9c0d37827c6..56674d5e498bfd920840f6ad56136490bb696d14 100644 --- a/.gitignore +++ b/.gitignore @@ -25,12 +25,13 @@ yarn-debug.log* yarn-error.log* # local env files -.env*.local .env +.env*.local +.env*.production # vercel .vercel # typescript *.tsbuildinfo -next-env.d.ts +next-env.d.ts \ No newline at end of file diff --git a/app/api/games/route.ts b/app/api/games/route.ts index 0a7388133d827e15f07130339dd35e185de184d1..842198fe639044332f6c6fd4c8b96b76c8f5648e 100644 --- a/app/api/games/route.ts +++ b/app/api/games/route.ts @@ -1,7 +1,8 @@ import { getGames } from "@/lib/igdb"; import { NextRequest, NextResponse } from "next/server"; -export async function GET() { - const games = await getGames(); +export async function GET(req: NextRequest) { + const p = req.nextUrl.searchParams; + const games = await getGames(p.get('page') ? parseInt(p.get('page') as string) : undefined); return NextResponse.json(games); -} +} \ No newline at end of file diff --git a/app/games/Game.tsx b/app/games/Game.tsx index 03e3bb3b9dbf46a29fcfc96c8eb593d57f1884a1..4f72f6879c8fb9611b12aaf314341965e0123111 100644 --- a/app/games/Game.tsx +++ b/app/games/Game.tsx @@ -1,14 +1,17 @@ +import { Card, CardContent, Typography } from "@mui/material"; import Image from "next/image"; import Link from "next/link"; // this is a single game helper-component, only for design purposes -export default function Game({ id, name, cover }: { id: any, name: any, cover: any }) { +export default function Game({ id, name, cover }: { id: number, name: string, cover: { url: string } }) { return ( - <div> - <h1>{name}</h1> + <Card sx={{ maxWidth: 264 }} variant="outlined" > <Link href={`/games/${id}`}> - <Image src={cover.url} alt={name} width={264} height={374} priority={true} /> + <Image src={cover.url} alt={name} width={264} height={374} priority={true} style={{ width: '100%', height: '100%' }} /> </Link> - </div> + <CardContent> + <Typography noWrap={true}>{name}</Typography> + </CardContent> + </Card> ) -} +} \ No newline at end of file diff --git a/app/games/[gameid]/page.tsx b/app/games/[gameid]/page.tsx index d86eb4a9ad8182de8c641587a18e5b9a09ffdeb1..31e205bfa9106eb36b59fb1edc1ade071cecb8cd 100644 --- a/app/games/[gameid]/page.tsx +++ b/app/games/[gameid]/page.tsx @@ -8,7 +8,7 @@ export default async function GameDetail({ params }: { params: { gameid: string return ( <div> - Game Detail + <h1>Game Detail</h1> <h1>{data[0].name}</h1> <Image src={data[0].cover.url} alt={data[0].name} width={264} height={374} priority={true} /> <p>{data[0].summary}</p> diff --git a/app/games/page.tsx b/app/games/page.tsx index 1d520e5a7f7f34901be8a500b25c4a03846bdff7..6f0ce94d557cc1cf9d0df2e308f4f9bcd76d5771 100644 --- a/app/games/page.tsx +++ b/app/games/page.tsx @@ -1,26 +1,65 @@ "use client" +import { getBaseURL } from "@/lib/utils"; import { IGame } from "@/types/types"; -import { useEffect, useState } from "react"; +import { Grid } from "@mui/material"; +import { Fragment } from "react"; +import InfiniteScroll from "react-infinite-scroll-component"; +import { useInfiniteQuery } from "react-query"; import Game from "./Game"; -// renders a list of games +// renders a list of games infinitely (presumably) export default function GamesList() { - const [data, setData] = useState<IGame[]>([]) - - async function load() { - const data = await fetch("http://localhost:3000/api/games").then((res) => res.json()) - setData(data) - } - - useEffect(() => { load() }, []) + const { + data, + error, + fetchNextPage, + hasNextPage, + isFetching, + isFetchingNextPage, + status, + } = useInfiniteQuery( + 'infiniteGames', + async ({ pageParam = 1 }) => + await fetch( + `${getBaseURL()}/api/games/?page=${pageParam}`, + { cache: 'force-cache', } + ).then((result) => result.json() as Promise<IGame[]>), + { + getNextPageParam: (lastPage, pages) => { + return lastPage.length > 0 ? pages.length + 1 : undefined; + }, + } + ); return ( - <div> - Games List Page - {data.map((game: any) => ( - <Game key={game.id} id={game.id} name={game.name} cover={game.cover} /> - ))} - </div> + <> + <h1>Games List Page</h1> + {status === 'success' && ( + <InfiniteScroll + dataLength={data?.pages.length * 20} + next={fetchNextPage} + hasMore={hasNextPage ? true : false} + loader={<h4>Loading...</h4>} + endMessage={ + <p style={{ textAlign: 'center' }}> + <b>Yay! You have seen it all</b> + </p> + } + > + <Grid container spacing={2} justifyContent="center"> + {data?.pages.map((page, i) => ( + <Fragment key={i}> + {page.map((game: IGame) => ( + <Grid item xs={12} ss={6} sm={4} md={3} lg={2} key={game.id}> + <Game id={game.id} name={game.name} cover={game.cover} key={game.id} /> + </Grid> + ))} + </Fragment> + ))} + </Grid> + </InfiniteScroll> + )} + </> ) -} +} \ No newline at end of file diff --git a/app/layout.tsx b/app/layout.tsx index f4f1f87745551aa53bcbec9fc1f5de851bb7ad70..70082c1bd30f3c48711e389aadb29b16b0f58ca5 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,13 +1,12 @@ "use client" -import { Container } from '@mui/material' -import { Inter } from 'next/font/google' - -const inter = Inter({ subsets: ['latin'] }) +import { Container, CssBaseline, ThemeProvider, createTheme, useMediaQuery } from "@mui/material" +import { useMemo, useState } from "react" +import { QueryClient, QueryClientProvider } from "react-query" export const metadata = { - title: 'Create Next App', - description: 'Generated by create next app', + title: 'GameUnity', + description: 'Soon', } // this is the root layout for all pages ({children}) @@ -16,13 +15,48 @@ export default function RootLayout({ }: { children: React.ReactNode }) { + const [queryClient] = useState(() => new QueryClient()); + + const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)'); + const theme = useMemo( + () => + createTheme({ + palette: { + mode: prefersDarkMode ? 'dark' : 'light', + }, + breakpoints: { + values: { + xs: 0, + ss: 300, + sm: 600, + md: 900, + lg: 1200, + xl: 1536, + }, + }, + }), + [prefersDarkMode], + ); + return ( <html lang="en"> - <body className={inter.className}> - <Container> - {children} - </Container> - </body> + <QueryClientProvider client={queryClient}> + <ThemeProvider theme={theme}> + <CssBaseline /> + <body> + <Container> + {children} + </Container> + </body> + </ThemeProvider> + </QueryClientProvider> </html> ) } + +// custom super small breakpoint for responsive design +declare module '@mui/material/styles' { + interface BreakpointOverrides { + ss: true; + } +} \ No newline at end of file diff --git a/app/page.tsx b/app/page.tsx index 04c430ecc443342adecca74825744ac046752e6b..bca80d64f653278e0dbc026966da87b02b451e5c 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,10 +1,14 @@ +import Link from "next/link"; + // renders home page export default function Home() { return ( <main> <div> - Hello World! + <h1>Welcome to GameUnity!</h1> + <p>This will be our Home Page and is still WIP</p> + <Link href="/games">Games List Progress</Link> </div> </main> ) -} +} \ No newline at end of file diff --git a/lib/igdb.ts b/lib/igdb.ts index f9a5396b05ec82ab464207d1dcd100a885f0bfbb..82e3f411ae739ede067b5b797e2cb628e9c36ed4 100644 --- a/lib/igdb.ts +++ b/lib/igdb.ts @@ -1,5 +1,5 @@ import { IAuth, IGame } from "@/types/types" -import { getImageURL } from "./utils" +import { calculateOffset, getImageURL } from "./utils" const TWITCH_AUTH_BASE_URL = process.env.TWITCH_AUTH_BASE_URL ?? '' const IGDB_BASE_URL = process.env.IGDB_BASE_URL ?? '' @@ -7,6 +7,8 @@ const IGDB_BASE_URL = process.env.IGDB_BASE_URL ?? '' const CLIENT_ID = process.env.TWITCH_CLIENT_ID ?? '' const CLIENT_SECRET = process.env.TWITCH_CLIENT_SECRET ?? '' +const limit = 200 + let _auth: IAuth let _lastUpdate = 0 @@ -26,21 +28,24 @@ async function getToken(): Promise<IAuth> { } // fetches the top 200 games with a rating of 96 or higher -export async function getGames(offset = 0): Promise<IGame[]> { +export async function getGames(page = 1): Promise<IGame[]> { const auth = await getToken() const url = new URL(`${IGDB_BASE_URL}/games`) + let offset = calculateOffset(page, limit) + const response = await fetch(url, { method: 'POST', headers: { 'Client-ID': CLIENT_ID, 'Authorization': `Bearer ${auth.access_token}` }, - body: `fields name, cover.*; limit 200; offset ${offset}; - sort total_rating desc; where total_rating_count > 200 + body: `fields name, cover.*; limit ${limit}; offset ${offset}; + sort total_rating desc; where total_rating_count > 2 & cover != null & total_rating != null & rating != null;` }) const games = await response.json() as IGame[] + games.forEach(game => { game.cover.url = getImageURL(game.cover.image_id, 'cover_big') }) diff --git a/lib/utils.ts b/lib/utils.ts index be0edbb6695d7da5969de6dcc651f18435348d4b..1aebb959232883d237e08ef11bfdf6063931f559 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -3,4 +3,18 @@ const IGDB_IMG_BASE_URL = process.env.IGDB_IMG_BASE_URL ?? '' // changes the default size of the image to be fetched export function getImageURL(hashId: string, size: string): string { return `${IGDB_IMG_BASE_URL}/t_${size}_2x/${hashId}.jpg` +} + +// returns the base url for the current environment, even considering current port +export function getBaseURL(): string { + return process.env.NODE_ENV === 'production' + ? process.env.PROD_URL ?? '' + : (typeof window !== 'undefined' + ? `http://${window.location.hostname}:${window.location.port}` + : 'http://localhost:3000') +} + +// calculates the offset for the query +export function calculateOffset(page: number, limit: number): number { + return (page - 1) * limit } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 829893129ae12ce256cc9d9ed46ca48b2211c1aa..76e06540a7c41d39bf085e0e71412c5c96ea4014 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@emotion/react": "^11.11.0", "@emotion/styled": "^11.11.0", "@mui/material": "^5.13.0", - "@types/node": "20.1.3", + "@types/node": "20.1.4", "@types/react": "18.2.6", "@types/react-dom": "18.2.4", "eslint": "8.40.0", @@ -20,6 +20,8 @@ "next": "13.4.2", "react": "18.2.0", "react-dom": "18.2.0", + "react-infinite-scroll-component": "^6.1.0", + "react-query": "^3.39.3", "typescript": "5.0.4" }, "devDependencies": { @@ -1076,9 +1078,9 @@ "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==" }, "node_modules/@types/node": { - "version": "20.1.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.1.3.tgz", - "integrity": "sha512-NP2yfZpgmf2eDRPmgGq+fjGjSwFgYbihA8/gK+ey23qT9RkxsgNTZvGOEpXgzIGqesTYkElELLgtKoMQTys5vA==" + "version": "20.1.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.1.4.tgz", + "integrity": "sha512-At4pvmIOki8yuwLtd7BNHl3CiWNbtclUbNtScGx4OHfBd4/oWoJC8KRCIxXwkdndzhxOsPXihrsOoydxBjlE9Q==" }, "node_modules/@types/node-fetch": { "version": "2.6.2", @@ -1533,6 +1535,21 @@ "node": ">=8" } }, + "node_modules/broadcast-channel": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/broadcast-channel/-/broadcast-channel-3.7.0.tgz", + "integrity": "sha512-cIAKJXAxGJceNZGTZSBzMxzyOn72cVgPnKx4dc6LRjQgbaJUQqhy5rzL3zbMxkMWsGKkv2hSFkPRMEXfoMZ2Mg==", + "dependencies": { + "@babel/runtime": "^7.7.2", + "detect-node": "^2.1.0", + "js-sha3": "0.8.0", + "microseconds": "0.2.0", + "nano-time": "1.0.0", + "oblivious-set": "1.0.0", + "rimraf": "3.0.2", + "unload": "2.2.0" + } + }, "node_modules/bundle-name": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-3.0.0.tgz", @@ -1855,6 +1872,11 @@ "node": ">=0.4.0" } }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==" + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -3296,6 +3318,11 @@ "url": "https://opencollective.com/js-sdsl" } }, + "node_modules/js-sha3": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz", + "integrity": "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q==" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -3440,6 +3467,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/match-sorter": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/match-sorter/-/match-sorter-6.3.1.tgz", + "integrity": "sha512-mxybbo3pPNuA+ZuCUhm5bwNkXrJTbsk5VWbR5wiwz/GC6LIiegBGn2w3O08UG/jdbYLinw51fSQ5xNU1U3MgBw==", + "dependencies": { + "@babel/runtime": "^7.12.5", + "remove-accents": "0.4.2" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -3465,6 +3501,11 @@ "node": ">=8.6" } }, + "node_modules/microseconds": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/microseconds/-/microseconds-0.2.0.tgz", + "integrity": "sha512-n7DHHMjR1avBbSpsTBj6fmMGh2AGrifVV4e+WYc3Q9lO+xnSZ3NyhcBND3vzzatt05LFhoKFRxrIyklmLlUtyA==" + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -3519,6 +3560,14 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, + "node_modules/nano-time": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/nano-time/-/nano-time-1.0.0.tgz", + "integrity": "sha512-flnngywOoQ0lLQOTRNexn2gGSNuM9bKj9RZAWSzhQ+UJYaAFG9bac4DW9VHjUAzrOaIcajHybCTHe/bkvozQqA==", + "dependencies": { + "big-integer": "^1.6.16" + } + }, "node_modules/nanoid": { "version": "3.3.6", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz", @@ -3746,6 +3795,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/oblivious-set": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/oblivious-set/-/oblivious-set-1.0.0.tgz", + "integrity": "sha512-z+pI07qxo4c2CulUHCDf9lcqDlMSo72N/4rLUpRXf6fu+q8vjt8y0xS+Tlf8NTJDdTXHbdeO1n3MlbctwEoXZw==" + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -4047,11 +4101,47 @@ "react": "^18.2.0" } }, + "node_modules/react-infinite-scroll-component": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/react-infinite-scroll-component/-/react-infinite-scroll-component-6.1.0.tgz", + "integrity": "sha512-SQu5nCqy8DxQWpnUVLx7V7b7LcA37aM7tvoWjTLZp1dk6EJibM5/4EJKzOnl07/BsM1Y40sKLuqjCwwH/xV0TQ==", + "dependencies": { + "throttle-debounce": "^2.1.0" + }, + "peerDependencies": { + "react": ">=16.0.0" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "node_modules/react-query": { + "version": "3.39.3", + "resolved": "https://registry.npmjs.org/react-query/-/react-query-3.39.3.tgz", + "integrity": "sha512-nLfLz7GiohKTJDuT4us4X3h/8unOh+00MLb2yJoGTPjxKs2bc1iDhkNx2bd5MKklXnOD3NrVZ+J2UXujA5In4g==", + "dependencies": { + "@babel/runtime": "^7.5.5", + "broadcast-channel": "^3.4.1", + "match-sorter": "^6.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, "node_modules/react-transition-group": { "version": "4.4.5", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", @@ -4088,6 +4178,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/remove-accents": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.4.2.tgz", + "integrity": "sha512-7pXIJqJOq5tFgG1A2Zxti3Ht8jJF337m4sowbuHsW30ZnkQFnDzy9qBNhgzX8ZLW4+UBcXiiR7SwR6pokHsxiA==" + }, "node_modules/resolve": { "version": "1.22.2", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.2.tgz", @@ -4587,6 +4682,14 @@ "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==" }, + "node_modules/throttle-debounce": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-2.3.0.tgz", + "integrity": "sha512-H7oLPV0P7+jgvrk+6mwwwBDmxTaxnu9HMXmloNLXwnNO0ZxZ31Orah2n8lU1eMPvsaowP2CX+USCgyovXfdOFQ==", + "engines": { + "node": ">=8" + } + }, "node_modules/titleize": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/titleize/-/titleize-3.0.0.tgz", @@ -4734,6 +4837,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/unload": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unload/-/unload-2.2.0.tgz", + "integrity": "sha512-B60uB5TNBLtN6/LsgAf3udH9saB5p7gqJwcFfbOEZ8BcBHnGwCf6G/TGiEqkRAxX7zAFIUtzdrXQSdL3Q/wqNA==", + "dependencies": { + "@babel/runtime": "^7.6.2", + "detect-node": "^2.0.4" + } + }, "node_modules/untildify": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", diff --git a/package.json b/package.json index 5e9df3419be2d73be3993a51e1d4f26fb2112f80..9bd9dd31dde95e3fd0299295f4fcf92c3932a977 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "@emotion/react": "^11.11.0", "@emotion/styled": "^11.11.0", "@mui/material": "^5.13.0", - "@types/node": "20.1.3", + "@types/node": "20.1.4", "@types/react": "18.2.6", "@types/react-dom": "18.2.4", "eslint": "8.40.0", @@ -21,6 +21,8 @@ "next": "13.4.2", "react": "18.2.0", "react-dom": "18.2.0", + "react-infinite-scroll-component": "^6.1.0", + "react-query": "^3.39.3", "typescript": "5.0.4" }, "devDependencies": {