Skip to content
Snippets Groups Projects
Commit b7d79d8c authored by Yusuf Akgül's avatar Yusuf Akgül :hatching_chick:
Browse files

Merge branch 'FeatureGamesList' into 'main'

Feature games list

See merge request !1
parents 5d695c64 f93cb9d2
No related branches found
No related tags found
1 merge request!1Feature games list
Pipeline #32982 passed
# 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
# 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="file:./dev.db"
IMDB_CLIENT_ID="imdb-client-id"
IMDB_AUTH="Bearer imdb-auth-id"
\ No newline at end of file
......@@ -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
import { getGames } from "@/lib/igdb";
import { NextRequest, NextResponse } from "next/server";
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
import { Card, CardContent, Typography } from "@mui/material";
import Image from "next/image";
import Link from "next/link";
export default function Game({ id, name, cover }: { id: any, name: any, cover: any }) {
// this is a single game helper-component, only for design purposes
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={"https:" + cover.url} alt={name} width={200} height={200} />
<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
// loading component, this renders when loading in /games happens
export default function Loading() {
return (
<div>
......
import { getGame, getGames } from "@/lib/igdb";
import { IGame } from "@/types/types";
import Image from "next/image";
type DetailView = {
id: number;
name: string;
cover: { url: string };
summary: string;
}
type DetailViewArray = DetailView[];
export default async function GameDetail({ params }: { params: any }) {
const res = await fetch("https://api.igdb.com/v4/games", {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Client-ID': `${process.env.IMDB_CLIENT_ID}`,
'Authorization': `${process.env.IMDB_AUTH}`,
},
body: `fields name,cover.*,summary; where cover != null; where id = ${params.gameid};`,
});
const data: DetailViewArray = await res.json()
// renders a single game detail page
export default async function GameDetail({ params }: { params: { gameid: string } }) {
const data: IGame[] = await getGame(parseInt(params.gameid))
return (
<div>
Game Detail
<h1>Game Detail</h1>
<h1>{data[0].name}</h1>
<Image src={"https:" + data[0].cover.url} alt={data[0].name} width={200} height={200} priority />
<Image src={data[0].cover.url} alt={data[0].name} width={264} height={374} priority={true} />
<p>{data[0].summary}</p>
</div>
)
}
// pre-renders static paths for all fetched games for faster page loads
export async function generateStaticParams() {
const games = await getGames()
return games.map((game) => ({
gameid: game.id.toString(),
}));
}
\ No newline at end of file
// root loading component, this renders when any loading happens
export default function Loading() {
return (
<div>
......
import Game from "./Game";
type DetailView = {
id: number;
name: string;
cover: { url: string };
summary: string;
}
"use client"
type DetailViewArray = DetailView[];
import { getBaseURL } from "@/lib/utils";
import { IGame } from "@/types/types";
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";
export default async function GamesList() {
const res = await fetch("https://api.igdb.com/v4/games", {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Client-ID': `${process.env.IMDB_CLIENT_ID}`,
'Authorization': `${process.env.IMDB_AUTH}`,
},
body: `fields name,cover.*; limit 40; where cover != null;`,
});
const data: DetailViewArray = await res.json()
// renders a list of games infinitely (presumably)
export default function GamesList() {
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
import { Inter } from 'next/font/google'
"use client"
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})
export default function RootLayout({
children,
}: {
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}>{children}</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
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
import { IAuth, IGame } from "@/types/types"
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 ?? ''
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
// fetches a new token if the current one is expired
async function getToken(): Promise<IAuth> {
if (!_auth || Date.now() - _lastUpdate > _auth.expires_in) {
const url = new URL(`${TWITCH_AUTH_BASE_URL}/token`)
url.searchParams.set('client_id', CLIENT_ID)
url.searchParams.set('client_secret', CLIENT_SECRET)
url.searchParams.set('grant_type', 'client_credentials')
const response = await fetch(url, { method: 'POST' })
_auth = await response.json() as IAuth
_lastUpdate = Date.now()
}
return _auth
}
// fetches the top 200 games with a rating of 96 or higher
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 ${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')
})
return games
}
// fetches a single game by id
export async function getGame(id: number): Promise<IGame[]> {
const auth = await getToken()
const url = new URL(`${IGDB_BASE_URL}/games`)
const response = await fetch(url, {
method: 'POST',
headers: {
'Client-ID': CLIENT_ID,
'Authorization': `Bearer ${auth.access_token}`
},
body: `fields name, cover.*, summary; where cover != null; where id = ${id};`
})
const games = await response.json() as IGame[]
games.forEach(game => {
game.cover.url = getImageURL(game.cover.image_id, 'cover_big')
})
return games
}
\ No newline at end of file
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
This diff is collapsed.
export enum EAgeRatingCategory {
'ESRB' = 0,
'PEGI' = 2,
'CERO' = 3,
'USK' = 4,
'GRAC' = 5,
'CLASS_IND' = 6,
'ACB' = 7,
}
export enum EAgeRatingRating {
'Three' = 1,
'Seven' = 2,
'Twelve' = 3,
'Sixteen' = 4,
'Eighteen' = 5,
'RP' = 6,
'EC' = 7,
'E' = 8,
'E10' = 9,
'T' = 10,
'M' = 11,
'AO' = 12,
'CERO_A' = 13,
'CERO_B' = 14,
'CERO_C' = 15,
'CERO_D' = 16,
'CERO_Z' = 17,
'USK_0' = 18,
'USK_6' = 19,
'USK_12' = 20,
'USK_16' = 21,
'USK_18' = 22,
'GRAC_ALL' = 23,
'GRAC_Twelve' = 24,
'GRAC_Fifteen' = 25,
'GRAC_Eighteen' = 26,
'GRAC_TESTING' = 27,
'CLASS_IND_L' = 28,
'CLASS_IND_Ten' = 29,
'CLASS_IND_Twelve' = 30,
'CLASS_IND_Fourteen' = 31,
'CLASS_IND_Sixteen' = 32,
'CLASS_IND_Eighteen' = 33,
'ACB_G' = 34,
'ACB_PG' = 35,
'ACB_M' = 36,
'ACB_MA15' = 37,
'ACB_R18' = 38,
'ACB_RC' = 39,
}
export enum EGameCategory {
'main_game' = 0,
'dlc_addon' = 1,
'expansion' = 2,
'bundle' = 3,
'standalone_expansion' = 4,
'mod' = 5,
'episode' = 6,
'season' = 7,
'remake' = 8,
'remaster' = 9,
'expanded_game' = 10,
'port' = 11,
'fork' = 12,
'pack' = 13,
'update' = 14,
}
export enum EGameStatus {
'released' = 0,
'alpha' = 2,
'beta' = 3,
'early_access' = 4,
'offline' = 5,
'cancelled' = 6,
'rumored' = 7,
'delisted' = 8,
}
export interface IAuth {
access_token: string;
expires_in: number;
token_type: 'bearer';
}
export interface IGame {
id: number;
age_ratings: EAgeRatingCategory[];
aggregrated_rating: number;
aggregrated_rating_count: number;
alternative_names: number[];
artworks: number[];
bundles: number[];
category: EGameCategory;
checksum: string;
collection: number;
cover: ICover;
created_at: number;
dlcs: number[];
expanded_games: number[];
expansions: number[];
external_games: number[];
first_release_date: number;
follows: number;
forks: number[];
franchise: number;
franchises: number[];
game_engines: number[];
game_localizations: number[];
game_modes: number[];
genres: number[];
hypes: number;
involved_companies: number[];
keywords: number[];
language_supports: number[];
multiplayer_modes: number[];
name: string;
parent_game: string;
platforms: number[];
player_perspectives: number[];
ports: number[];
rating: number;
rating_count: number;
release_dates: number[];
remakes: number[];
remasters: number[];
screenshots: number[];
similar_games: number[];
slug: string;
standalone_expansions: number[];
status: EGameStatus;
storyline: string;
summary: string;
tags: number[];
themes: number[];
total_rating: number;
total_rating_count: number;
updated_at: number;
url: string;
version_parent: number;
version_title: string;
videos: number[];
websites: number[];
}
export interface ICover {
id: number;
alpha_channel: boolean;
animated: boolean;
checksum: string;
game: number;
game_localization: number;
height: number;
image_id: string;
url: string;
width: number;
}
\ No newline at end of file
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment