├── .eslintignore ├── screenshot.png ├── public ├── favicon.ico ├── favicon-16x16.png ├── favicon-32x32.png ├── apple-touch-icon.png ├── assets │ ├── placeholder.avif │ ├── icon-play.svg │ ├── icon-nav-tv-series.svg │ ├── icon-category-tv.svg │ ├── logo.svg │ ├── icon-search.svg │ ├── icon-nav-home.svg │ ├── icon-nav-movies.svg │ ├── icon-nav-bookmark.svg │ ├── icon-category-movie.svg │ ├── icon-bookmark-full.svg │ └── icon-bookmark-empty.svg ├── android-chrome-192x192.png ├── android-chrome-512x512.png └── site.webmanifest ├── lib ├── rawg.ts ├── helpers │ └── index.ts ├── types │ └── index.ts ├── animations │ └── index.ts └── routes.tsx ├── postcss.config.js ├── components ├── PageHeading.tsx ├── PageList.tsx ├── GameDetail.tsx ├── GamesListContainer.tsx ├── Footer.tsx ├── Logo.tsx ├── Description.tsx ├── Banner.tsx ├── FollowButton.tsx ├── Divider.tsx ├── Message.tsx ├── PageItem.tsx ├── SignOutButton.tsx ├── SignInButton.tsx ├── MessangerInput.tsx ├── GameSeriesList.tsx ├── Layout.tsx ├── MainNav.tsx ├── PagesNav.tsx ├── LoadingCard.tsx ├── ErrorCard.tsx ├── TrendingGamesList.tsx ├── Drawer.tsx ├── TrendingGameCard.tsx ├── Screenshots.tsx ├── MessangerUsers.tsx ├── ScreenshotModal.tsx ├── FollowersTab.tsx ├── CollectionsDropdown.tsx ├── UserProfile.tsx ├── Navbar.tsx ├── CollectionItem.tsx ├── GameCard.tsx ├── GamesList.tsx └── SearchResults.tsx ├── next-env.d.ts ├── pages ├── api │ └── hello.ts ├── index.tsx ├── _app.tsx ├── genres │ └── index.tsx ├── stores │ └── index.tsx ├── bookmarks │ └── index.tsx ├── _document.tsx ├── tags │ └── index.tsx ├── platforms │ └── index.tsx ├── publishers │ └── index.tsx ├── developers │ └── index.tsx ├── tag │ └── [slug].tsx ├── genre │ └── [slug].tsx ├── store │ └── [slug].tsx ├── publisher │ └── [slug].tsx ├── platform │ └── [slug].tsx ├── developer │ └── [slug].tsx ├── messages │ └── index.tsx └── collections │ └── index.tsx ├── styles └── globals.css ├── hooks ├── useUsers.ts ├── useAuth.ts ├── useClickOutside.ts ├── useMediaQuery.ts ├── useUserBookmarks.ts ├── useUser.ts ├── useBookmarkMutation.ts ├── useFollow.ts ├── useCollections.ts └── useMessages.ts ├── .gitignore ├── next.config.js ├── tsconfig.json ├── firebase └── firebase.config.js ├── .eslintrc.json ├── tailwind.config.js ├── package.json └── README.md /.eslintignore: -------------------------------------------------------------------------------- 1 | *.config.js -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kens-visuals/game-zone/HEAD/screenshot.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kens-visuals/game-zone/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kens-visuals/game-zone/HEAD/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kens-visuals/game-zone/HEAD/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kens-visuals/game-zone/HEAD/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/assets/placeholder.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kens-visuals/game-zone/HEAD/public/assets/placeholder.avif -------------------------------------------------------------------------------- /lib/rawg.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | export default axios.create({ baseURL: `https://api.rawg.io/api/` }); 4 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kens-visuals/game-zone/HEAD/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kens-visuals/game-zone/HEAD/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /components/PageHeading.tsx: -------------------------------------------------------------------------------- 1 | interface Props { 2 | heading: string; 3 | } 4 | 5 | export default function PageHeading({ heading }: Props) { 6 | return

{heading}

; 7 | } 8 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /public/assets/icon-play.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/site.webmanifest: -------------------------------------------------------------------------------- 1 | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} -------------------------------------------------------------------------------- /public/assets/icon-nav-tv-series.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/assets/icon-category-tv.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/assets/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /components/PageList.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | 3 | interface Props { 4 | children: ReactNode; 5 | } 6 | 7 | export default function PageList({ children }: Props) { 8 | return ( 9 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /components/GameDetail.tsx: -------------------------------------------------------------------------------- 1 | interface Props { 2 | name: string; 3 | info: string | number; 4 | } 5 | 6 | export default function GameDetail({ name, info }: Props) { 7 | return ( 8 |
9 | {name}: 10 | {info} 11 |
12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /pages/api/hello.ts: -------------------------------------------------------------------------------- 1 | // Next.js API route support: https://nextjs.org/docs/api-routes/introduction 2 | import type { NextApiRequest, NextApiResponse } from 'next' 3 | 4 | type Data = { 5 | name: string 6 | } 7 | 8 | export default function handler( 9 | req: NextApiRequest, 10 | res: NextApiResponse 11 | ) { 12 | res.status(200).json({ name: 'John Doe' }) 13 | } 14 | -------------------------------------------------------------------------------- /public/assets/icon-search.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/assets/icon-nav-home.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer utilities { 6 | /* For Webkit-based browsers (Chrome, Safari and Opera) */ 7 | .scrollbar-hide::-webkit-scrollbar { 8 | display: none; 9 | } 10 | 11 | /* For IE, Edge and Firefox */ 12 | .scrollbar-hide { 13 | -ms-overflow-style: none; /* IE and Edge */ 14 | scrollbar-width: none; /* Firefox */ 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /public/assets/icon-nav-movies.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/assets/icon-nav-bookmark.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/helpers/index.ts: -------------------------------------------------------------------------------- 1 | import { orderBy, query, Query } from 'firebase/firestore'; 2 | 3 | export const formatDate = (date: string) => { 4 | const newDate = new Date(date); 5 | 6 | return newDate.toLocaleDateString('en-us', { 7 | month: 'short', 8 | year: 'numeric', 9 | day: 'numeric', 10 | }); 11 | }; 12 | 13 | export const orderByDescQuery = (collRef: Query) => 14 | query(collRef, orderBy('createdAt', 'desc')); 15 | 16 | export const formatName = (name: string) => name.replace(/\s\w*/gi, ''); 17 | -------------------------------------------------------------------------------- /hooks/useUsers.ts: -------------------------------------------------------------------------------- 1 | import { collection } from 'firebase/firestore'; 2 | import { useFirestore, useFirestoreCollectionData } from 'reactfire'; 3 | 4 | // Interfaces 5 | import { UserInterface } from './useUser'; 6 | 7 | export default function useUserBookmarks() { 8 | const firestore = useFirestore(); 9 | const usersCollection = collection(firestore, `users`); 10 | 11 | const { status, data } = useFirestoreCollectionData(usersCollection); 12 | const users = data as UserInterface[]; 13 | 14 | return { status, users }; 15 | } 16 | -------------------------------------------------------------------------------- /components/GamesListContainer.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | import Masonry from 'react-masonry-css'; 3 | 4 | interface Props { 5 | children: ReactNode; 6 | } 7 | 8 | export default function GamesListContainer({ children }: Props) { 9 | const breakpointColumnsObj = { 10 | default: 4, 11 | 1100: 2, 12 | 500: 1, 13 | }; 14 | 15 | return ( 16 | 20 | {children} 21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /components/Footer.tsx: -------------------------------------------------------------------------------- 1 | export default function Footer({ isSidebarOpen = false }) { 2 | return ( 3 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /public/assets/icon-category-movie.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /hooks/useAuth.ts: -------------------------------------------------------------------------------- 1 | import { 2 | useAuthSignInWithPopup, 3 | useAuthSignOut, 4 | } from '@react-query-firebase/auth'; 5 | import { auth, googleProvider } from '../firebase/firebase.config'; 6 | 7 | export default function useAuth() { 8 | const signInMutation = useAuthSignInWithPopup(auth); 9 | const signOutMutation = useAuthSignOut(auth); 10 | 11 | const handleUserSignIn = () => 12 | signInMutation.mutate({ provider: googleProvider }); 13 | const handleUserSignOut = () => signOutMutation.mutate(); 14 | 15 | return { handleUserSignIn, handleUserSignOut }; 16 | } 17 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | swcMinify: true, 5 | env: { 6 | RAWG_API_KEY: process.env.RAWG_API_KEY, 7 | }, 8 | images: { 9 | remotePatterns: [ 10 | { 11 | protocol: 'https', 12 | hostname: 'lh3.googleusercontent.com', 13 | pathname: '/a/**', 14 | }, 15 | { 16 | protocol: 'https', 17 | hostname: 'media.rawg.io', 18 | pathname: '/media/**', 19 | }, 20 | ], 21 | }, 22 | }; 23 | 24 | module.exports = nextConfig; 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true 17 | }, 18 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 19 | "exclude": ["node_modules"] 20 | } 21 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | 3 | // Componentns 4 | import Divider from '../components/Divider'; 5 | import GamesList from '../components/GamesList'; 6 | import TrendingGamesList from '../components/TrendingGamesList'; 7 | 8 | export default function Home() { 9 | return ( 10 |
11 | 12 | Game Zone 13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | 21 | 22 | 23 |
24 |
25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /hooks/useClickOutside.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | 3 | export default function useOutsideClick(callback: () => void) { 4 | const ref = useRef(null); 5 | 6 | useEffect(() => { 7 | const handleClick = (event: MouseEvent | TouchEvent) => { 8 | const target = event.target as Node; 9 | if (ref.current && !ref.current.contains(target)) { 10 | callback(); 11 | } 12 | }; 13 | 14 | document.addEventListener('click', handleClick, true); 15 | document.addEventListener('touchstart', handleClick, true); 16 | 17 | return () => { 18 | document.removeEventListener('click', handleClick, true); 19 | document.removeEventListener('touchstart', handleClick, true); 20 | }; 21 | }, [ref]); 22 | 23 | return ref; 24 | } 25 | -------------------------------------------------------------------------------- /public/assets/icon-bookmark-full.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /components/Logo.tsx: -------------------------------------------------------------------------------- 1 | export default function Logo({ isSidebarOpen = false }) { 2 | return ( 3 |
8 |
9 |
10 |
11 |
12 | 13 | Game
Zone 14 |
15 |
16 |
17 |
18 |
19 |
20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /components/Description.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | interface Props { 4 | description: string; 5 | } 6 | 7 | export default function Description({ description }: Props) { 8 | const [isShowMore, setIsShowMore] = useState(false); 9 | 10 | return ( 11 |
12 |

About

13 | 14 |
21 | 28 |
29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /firebase/firebase.config.js: -------------------------------------------------------------------------------- 1 | // Import the functions you need from the SDKs you need 2 | import { getApps, getApp, initializeApp } from 'firebase/app'; 3 | import { getFirestore } from 'firebase/firestore'; 4 | import { GoogleAuthProvider, getAuth } from 'firebase/auth'; 5 | 6 | export const firebaseConfig = { 7 | apiKey: process.env.NEXT_PUBLIC_API_KEY, 8 | authDomain: process.env.NEXT_PUBLIC_AUTH_DOMAIN, 9 | projectId: process.env.NEXT_PUBLIC_PROJECT_ID, 10 | storageBucket: process.env.NEXT_PUBLIC_STORAGE_BUCKET, 11 | messagingSenderId: process.env.NEXT_PUBLIC_MESSAGING_SENDER_ID, 12 | appId: process.env.NEXT_PUBLIC_APP_ID, 13 | measurementId: process.env.NEXT_PUBLIC_MEASUREMENT_ID, 14 | }; 15 | 16 | // Initialize Firebase 17 | const app = getApps().length === 0 ? initializeApp(firebaseConfig) : getApp(); 18 | 19 | export const db = getFirestore(app); 20 | export const auth = getAuth(app); 21 | export const googleProvider = new GoogleAuthProvider(); 22 | -------------------------------------------------------------------------------- /hooks/useMediaQuery.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | export default function useMediaQuery(query: string): boolean { 4 | // eslint-disable-next-line @typescript-eslint/no-shadow 5 | const getMatches = (query: string): boolean => { 6 | // Prevents SSR issues 7 | if (typeof window !== 'undefined') { 8 | return window.matchMedia(query).matches; 9 | } 10 | return false; 11 | }; 12 | 13 | const [matches, setMatches] = useState(getMatches(query)); 14 | 15 | function handleChange() { 16 | setMatches(getMatches(query)); 17 | } 18 | 19 | useEffect(() => { 20 | const matchMedia = window.matchMedia(query); 21 | 22 | // Triggered at the first client-side load and if query changes 23 | handleChange(); 24 | 25 | // Listen matchMedia 26 | matchMedia.addEventListener('change', handleChange); 27 | 28 | return () => matchMedia.removeEventListener('change', handleChange); 29 | }, [query]); 30 | 31 | return matches; 32 | } 33 | -------------------------------------------------------------------------------- /components/Banner.tsx: -------------------------------------------------------------------------------- 1 | // Components 2 | import Description from './Description'; 3 | 4 | // Interfaces 5 | import { DataType, GameInterface } from '../lib/types/index'; 6 | 7 | interface Props { 8 | data: DataType | GameInterface; 9 | } 10 | 11 | export default function Banner({ data }: Props) { 12 | const backgroundImage = 13 | 'background_image' in data 14 | ? data?.background_image 15 | : data?.image_background; 16 | 17 | return ( 18 |
22 |
23 |

24 | {data?.name} 25 |

26 | 27 | {data?.description && } 28 |
29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /components/FollowButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import useFollow from '../hooks/useFollow'; 3 | 4 | // Interfaces 5 | import { UserInterface } from '../hooks/useUser'; 6 | 7 | interface Props { 8 | user: UserInterface; 9 | } 10 | 11 | export default function FollowButton({ user }: Props) { 12 | const { followList, manageFollow } = useFollow(); 13 | 14 | return followList('following') 15 | ?.map((usr) => usr.uid) 16 | .includes(user.uid) ? ( 17 | 24 | ) : ( 25 | 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /public/assets/icon-bookmark-empty.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": [ 7 | "plugin:react/recommended", 8 | "airbnb", 9 | "airbnb-typescript", 10 | "plugin:@next/next/recommended", 11 | "prettier" 12 | ], 13 | "parser": "@typescript-eslint/parser", 14 | "parserOptions": { 15 | "project": "./tsconfig.json", 16 | "ecmaFeatures": { 17 | "jsx": true 18 | }, 19 | "ecmaVersion": "latest", 20 | "sourceType": "module" 21 | }, 22 | "plugins": ["react", "@typescript-eslint", "prettier"], 23 | "rules": { 24 | "react/prop-types": "off", 25 | "react/require-default-props": "off", 26 | "react/jsx-props-no-spreading": "off", 27 | "react/react-in-jsx-scope": "off", 28 | "react/jsx-filename-extension": [ 29 | 1, 30 | { "extensions": [".js", ".ts", ".tsx"] } 31 | ], 32 | "import/extensions": [ 33 | 2, 34 | "ignorePackages", 35 | { 36 | "js": "never", 37 | "jsx": "never", 38 | "ts": "never", 39 | "tsx": "never" 40 | } 41 | ] 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import '../styles/globals.css'; 3 | import type { AppProps } from 'next/app'; 4 | import { QueryClient, QueryClientProvider, Hydrate } from 'react-query'; 5 | import { ReactQueryDevtools } from 'react-query/devtools'; 6 | import { FirebaseAppProvider, FirestoreProvider } from 'reactfire'; 7 | import { db, firebaseConfig } from '../firebase/firebase.config'; 8 | import Layout from '../components/Layout'; 9 | 10 | export default function MyApp({ Component, pageProps }: AppProps) { 11 | const [queryClient] = useState(() => new QueryClient()); 12 | 13 | return ( 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /components/Divider.tsx: -------------------------------------------------------------------------------- 1 | import { motion } from 'framer-motion'; 2 | 3 | // Animations 4 | import { dividerChildrenVariants, dividerVariants } from '../lib/animations'; 5 | 6 | export default function Divider() { 7 | return ( 8 | 14 | 15 |
16 |
17 | 18 | 19 |
20 |
21 | 22 | 23 |
24 |
25 | 26 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | './app/**/*.{js,ts,jsx,tsx}', 5 | './pages/**/*.{js,ts,jsx,tsx}', 6 | './components/**/*.{js,ts,jsx,tsx}', 7 | ], 8 | theme: { 9 | extend: { 10 | fontFamily: { 11 | outfit: ['Outfit', 'sans-serif'], 12 | }, 13 | colors: { 14 | primary: '#10141E', 15 | 'primary-dark': '#161D2F', 16 | 'primary-light': '#5A698F', 17 | secondary: '#FC4747', 18 | }, 19 | fontSize: { 20 | h1: [ 21 | '2rem', 22 | { 23 | fontWeight: 300, 24 | lineHeight: '2.5rem', 25 | letterSpacing: '-0.0313rem', 26 | }, 27 | ], 28 | 'h2-light': ['1.5rem', { fontWeight: 300, lineHeight: '1.875rem' }], 29 | 'h2-medium': ['1.5rem', { fontWeight: 500, lineHeight: '1.1875rem' }], 30 | h3: ['1.125rem', { fontWeight: 500, lineHeight: '1.4375rem' }], 31 | 'body-1': ['0.9375rem', { fontWeight: 300, lineHeight: '1.1875rem' }], 32 | 'body-2': ['0.8125rem', { fontWeight: 300, lineHeight: '1rem' }], 33 | }, 34 | }, 35 | }, 36 | plugins: [], 37 | }; 38 | -------------------------------------------------------------------------------- /components/Message.tsx: -------------------------------------------------------------------------------- 1 | // Hooks 2 | import useUser from '../hooks/useUser'; 3 | 4 | // Interface 5 | import { MessageType } from '../lib/types/index'; 6 | 7 | interface Props { 8 | message: MessageType; 9 | } 10 | 11 | export default function Message({ message }: Props) { 12 | const { currentUser } = useUser(); 13 | 14 | const seconds = message.createdAt && message.createdAt?.seconds; 15 | const date = new Date(seconds * 1000); 16 | const time = date?.toLocaleTimeString('en-US', { timeStyle: 'short' }); 17 | 18 | const isCurrentUser = message.from === currentUser?.uid; 19 | 20 | return ( 21 |
  • 22 |
    27 |

    32 | {message.message} 33 | {time && {time}} 34 |

    35 |
    36 |
  • 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /hooks/useUserBookmarks.ts: -------------------------------------------------------------------------------- 1 | import { collection, onSnapshot } from 'firebase/firestore'; 2 | import { useFirestore, useFirestoreCollectionData } from 'reactfire'; 3 | import { db } from '../firebase/firebase.config'; 4 | 5 | // Hooks 6 | import useUser from './useUser'; 7 | import { orderByDescQuery } from '../lib/helpers'; 8 | 9 | // Type 10 | import { Bookmark } from '../lib/types/index'; 11 | 12 | export default function useUserBookmarks() { 13 | const { currentUser } = useUser(); 14 | 15 | const firestore = useFirestore(); 16 | const gamesCollection = collection( 17 | firestore, 18 | `users/${currentUser?.uid}/bookmarks` 19 | ); 20 | const userBookmarksQuery = orderByDescQuery(gamesCollection); 21 | 22 | const getCurrentUserBookmarks = ( 23 | userId: string, 24 | callback: (d: any) => void 25 | ) => { 26 | const userCollRef = collection(db, `users/${userId}/bookmarks`); 27 | const userCollQuery = orderByDescQuery(userCollRef); 28 | 29 | return onSnapshot(userCollQuery, callback); 30 | }; 31 | 32 | const { status, data: bookmarks } = useFirestoreCollectionData( 33 | userBookmarksQuery, 34 | { idField: 'id' } 35 | ); 36 | const bookmarksData = bookmarks as Bookmark[]; 37 | 38 | return { status, bookmarksData, getCurrentUserBookmarks }; 39 | } 40 | -------------------------------------------------------------------------------- /components/PageItem.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import Image from 'next/image'; 3 | 4 | // Assets 5 | import placeholderImg from '../public/assets/placeholder.avif'; 6 | 7 | // Interfaces 8 | import { DataType } from '../lib/types/index'; 9 | 10 | interface Props { 11 | route: string; 12 | data: DataType; 13 | } 14 | 15 | export default function PageItem({ route, data }: Props) { 16 | return ( 17 |
  • 18 | {data.name} 25 | 29 | 30 | {data?.name} 31 | 32 | 33 | 34 | Games count: {data?.games_count} 35 | 36 | 37 |
  • 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /components/SignOutButton.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/router'; 2 | 3 | // Hooks 4 | import useAuth from '../hooks/useAuth'; 5 | 6 | export default function SignOutButton({ isSidebarOpen = false }) { 7 | const router = useRouter(); 8 | const { handleUserSignOut } = useAuth(); 9 | 10 | return ( 11 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /lib/types/index.ts: -------------------------------------------------------------------------------- 1 | export type DataType = { 2 | id?: number; 3 | name: string; 4 | slug: string; 5 | description: string; 6 | games_count: number; 7 | image_background: string; 8 | }; 9 | 10 | export type Ratings = { 11 | title: string; 12 | percent: number; 13 | }[]; 14 | 15 | export type ParentPlatform = { 16 | platform: { 17 | name: string; 18 | }; 19 | }[]; 20 | 21 | export type Tags = { 22 | slug: string; 23 | name: string; 24 | }[]; 25 | 26 | export interface Bookmark { 27 | id?: string; 28 | name: string; 29 | slug: string; 30 | genres: DataType[]; 31 | released: string; 32 | createdAt: string; 33 | background_image: string; 34 | } 35 | 36 | export interface GameInterface { 37 | id?: string; 38 | slug: string; 39 | name: string; 40 | released: string; 41 | background_image: string; 42 | description?: string; 43 | genres?: DataType[]; 44 | website?: string; 45 | redditurl?: string; 46 | ratings?: Ratings; 47 | rating?: string; 48 | rating_top?: string; 49 | metacritic?: number; 50 | parent_platforms?: ParentPlatform; 51 | tags?: Tags; 52 | } 53 | 54 | export interface Screenshots { 55 | image: string; 56 | } 57 | 58 | export interface MessageType { 59 | id: string; 60 | to: string; 61 | from: string; 62 | seen?: boolean; 63 | chatId: string; 64 | message: string; 65 | photoURL: string; 66 | displayName: string; 67 | createdAt: { seconds: number }; 68 | } 69 | -------------------------------------------------------------------------------- /components/SignInButton.tsx: -------------------------------------------------------------------------------- 1 | import useAuth from '../hooks/useAuth'; 2 | 3 | interface Props { 4 | isUserLoading: boolean; 5 | isSidebarOpen?: boolean; 6 | } 7 | 8 | export default function SignInButton({ isUserLoading, isSidebarOpen }: Props) { 9 | const { handleUserSignIn } = useAuth(); 10 | 11 | return ( 12 | 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /components/MessangerInput.tsx: -------------------------------------------------------------------------------- 1 | import { RefObject, SyntheticEvent, useState } from 'react'; 2 | 3 | // Hook 4 | import useUser from '../hooks/useUser'; 5 | import useMessages from '../hooks/useMessages'; 6 | 7 | // Interfaces 8 | interface Props { 9 | sendTo: string; 10 | scrollRef: RefObject; 11 | } 12 | 13 | export default function MessangerInput({ sendTo, scrollRef }: Props) { 14 | const { currentUser } = useUser(); 15 | const { addNewMessage } = useMessages(); 16 | 17 | const [message, setMessage] = useState(''); 18 | 19 | const handleClick = (e: SyntheticEvent) => { 20 | addNewMessage(e, message, sendTo); 21 | setMessage(''); 22 | 23 | if (scrollRef.current) { 24 | scrollRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' }); 25 | } 26 | }; 27 | 28 | return ( 29 |
    30 | setMessage(e.target.value)} 34 | className="w-full bg-secondary/50 px-4 text-white outline-none disabled:cursor-not-allowed disabled:opacity-50 md:px-6 md:py-4 md:text-h3" 35 | /> 36 | 44 |
    45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "book-club", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@headlessui/react": "^1.7.7", 13 | "@react-query-firebase/auth": "^1.0.0-dev.2", 14 | "@react-query-firebase/firestore": "^1.0.0-dev.7", 15 | "@tanstack/react-query": "^4.20.4", 16 | "@tanstack/react-query-devtools": "^4.20.4", 17 | "@types/node": "18.11.9", 18 | "@types/react": "18.0.25", 19 | "@types/react-dom": "18.0.8", 20 | "axios": "^1.2.2", 21 | "eslint-config-airbnb-typescript": "^17.0.0", 22 | "eslint-config-next": "13.0.2", 23 | "firebase": "^9.15.0", 24 | "framer-motion": "^9.0.2", 25 | "nanoid": "^4.0.1", 26 | "next": "13.0.2", 27 | "react": "18.2.0", 28 | "react-dom": "18.2.0", 29 | "react-masonry-css": "^1.0.16", 30 | "react-query": "^3.39.2", 31 | "reactfire": "^4.2.2", 32 | "typescript": "4.8.4" 33 | }, 34 | "devDependencies": { 35 | "@typescript-eslint/eslint-plugin": "^5.42.0", 36 | "@typescript-eslint/parser": "^5.42.0", 37 | "autoprefixer": "^10.4.13", 38 | "eslint": "^8.27.0", 39 | "eslint-config-airbnb": "^19.0.4", 40 | "eslint-config-prettier": "^8.5.0", 41 | "eslint-plugin-import": "^2.26.0", 42 | "eslint-plugin-jsx-a11y": "^6.6.1", 43 | "eslint-plugin-prettier": "^4.2.1", 44 | "eslint-plugin-react": "^7.31.10", 45 | "eslint-plugin-react-hooks": "^4.6.0", 46 | "postcss": "^8.4.18", 47 | "prettier": "^2.7.1", 48 | "prettier-plugin-tailwindcss": "^0.1.13", 49 | "tailwindcss": "^3.2.2" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /components/GameSeriesList.tsx: -------------------------------------------------------------------------------- 1 | import { useQuery } from 'react-query'; 2 | 3 | // Components 4 | import GameCard from './GameCard'; 5 | import GamesListContainer from './GamesListContainer'; 6 | 7 | // Helpers 8 | import RAWG from '../lib/rawg'; 9 | import LoadingCard from './LoadingCard'; 10 | import ErrorCard from './ErrorCard'; 11 | 12 | // Types 13 | import { GameInterface } from '../lib/types/index'; 14 | 15 | interface Props { 16 | gameSlug: string; 17 | } 18 | 19 | const API_KEY = process.env.NEXT_PUBLIC_RAWG_API_KEY; 20 | 21 | export default function GameSeriesList({ gameSlug }: Props) { 22 | const fetchGameSeries = async (slug: string): Promise => { 23 | const { data } = await RAWG.get(`games/${slug}/game-series?key=${API_KEY}`); 24 | 25 | return data.results; 26 | }; 27 | 28 | const { 29 | data: series, 30 | isError: isSeriesError, 31 | isLoading: isSeriesLoading, 32 | } = useQuery(['getGameSeries', gameSlug], () => fetchGameSeries(gameSlug)); 33 | 34 | if (isSeriesLoading) return ; 35 | 36 | if (isSeriesError) return ; 37 | 38 | return ( 39 |
    40 | Game Series 41 | {series?.length ? ( 42 | 43 | {series?.map((seria) => ( 44 |
    45 | 46 |
    47 | ))} 48 |
    49 | ) : ( 50 |
    51 | No series available 52 |
    53 | )} 54 |
    55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /components/Layout.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useEffect, useState } from 'react'; 2 | import { useRouter } from 'next/router'; 3 | import { AnimatePresence, LayoutGroup, motion } from 'framer-motion'; 4 | 5 | // Components 6 | import Navbar from './Navbar'; 7 | import SearchResults from './SearchResults'; 8 | 9 | // Animations 10 | import { pageAnimationVariants } from '../lib/animations'; 11 | 12 | interface Props { 13 | children: ReactNode; 14 | } 15 | 16 | export default function Layout({ children }: Props) { 17 | const [isSidebarOpen, setIsSidebarOpen] = useState(false); 18 | const { pathname } = useRouter(); 19 | 20 | useEffect(() => window.scrollTo(0, 0), [pathname]); 21 | 22 | return ( 23 |
    24 | 25 | 29 | 34 | 35 | 36 | 37 | 44 | {children} 45 | 46 | 47 | 48 | 49 |
    50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /hooks/useUser.ts: -------------------------------------------------------------------------------- 1 | import { doc, getDoc, serverTimestamp, setDoc } from 'firebase/firestore'; 2 | import { useAuthUser } from '@react-query-firebase/auth'; 3 | import { auth, db } from '../firebase/firebase.config'; 4 | 5 | interface UserFollow { 6 | uid: string; 7 | email: string; 8 | photoURL: string; 9 | displayName: string; 10 | } 11 | 12 | export interface UserInterface { 13 | uid: string; 14 | email: string; 15 | photoURL: string; 16 | createdAt?: string; 17 | displayName: string; 18 | following?: UserFollow[]; 19 | followers?: UserFollow[]; 20 | } 21 | 22 | const options = { 23 | async onSuccess(newUser: UserInterface) { 24 | if (!newUser) return; 25 | 26 | const { displayName, email, uid, photoURL } = newUser; 27 | const userDocRef = doc(db, 'users', uid); 28 | 29 | const userSnapshot = await getDoc(userDocRef); 30 | 31 | if (!userSnapshot.exists()) { 32 | const createdAt = serverTimestamp(); 33 | 34 | try { 35 | // Create user doc 36 | await setDoc(userDocRef, { 37 | uid, 38 | email, 39 | photoURL, 40 | createdAt, 41 | displayName, 42 | }); 43 | } catch (error) { 44 | // eslint-disable-next-line no-console 45 | console.error(error); 46 | } 47 | } 48 | }, 49 | onError(error: ErrorOptions | undefined) { 50 | throw new Error( 51 | 'Failed to subscribe to users authentication state!', 52 | error 53 | ); 54 | }, 55 | }; 56 | 57 | export default function useUser() { 58 | const { 59 | data: currentUser, 60 | isError: isUserError, 61 | isLoading: isUserLoading, 62 | isFetching: isUserFetching, 63 | } = useAuthUser(['user'], auth, options); 64 | 65 | return { currentUser, isUserError, isUserFetching, isUserLoading }; 66 | } 67 | -------------------------------------------------------------------------------- /hooks/useBookmarkMutation.ts: -------------------------------------------------------------------------------- 1 | import { useFirestore } from 'reactfire'; 2 | import { useFirestoreCollectionMutation } from '@react-query-firebase/firestore'; 3 | import { 4 | collection, 5 | deleteDoc, 6 | doc, 7 | serverTimestamp, 8 | } from 'firebase/firestore'; 9 | 10 | // Hooks 11 | import useUser from './useUser'; 12 | 13 | // Types 14 | import { Bookmark, GameInterface } from '../lib/types/index'; 15 | 16 | export default function useAddBookmark() { 17 | const { currentUser } = useUser(); 18 | 19 | const firestore = useFirestore(); 20 | const userBookmarkRef = collection( 21 | firestore, 22 | `users/${currentUser?.uid}/bookmarks` 23 | ); 24 | const addNewDataMutation = useFirestoreCollectionMutation(userBookmarkRef); 25 | 26 | const addNewData = (bookmarkObj: GameInterface) => { 27 | // eslint-disable-next-line @typescript-eslint/naming-convention 28 | const { name, slug, background_image, released, genres } = bookmarkObj; 29 | const createdAt = serverTimestamp(); 30 | 31 | addNewDataMutation.mutate({ 32 | name, 33 | slug, 34 | genres, 35 | released, 36 | createdAt, 37 | background_image, 38 | }); 39 | }; 40 | 41 | const removeBookmark = async (docId: string) => { 42 | try { 43 | const bookmarkRef = doc(userBookmarkRef, docId); 44 | await deleteDoc(bookmarkRef); 45 | } catch (error) { 46 | // eslint-disable-next-line no-console 47 | console.log(error); 48 | } 49 | }; 50 | 51 | const handleAddBookmark = ( 52 | bookmarks: Bookmark[], 53 | bookmarkObj: GameInterface 54 | ) => { 55 | const hasBeenBookmarked = bookmarks 56 | .map((bookmark: Bookmark) => bookmark.name) 57 | .includes(bookmarkObj.name); 58 | 59 | if (!hasBeenBookmarked) addNewData(bookmarkObj); 60 | }; 61 | 62 | return { addNewData, removeBookmark, handleAddBookmark }; 63 | } 64 | -------------------------------------------------------------------------------- /pages/genres/index.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | import { GetStaticProps } from 'next'; 3 | import { dehydrate, QueryClient, useQuery } from 'react-query'; 4 | 5 | // Componentns 6 | import Divider from '../../components/Divider'; 7 | import PageItem from '../../components/PageItem'; 8 | import PageList from '../../components/PageList'; 9 | import ErrorCard from '../../components/ErrorCard'; 10 | import LoadingCard from '../../components/LoadingCard'; 11 | import PageHeading from '../../components/PageHeading'; 12 | 13 | // Helpers 14 | import RAWG from '../../lib/rawg'; 15 | 16 | // Types 17 | import { DataType } from '../../lib/types/index'; 18 | 19 | interface DataProps { 20 | results: DataType[]; 21 | } 22 | 23 | const fetchGenres = async (): Promise => { 24 | const apiKey = process.env.NEXT_PUBLIC_RAWG_API_KEY; 25 | const { data } = await RAWG.get(`/genres?key=${apiKey}`); 26 | 27 | return data?.results; 28 | }; 29 | 30 | export default function Genres() { 31 | const { 32 | data: genres, 33 | isError, 34 | isLoading, 35 | } = useQuery(['getGenres'], fetchGenres); 36 | 37 | if (isLoading) return ; 38 | 39 | if (isError) return ; 40 | 41 | return ( 42 | <> 43 | 44 | GZ | Genres 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | {genres?.map((genre) => ( 54 | 55 | ))} 56 | 57 | 58 | ); 59 | } 60 | 61 | export const getStaticProps: GetStaticProps = async () => { 62 | const queryClient = new QueryClient(); 63 | await queryClient.prefetchQuery(['getGenres'], fetchGenres); 64 | 65 | return { 66 | props: { 67 | dehydratedState: dehydrate(queryClient), 68 | }, 69 | }; 70 | }; 71 | -------------------------------------------------------------------------------- /components/MainNav.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import { motion } from 'framer-motion'; 3 | import { useRouter } from 'next/router'; 4 | 5 | // Hooks 6 | import useMediaQuery from '../hooks/useMediaQuery'; 7 | 8 | // Animations 9 | import { fadeIn, fadeInOut } from '../lib/animations'; 10 | 11 | // Helpers 12 | import { mainRoutes } from '../lib/routes'; 13 | 14 | export default function PagesNav({ isSidebarOpen = false }) { 15 | const { pathname } = useRouter(); 16 | 17 | const matches = useMediaQuery('(min-width: 768px)'); 18 | 19 | return ( 20 | 30 | {mainRoutes.map((route) => ( 31 | 32 | 33 | 43 | {route.icon} 44 | 45 | 56 | {route.name} 57 | 58 | 59 | 60 | ))} 61 | 62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /components/PagesNav.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import { useRouter } from 'next/router'; 3 | import { AnimatePresence, motion } from 'framer-motion'; 4 | 5 | // Hooks 6 | import useMediaQuery from '../hooks/useMediaQuery'; 7 | 8 | // Animations 9 | import { fadeIn, fadeInOut } from '../lib/animations'; 10 | 11 | // Helpers 12 | import { pageRoutes } from '../lib/routes'; 13 | 14 | export default function PagesNav({ isSidebarOpen = false }) { 15 | const { pathname } = useRouter(); 16 | const matches = useMediaQuery('(min-width: 768px)'); 17 | 18 | return ( 19 | 27 | 28 | {pageRoutes.map((route) => ( 29 | 30 | 38 | 48 | {route.icon} 49 | 50 | 53 | {route.name} 54 | 55 | 56 | 57 | ))} 58 | 59 | 60 | ); 61 | } 62 | -------------------------------------------------------------------------------- /pages/stores/index.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | import { GetStaticProps } from 'next'; 3 | import { dehydrate, QueryClient, useQuery } from 'react-query'; 4 | 5 | // Componentns 6 | import PageList from '../../components/PageList'; 7 | import PageItem from '../../components/PageItem'; 8 | import ErrorCard from '../../components/ErrorCard'; 9 | import PageHeading from '../../components/PageHeading'; 10 | import LoadingCard from '../../components/LoadingCard'; 11 | 12 | // Helpers 13 | import RAWG from '../../lib/rawg'; 14 | 15 | // Types 16 | import { DataType } from '../../lib/types/index'; 17 | import Divider from '../../components/Divider'; 18 | 19 | interface DataProps { 20 | results: DataType[]; 21 | } 22 | 23 | const fetchStores = async (): Promise => { 24 | const apiKey = process.env.NEXT_PUBLIC_RAWG_API_KEY; 25 | 26 | const { data } = await RAWG.get( 27 | `/stores?page_size=40&&key=${apiKey}` 28 | ); 29 | 30 | return data?.results; 31 | }; 32 | export default function Stores() { 33 | const { 34 | data: stores, 35 | isError, 36 | isLoading, 37 | } = useQuery(['getStores'], fetchStores, { 38 | getNextPageParam: (lastPage, allPages) => { 39 | if (lastPage.length < 40) return undefined; 40 | 41 | if (allPages.length) return allPages.length + 1; 42 | 43 | return undefined; 44 | }, 45 | }); 46 | 47 | if (isLoading) return ; 48 | 49 | if (isError) return ; 50 | 51 | return ( 52 | <> 53 | 54 | GZ | Stores 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 |
    63 | 64 | {stores?.map((data) => ( 65 | 66 | ))} 67 | 68 |
    69 | 70 | ); 71 | } 72 | 73 | export const getStaticProps: GetStaticProps = async () => { 74 | const queryClient = new QueryClient(); 75 | await queryClient.prefetchQuery(['getStores'], fetchStores); 76 | 77 | return { 78 | props: { 79 | dehydratedState: dehydrate(queryClient), 80 | }, 81 | }; 82 | }; 83 | -------------------------------------------------------------------------------- /components/LoadingCard.tsx: -------------------------------------------------------------------------------- 1 | interface Props { 2 | size: number; 3 | isHorizontal?: boolean; 4 | } 5 | 6 | export default function LoadingCard({ size, isHorizontal = false }: Props) { 7 | const emptyArray = Array.from({ length: size }, () => Math.random()); 8 | 9 | return ( 10 |
    17 | {emptyArray.map((el) => ( 18 |
    23 |
    24 | 33 |
    34 |
    35 |
    36 |
    37 |
    38 |
    39 |
    40 |
    41 |
    42 |
    43 | Loading... 44 |
    45 | ))} 46 |
    47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /components/ErrorCard.tsx: -------------------------------------------------------------------------------- 1 | export default function ErrorCard() { 2 | return ( 3 | 54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /hooks/useFollow.ts: -------------------------------------------------------------------------------- 1 | import { 2 | arrayRemove, 3 | arrayUnion, 4 | collection, 5 | doc, 6 | serverTimestamp, 7 | writeBatch, 8 | } from 'firebase/firestore'; 9 | import { db } from '../firebase/firebase.config'; 10 | 11 | // Hooks 12 | import useUser, { UserInterface } from './useUser'; 13 | import useUsers from './useUsers'; 14 | 15 | export default function useFollow() { 16 | const { currentUser } = useUser(); 17 | const { users, status: usersStatus } = useUsers(); 18 | 19 | const followList = ( 20 | type: 'followers' | 'following', 21 | currentUserId = currentUser?.uid 22 | ) => 23 | usersStatus === 'success' 24 | ? users 25 | .filter((user) => user.uid === currentUserId) 26 | .map((user) => 27 | type === 'following' ? user?.following : user?.followers 28 | ) 29 | .at(0) 30 | : []; 31 | 32 | const manageFollow = async ( 33 | type: 'follow' | 'unfollow', 34 | targetUserId: string, 35 | targetUserObj: UserInterface 36 | ): Promise => { 37 | const batch = writeBatch(db); 38 | 39 | const currentUserData = { 40 | uid: currentUser?.uid, 41 | email: currentUser?.email, 42 | photoURL: currentUser?.photoURL, 43 | displayName: currentUser?.displayName, 44 | }; 45 | 46 | const targetUserData = { 47 | uid: targetUserObj?.uid, 48 | email: targetUserObj?.email, 49 | photoURL: targetUserObj?.photoURL, 50 | displayName: targetUserObj?.displayName, 51 | }; 52 | 53 | const usersCollection = collection(db, `users`); 54 | 55 | const userDocRef = doc(usersCollection, currentUser?.uid); 56 | const targetUserDocRef = doc(usersCollection, targetUserId); 57 | 58 | if (type === 'follow') { 59 | batch.update(userDocRef, { 60 | following: arrayUnion(targetUserData), 61 | updatedAt: serverTimestamp(), 62 | }); 63 | batch.update(targetUserDocRef, { 64 | followers: arrayUnion(currentUserData), 65 | updatedAt: serverTimestamp(), 66 | }); 67 | } else { 68 | batch.update(userDocRef, { 69 | following: arrayRemove(targetUserData), 70 | updatedAt: serverTimestamp(), 71 | }); 72 | batch.update(targetUserDocRef, { 73 | followers: arrayRemove(currentUserData), 74 | updatedAt: serverTimestamp(), 75 | }); 76 | } 77 | 78 | await batch.commit(); 79 | }; 80 | 81 | return { manageFollow, followList }; 82 | } 83 | -------------------------------------------------------------------------------- /components/TrendingGamesList.tsx: -------------------------------------------------------------------------------- 1 | import { useInfiniteQuery } from 'react-query'; 2 | 3 | // Components 4 | import ErrorCard from './ErrorCard'; 5 | import LoadingCard from './LoadingCard'; 6 | import PageHeading from './PageHeading'; 7 | import TrendingGameCard from './TrendingGameCard'; 8 | 9 | // Helpers 10 | import RAWG from '../lib/rawg'; 11 | 12 | // Types 13 | import { GameInterface } from '../lib/types/index'; 14 | 15 | interface Games { 16 | results: GameInterface[]; 17 | } 18 | 19 | const fetchGames = async ({ pageParam = 1 }): Promise => { 20 | const apiKey = process.env.NEXT_PUBLIC_RAWG_API_KEY; 21 | const { data } = await RAWG.get( 22 | `/games/lists/main?key=${apiKey}&ordering=-released&page_size=10&page=${pageParam}` 23 | ); 24 | 25 | return data?.results; 26 | }; 27 | 28 | export default function TrendingGamesList() { 29 | const { 30 | data: games, 31 | isError, 32 | isLoading, 33 | hasNextPage, 34 | fetchNextPage, 35 | isFetchingNextPage, 36 | } = useInfiniteQuery(['getTrendingGames'], fetchGames, { 37 | getNextPageParam: (lastPage, allPages) => { 38 | if (lastPage.length < 10) return undefined; 39 | 40 | if (allPages.length) return allPages.length + 1; 41 | 42 | return undefined; 43 | }, 44 | }); 45 | 46 | if (isLoading) return ; 47 | 48 | if (isError) return ; 49 | 50 | return ( 51 |
    52 | 53 | 54 |
      55 | {games?.pages.map((page) => 56 | page.map((detail) => ( 57 |
    • 58 | 59 |
    • 60 | )) 61 | )} 62 | 63 |
    • 64 | 77 |
    • 78 |
    79 |
    80 | ); 81 | } 82 | -------------------------------------------------------------------------------- /pages/bookmarks/index.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | import Link from 'next/link'; 3 | 4 | // Components 5 | import Divider from '../../components/Divider'; 6 | import GameCard from '../../components/GameCard'; 7 | import ErrorCard from '../../components/ErrorCard'; 8 | import LoadingCard from '../../components/LoadingCard'; 9 | import PageHeading from '../../components/PageHeading'; 10 | import SignInButton from '../../components/SignInButton'; 11 | import GamesListContainer from '../../components/GamesListContainer'; 12 | 13 | // Hooks 14 | import useUser from '../../hooks/useUser'; 15 | import useUserBookmarks from '../../hooks/useUserBookmarks'; 16 | 17 | export default function Bookmarks() { 18 | const { currentUser, isUserLoading } = useUser(); 19 | const { status, bookmarksData } = useUserBookmarks(); 20 | 21 | if (status === 'loading') return ; 22 | 23 | if (status === 'error') return ; 24 | 25 | return ( 26 | <> 27 | 28 | GZ | Bookmarks 29 | 33 | 34 | 35 |
    36 | 37 | 38 | 39 | 40 | {/* eslint-disable-next-line no-nested-ternary */} 41 | {!currentUser ? ( 42 |
    43 |
    44 | 45 | Sign In to bookmark games 46 | 47 | 48 |
    49 |
    50 | ) : !bookmarksData.length ? ( 51 |
    52 | 53 | You don't have any bookmarks yet 54 | 55 | 59 | Go to games 60 | 61 |
    62 | ) : ( 63 | 64 | {bookmarksData?.map((bookmark) => ( 65 |
    66 | 67 |
    68 | ))} 69 |
    70 | )} 71 |
    72 | 73 | ); 74 | } 75 | -------------------------------------------------------------------------------- /pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { Html, Head, Main, NextScript } from 'next/document'; 2 | 3 | export default function Document() { 4 | return ( 5 | 6 | 7 | 8 | 13 | 17 | 18 | {/* */} 19 | 20 | 24 | 25 | {/* */} 26 | 30 | 31 | 35 | 39 | 40 | {/* */} 41 | 42 | 43 | 44 | 48 | 49 | 53 | 57 | 58 | {/* Favicon */} 59 | 64 | 70 | 76 | 77 | 78 | 79 |
    80 | 81 | 82 | 83 | ); 84 | } 85 | -------------------------------------------------------------------------------- /components/Drawer.tsx: -------------------------------------------------------------------------------- 1 | import { AnimatePresence, motion } from 'framer-motion'; 2 | import { useRouter } from 'next/router'; 3 | import Link from 'next/link'; 4 | 5 | // Components 6 | import SignInButton from './SignInButton'; 7 | import SignOutButton from './SignOutButton'; 8 | import UserProfile from './UserProfile'; 9 | 10 | // Hooks 11 | import useUser from '../hooks/useUser'; 12 | 13 | // Animatons 14 | import { drawerVariants, fadeInOut } from '../lib/animations'; 15 | 16 | // Helpers 17 | import { pageRoutes } from '../lib/routes'; 18 | 19 | export default function Drawer() { 20 | const { pathname } = useRouter(); 21 | const { currentUser, isUserLoading } = useUser(); 22 | 23 | return ( 24 |
    25 | 33 | {currentUser && } 34 | 35 |
      36 | 37 | {pageRoutes.map((route) => ( 38 | 43 | 51 | 61 | {route.icon} 62 | 63 | {route.name} 64 | 65 | 66 | ))} 67 | 68 |
    69 | 70 | 71 | {!currentUser && !isUserLoading ? ( 72 | 73 | ) : ( 74 | 75 | )} 76 | 77 |
    78 |
    79 | ); 80 | } 81 | -------------------------------------------------------------------------------- /components/TrendingGameCard.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import Link from 'next/link'; 3 | import Image from 'next/image'; 4 | import { useRouter } from 'next/router'; 5 | 6 | // Hooks 7 | import useUser from '../hooks/useUser'; 8 | import useUserBookmarks from '../hooks/useUserBookmarks'; 9 | import useBookmarkMutation from '../hooks/useBookmarkMutation'; 10 | 11 | // Assets 12 | import placeholderImg from '../public/assets/placeholder.avif'; 13 | 14 | // Types 15 | import { GameInterface } from '../lib/types/index'; 16 | 17 | interface Props { 18 | detail: GameInterface; 19 | } 20 | 21 | export default function TrendingGameCard({ detail }: Props) { 22 | const router = useRouter(); 23 | const { currentUser } = useUser(); 24 | const { bookmarksData } = useUserBookmarks(); 25 | const { handleAddBookmark } = useBookmarkMutation(); 26 | 27 | const [wasBookmarked, setWasBookmarked] = useState(false); 28 | 29 | const handleClick = (details: GameInterface) => { 30 | if (!currentUser) return router.push('/bookmarks'); 31 | 32 | return handleAddBookmark(bookmarksData, details); 33 | }; 34 | 35 | useEffect(() => { 36 | const timer = setTimeout(() => setWasBookmarked(false), 1500); 37 | 38 | return () => clearTimeout(timer); 39 | }, [wasBookmarked]); 40 | 41 | return ( 42 |
    43 | {detail.name} 50 | 51 |
    52 |
    53 | 57 | {detail.name} 58 | 59 | 60 | 86 |
    87 |
    88 |
    89 | ); 90 | } 91 | -------------------------------------------------------------------------------- /pages/tags/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | import Head from 'next/head'; 3 | import { GetStaticProps } from 'next'; 4 | import { dehydrate, QueryClient, useInfiniteQuery } from 'react-query'; 5 | import { useInView } from 'framer-motion'; 6 | 7 | // Componentns 8 | import Divider from '../../components/Divider'; 9 | import PageItem from '../../components/PageItem'; 10 | import PageList from '../../components/PageList'; 11 | import ErrorCard from '../../components/ErrorCard'; 12 | import LoadingCard from '../../components/LoadingCard'; 13 | import PageHeading from '../../components/PageHeading'; 14 | 15 | // Helpers 16 | import RAWG from '../../lib/rawg'; 17 | 18 | // Types 19 | import { DataType } from '../../lib/types/index'; 20 | 21 | interface DataProps { 22 | results: DataType[]; 23 | } 24 | 25 | const fetchTags = async ({ pageParam = 1 }): Promise => { 26 | const apiKey = process.env.NEXT_PUBLIC_RAWG_API_KEY; 27 | 28 | const { data } = await RAWG.get( 29 | `/tags?page_size=40&page=${pageParam}&key=${apiKey}` 30 | ); 31 | 32 | return data?.results; 33 | }; 34 | export default function Tags() { 35 | const ref = useRef(null); 36 | const isInView = useInView(ref); 37 | 38 | const { 39 | data: tags, 40 | isError, 41 | isLoading, 42 | hasNextPage, 43 | fetchNextPage, 44 | isFetchingNextPage, 45 | } = useInfiniteQuery(['getTags'], fetchTags, { 46 | getNextPageParam: (lastPage, allPages) => { 47 | if (lastPage.length < 40) return undefined; 48 | 49 | if (allPages.length) return allPages.length + 1; 50 | 51 | return undefined; 52 | }, 53 | keepPreviousData: true, 54 | }); 55 | 56 | useEffect(() => { 57 | fetchNextPage(); 58 | }, [isInView]); 59 | 60 | if (isLoading) return ; 61 | 62 | if (isError) return ; 63 | 64 | return ( 65 | <> 66 | 67 | GZ | Tags 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 |
    76 | 77 | {tags?.pages?.map((page) => 78 | page.map((data) => ( 79 | 80 | )) 81 | )} 82 | 83 | 84 | 98 |
    99 | 100 | ); 101 | } 102 | 103 | export const getStaticProps: GetStaticProps = async () => { 104 | const queryClient = new QueryClient(); 105 | await queryClient.prefetchQuery(['getTags'], fetchTags); 106 | 107 | return { 108 | props: { 109 | dehydratedState: dehydrate(queryClient), 110 | }, 111 | }; 112 | }; 113 | -------------------------------------------------------------------------------- /pages/platforms/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | import Head from 'next/head'; 3 | import { GetStaticProps } from 'next'; 4 | import { dehydrate, QueryClient, useInfiniteQuery } from 'react-query'; 5 | import { nanoid } from 'nanoid'; 6 | import { useInView } from 'framer-motion'; 7 | 8 | // Componentns 9 | import Divider from '../../components/Divider'; 10 | import PageItem from '../../components/PageItem'; 11 | import PageList from '../../components/PageList'; 12 | import ErrorCard from '../../components/ErrorCard'; 13 | import LoadingCard from '../../components/LoadingCard'; 14 | import PageHeading from '../../components/PageHeading'; 15 | 16 | // Helpers 17 | import RAWG from '../../lib/rawg'; 18 | 19 | // Types 20 | import { DataType } from '../../lib/types/index'; 21 | 22 | interface DataProps { 23 | results: DataType[]; 24 | } 25 | 26 | const fetchPlatforms = async ({ pageParam = 1 }): Promise => { 27 | const apiKey = process.env.NEXT_PUBLIC_RAWG_API_KEY; 28 | 29 | const { data } = await RAWG.get( 30 | `/platforms?page_size=40&page=${pageParam}&key=${apiKey}` 31 | ); 32 | 33 | return data?.results; 34 | }; 35 | export default function Platforms() { 36 | const ref = useRef(null); 37 | const isInView = useInView(ref); 38 | 39 | const { 40 | data: platforms, 41 | isError, 42 | isLoading, 43 | hasNextPage, 44 | fetchNextPage, 45 | isFetchingNextPage, 46 | } = useInfiniteQuery(['getPlatforms'], fetchPlatforms, { 47 | getNextPageParam: (lastPage, allPages) => { 48 | if (lastPage.length < 40) return undefined; 49 | 50 | if (allPages.length) return allPages.length + 1; 51 | 52 | return undefined; 53 | }, 54 | }); 55 | 56 | useEffect(() => { 57 | fetchNextPage(); 58 | }, [isInView]); 59 | 60 | if (isLoading) return ; 61 | 62 | if (isError) return ; 63 | 64 | return ( 65 | <> 66 | 67 | GZ | Platforms 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 |
    76 | 77 | {platforms?.pages?.map((page) => 78 | page.map((data) => ( 79 | 80 | )) 81 | )} 82 | 83 | 84 | 97 |
    98 | 99 | ); 100 | } 101 | 102 | export const getStaticProps: GetStaticProps = async () => { 103 | const queryClient = new QueryClient(); 104 | await queryClient.prefetchQuery(['getPlatforms'], fetchPlatforms); 105 | 106 | return { 107 | props: { 108 | dehydratedState: dehydrate(queryClient), 109 | }, 110 | }; 111 | }; 112 | -------------------------------------------------------------------------------- /pages/publishers/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | import Head from 'next/head'; 3 | import { GetStaticProps } from 'next'; 4 | import { dehydrate, QueryClient, useInfiniteQuery } from 'react-query'; 5 | import { nanoid } from 'nanoid'; 6 | import { useInView } from 'framer-motion'; 7 | 8 | // Componentns 9 | import Divider from '../../components/Divider'; 10 | import PageItem from '../../components/PageItem'; 11 | import PageList from '../../components/PageList'; 12 | import ErrorCard from '../../components/ErrorCard'; 13 | import LoadingCard from '../../components/LoadingCard'; 14 | import PageHeading from '../../components/PageHeading'; 15 | 16 | // Helpers 17 | import RAWG from '../../lib/rawg'; 18 | 19 | // Types 20 | import { DataType } from '../../lib/types/index'; 21 | 22 | interface DataProps { 23 | results: DataType[]; 24 | } 25 | 26 | const fetchPublishers = async ({ pageParam = 1 }): Promise => { 27 | const apiKey = process.env.NEXT_PUBLIC_RAWG_API_KEY; 28 | 29 | const { data } = await RAWG.get( 30 | `/publishers?page_size=40&page=${pageParam}&key=${apiKey}` 31 | ); 32 | 33 | return data?.results; 34 | }; 35 | export default function Publishers() { 36 | const ref = useRef(null); 37 | const isInView = useInView(ref); 38 | 39 | const { 40 | data: publishers, 41 | isError, 42 | isLoading, 43 | hasNextPage, 44 | fetchNextPage, 45 | isFetchingNextPage, 46 | } = useInfiniteQuery(['getPublishers'], fetchPublishers, { 47 | getNextPageParam: (lastPage, allPages) => { 48 | if (lastPage.length < 40) return undefined; 49 | 50 | if (allPages.length) return allPages.length + 1; 51 | 52 | return undefined; 53 | }, 54 | }); 55 | 56 | useEffect(() => { 57 | fetchNextPage(); 58 | }, [isInView]); 59 | 60 | if (isLoading) return ; 61 | 62 | if (isError) return ; 63 | 64 | return ( 65 | <> 66 | 67 | GZ | Publishers 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 |
    76 | 77 | {publishers?.pages?.map((page) => 78 | page.map((data) => ( 79 | 80 | )) 81 | )} 82 | 83 | 84 | 97 |
    98 | 99 | ); 100 | } 101 | 102 | export const getStaticProps: GetStaticProps = async () => { 103 | const queryClient = new QueryClient(); 104 | await queryClient.prefetchQuery(['getPublishers'], fetchPublishers); 105 | 106 | return { 107 | props: { 108 | dehydratedState: dehydrate(queryClient), 109 | }, 110 | }; 111 | }; 112 | -------------------------------------------------------------------------------- /pages/developers/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | import Head from 'next/head'; 3 | import { GetStaticProps } from 'next'; 4 | import { dehydrate, QueryClient, useInfiniteQuery } from 'react-query'; 5 | import { nanoid } from 'nanoid'; 6 | import { useInView } from 'framer-motion'; 7 | 8 | // Componentns 9 | import Divider from '../../components/Divider'; 10 | import PageList from '../../components/PageList'; 11 | import PageItem from '../../components/PageItem'; 12 | import ErrorCard from '../../components/ErrorCard'; 13 | import LoadingCard from '../../components/LoadingCard'; 14 | import PageHeading from '../../components/PageHeading'; 15 | 16 | // Helpers 17 | import RAWG from '../../lib/rawg'; 18 | 19 | // Types 20 | import { DataType } from '../../lib/types/index'; 21 | 22 | interface DataProps { 23 | results: DataType[]; 24 | } 25 | 26 | const fetchDevelopers = async ({ pageParam = 1 }): Promise => { 27 | const apiKey = process.env.NEXT_PUBLIC_RAWG_API_KEY; 28 | 29 | const { data } = await RAWG.get( 30 | `/developers?page_size=40&page=${pageParam}&key=${apiKey}` 31 | ); 32 | 33 | return data?.results; 34 | }; 35 | export default function Developers() { 36 | const ref = useRef(null); 37 | const isInView = useInView(ref); 38 | 39 | const { 40 | data: developers, 41 | isError, 42 | isLoading, 43 | hasNextPage, 44 | fetchNextPage, 45 | isFetchingNextPage, 46 | } = useInfiniteQuery(['getDevelopers'], fetchDevelopers, { 47 | getNextPageParam: (lastPage, allPages) => { 48 | if (lastPage.length < 40) return undefined; 49 | 50 | if (allPages.length < 10) return allPages.length + 1; 51 | 52 | return undefined; 53 | }, 54 | }); 55 | 56 | useEffect(() => { 57 | fetchNextPage(); 58 | }, [isInView]); 59 | 60 | if (isLoading) return ; 61 | 62 | if (isError) return ; 63 | 64 | return ( 65 | <> 66 | 67 | GZ | Developers 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 |
    76 | 77 | {developers?.pages?.map((page) => 78 | page.map((data) => ( 79 | 80 | )) 81 | )} 82 | 83 | 84 | 97 |
    98 | 99 | ); 100 | } 101 | 102 | export const getStaticProps: GetStaticProps = async () => { 103 | const queryClient = new QueryClient(); 104 | await queryClient.prefetchQuery(['getDevelopers'], fetchDevelopers); 105 | 106 | return { 107 | props: { 108 | dehydratedState: dehydrate(queryClient), 109 | }, 110 | }; 111 | }; 112 | -------------------------------------------------------------------------------- /components/Screenshots.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import Image from 'next/image'; 3 | import { useQuery } from 'react-query'; 4 | 5 | // Components 6 | import ScreenshotModal from './ScreenshotModal'; 7 | 8 | // Helpers 9 | import RAWG from '../lib/rawg'; 10 | 11 | // Interfaces 12 | import { Screenshots as ScreenshotsProps } from '../lib/types/index'; 13 | 14 | interface Props { 15 | gameSlug: string; 16 | } 17 | 18 | const fetchScreenshots = async (slug: string): Promise => { 19 | const API_KEY = process.env.NEXT_PUBLIC_RAWG_API_KEY; 20 | 21 | const { data } = await RAWG.get(`games/${slug}/screenshots?key=${API_KEY}`); 22 | 23 | return data.results; 24 | }; 25 | 26 | export default function Screenshots({ gameSlug }: Props) { 27 | const [currImg, setCurrImg] = useState(0); 28 | const [isModalOpen, setIsModalOpen] = useState(false); 29 | 30 | const { 31 | data: screens, 32 | isLoading: isScreenLoading, 33 | isFetching: isScreenFetching, 34 | } = useQuery(['getScreens', gameSlug], () => fetchScreenshots(gameSlug)); 35 | 36 | const emptyArray = Array.from({ length: 6 }, () => Math.random()); 37 | 38 | if (isScreenLoading || isScreenFetching) 39 | return ( 40 |
      41 | {emptyArray.map((el) => ( 42 |
    • 47 |
      48 | 57 |
      58 | Loading... 59 |
    • 60 | ))} 61 |
    62 | ); 63 | 64 | return ( 65 | <> 66 |
      67 | {screens?.map(({ image }, idx) => ( 68 |
    • 69 | 85 |
    • 86 | ))} 87 |
    88 | 89 | {isModalOpen && ( 90 | 96 | )} 97 | 98 | ); 99 | } 100 | -------------------------------------------------------------------------------- /lib/animations/index.ts: -------------------------------------------------------------------------------- 1 | import { Variants } from 'framer-motion'; 2 | 3 | export const pageAnimationVariants: Variants = { 4 | initial: { 5 | y: -10, 6 | opacity: 0, 7 | }, 8 | animate: { 9 | y: 0, 10 | opacity: 1, 11 | transition: { duration: 0.5, staggerDirection: 0.4, delay: 0.5 }, 12 | }, 13 | exit: { 14 | y: 10, 15 | opacity: 0, 16 | }, 17 | }; 18 | 19 | export const drawerVariants: Variants = { 20 | initial: { 21 | x: '150%', 22 | backdropFilter: `blur(0px)`, 23 | WebkitBackdropFilter: `blur(0px)`, 24 | }, 25 | animate: { 26 | x: 0, 27 | backdropFilter: `blur(16px)`, 28 | WebkitBackdropFilter: `blur(16px)`, 29 | transition: { duration: 0.5, delayChildren: 0.1, staggerChildren: 0.1 }, 30 | }, 31 | exit: { 32 | x: '150%', 33 | backdropFilter: `blur(0px)`, 34 | WebkitBackdropFilter: `blur(0px)`, 35 | transition: { 36 | delay: 0.6, 37 | duration: 0.35, 38 | staggerChildren: 0.1, 39 | staggerDirection: -1, 40 | }, 41 | }, 42 | }; 43 | 44 | export const fadeIn: Variants = { 45 | closed: { opacity: 0 }, 46 | open: { opacity: 1 }, 47 | }; 48 | 49 | export const fadeInOut: Variants = { 50 | initial: { opacity: 0, x: -50 }, 51 | animate: { opacity: 1, x: 0 }, 52 | exit: { opacity: 0, x: -10 }, 53 | }; 54 | 55 | export const fadeInDown: Variants = { 56 | initial: { opacity: 0, y: -10 }, 57 | animate: { 58 | opacity: 1, 59 | y: 0, 60 | transition: { 61 | duration: 0.2, 62 | delayChildren: 0.2, 63 | staggerChildren: 0.1, 64 | }, 65 | }, 66 | exit: { opacity: 0, y: -10 }, 67 | }; 68 | 69 | export const navVariants = ({ 70 | matches, 71 | isSidebarOpen, 72 | }: { 73 | matches: boolean; 74 | isSidebarOpen: boolean; 75 | }) => 76 | matches 77 | ? { 78 | initial: { 79 | y: 0, 80 | width: isSidebarOpen ? '16rem' : '5rem', 81 | transition: { 82 | staggerChildren: 0.1, 83 | staggerDirection: -1, 84 | delayChildren: 1.5, 85 | }, 86 | }, 87 | animate: { 88 | width: isSidebarOpen ? '16rem' : '5rem', 89 | transition: { 90 | staggerChildren: 0.1, 91 | staggerDirection: 1, 92 | delayChildren: 1.5, 93 | }, 94 | }, 95 | } 96 | : { 97 | initial: { 98 | y: -50, 99 | transition: { 100 | staggerChildren: 0.1, 101 | staggerDirection: -1, 102 | delayChildren: 1.5, 103 | }, 104 | }, 105 | animate: { 106 | y: 0, 107 | transition: { 108 | staggerChildren: 0.1, 109 | staggerDirection: 1, 110 | delayChildren: 1.5, 111 | }, 112 | }, 113 | }; 114 | 115 | export const dividerVariants: Variants = { 116 | initial: { 117 | opacity: 0, 118 | }, 119 | animate: { 120 | opacity: 1, 121 | transition: { 122 | delayChildren: 0.8, 123 | staggerChildren: 0.2, 124 | }, 125 | }, 126 | }; 127 | 128 | export const dividerChildrenVariants: Variants = { 129 | initial: { 130 | width: 0, 131 | }, 132 | animate: { 133 | width: '100%', 134 | transition: { duration: 0.4 }, 135 | }, 136 | }; 137 | 138 | export const listVariants = { 139 | initial: { opacity: 0 }, 140 | animate: { 141 | opacity: 1, 142 | transition: { 143 | delayChildren: 0.3, 144 | staggerChildren: 0.1, 145 | }, 146 | }, 147 | }; 148 | 149 | export const itemVariants = { 150 | initial: { opacity: 0, y: -10 }, 151 | animate: { opacity: 1, y: 0 }, 152 | }; 153 | -------------------------------------------------------------------------------- /hooks/useCollections.ts: -------------------------------------------------------------------------------- 1 | import { 2 | arrayRemove, 3 | arrayUnion, 4 | collection, 5 | deleteDoc, 6 | doc, 7 | onSnapshot, 8 | orderBy, 9 | query, 10 | serverTimestamp, 11 | where, 12 | writeBatch, 13 | } from 'firebase/firestore'; 14 | import { useFirestoreCollectionData } from 'reactfire'; 15 | import { useFirestoreCollectionMutation } from '@react-query-firebase/firestore'; 16 | import { db } from '../firebase/firebase.config'; 17 | 18 | // Hooks 19 | import useUser from './useUser'; 20 | 21 | // Types 22 | import { GameInterface } from '../lib/types/index'; 23 | import { orderByDescQuery } from '../lib/helpers'; 24 | 25 | export interface CollectionInfo { 26 | id?: string; 27 | name: string; 28 | slug?: string; 29 | createdBy: string; 30 | createdAt?: string; 31 | isPublic?: boolean; 32 | description?: string; 33 | games?: GameInterface[] | undefined; 34 | } 35 | 36 | export default function useCollections() { 37 | const { currentUser } = useUser(); 38 | 39 | const userCollectionsRef = collection( 40 | db, 41 | `users/${currentUser?.uid}/collections` 42 | ); 43 | const userCollectionsQuery = orderByDescQuery(userCollectionsRef); 44 | const privateCollectionsQuery = query( 45 | userCollectionsRef, 46 | where('isPublic', '==', false), 47 | orderBy('createdAt', 'desc') 48 | ); 49 | 50 | const { status, data } = useFirestoreCollectionData(userCollectionsQuery, { 51 | idField: 'id', 52 | }); 53 | const { status: privateCollectionsStatus, data: privateCollectionsData } = 54 | useFirestoreCollectionData(privateCollectionsQuery, { 55 | idField: 'id', 56 | }); 57 | const collections = data as CollectionInfo[]; 58 | const privateCollections = privateCollectionsData as CollectionInfo[]; 59 | 60 | const getCurrentUserCollections = ( 61 | userId: string, 62 | callback: (d: any) => void 63 | ) => { 64 | const userCollRef = collection(db, `users/${userId}/collections`); 65 | const userCollQuery = query(userCollRef, orderBy('createdAt', 'desc')); 66 | 67 | return onSnapshot(userCollQuery, callback); 68 | }; 69 | 70 | const addNewDataMutation = useFirestoreCollectionMutation(userCollectionsRef); 71 | const addNewCollection = (collectionInfo: CollectionInfo) => { 72 | const createdAt = serverTimestamp(); 73 | const createdBy = currentUser?.displayName; 74 | const slug = collectionInfo.name 75 | .toLowerCase() 76 | .replace(/[^\w ]+/g, ' ') 77 | .replace(/ +/g, '-'); 78 | 79 | addNewDataMutation.mutate({ 80 | ...collectionInfo, 81 | createdAt, 82 | createdBy, 83 | slug, 84 | }); 85 | }; 86 | 87 | const removeCollection = async (docId: string) => { 88 | try { 89 | const collectionRef = doc(userCollectionsRef, docId); 90 | await deleteDoc(collectionRef); 91 | } catch (error) { 92 | // eslint-disable-next-line no-console 93 | console.log(error); 94 | } 95 | }; 96 | 97 | const manageCollection = async ( 98 | type: 'add' | 'remove', 99 | docId: string, 100 | collectionData: GameInterface 101 | ): Promise => { 102 | const batch = writeBatch(db); 103 | 104 | const userDocRef = doc(userCollectionsRef, docId); 105 | 106 | if (type === 'add') { 107 | batch.update(userDocRef, { 108 | games: arrayUnion(collectionData), 109 | updatedAt: serverTimestamp(), 110 | }); 111 | } else { 112 | batch.update(userDocRef, { 113 | games: arrayRemove(collectionData), 114 | updatedAt: serverTimestamp(), 115 | }); 116 | } 117 | 118 | await batch.commit(); 119 | }; 120 | 121 | return { 122 | status, 123 | collections, 124 | privateCollectionsStatus, 125 | privateCollections, 126 | removeCollection, 127 | addNewCollection, 128 | manageCollection, 129 | getCurrentUserCollections, 130 | }; 131 | } 132 | -------------------------------------------------------------------------------- /pages/tag/[slug].tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | import { useRouter } from 'next/router'; 3 | import { GetStaticPaths, GetStaticProps } from 'next'; 4 | import { 5 | dehydrate, 6 | QueryClient, 7 | useInfiniteQuery, 8 | useQuery, 9 | } from 'react-query'; 10 | 11 | // Components 12 | import Banner from '../../components/Banner'; 13 | import GameCard from '../../components/GameCard'; 14 | import ErrorCard from '../../components/ErrorCard'; 15 | import LoadingCard from '../../components/LoadingCard'; 16 | import GamesListContainer from '../../components/GamesListContainer'; 17 | 18 | // Helpers 19 | import RAWG from '../../lib/rawg'; 20 | 21 | // Types 22 | import { DataType, GameInterface } from '../../lib/types/index'; 23 | 24 | const API_KEY = process.env.NEXT_PUBLIC_RAWG_API_KEY; 25 | 26 | const fetchTag = async (slug: string): Promise => { 27 | const { data } = await RAWG.get(`tags/${slug}?key=${API_KEY}`); 28 | 29 | return data; 30 | }; 31 | 32 | const fetchGames = async ({ 33 | tagSlug, 34 | pageParam = 1, 35 | }: { 36 | tagSlug: string; 37 | pageParam: number; 38 | }): Promise => { 39 | const apiKey = process.env.NEXT_PUBLIC_RAWG_API_KEY; 40 | const { data } = await RAWG.get<{ results: GameInterface[] }>( 41 | `/games?key=${apiKey}&ordering=popularity&page_size=40&page=${pageParam}&tags=${tagSlug}` 42 | ); 43 | 44 | return data?.results; 45 | }; 46 | 47 | export default function Tag() { 48 | const router = useRouter(); 49 | const tagSlug = 50 | typeof router.query?.slug === 'string' ? router.query.slug : ''; 51 | 52 | const { 53 | data: tag, 54 | isError: isTagError, 55 | isLoading: isTagLoading, 56 | } = useQuery(['getTag', tagSlug], () => fetchTag(tagSlug), { 57 | enabled: !!tagSlug, 58 | }); 59 | 60 | const { 61 | data: games, 62 | isError: isGamesError, 63 | isLoading: isGamesLoading, 64 | hasNextPage, 65 | fetchNextPage, 66 | isFetchingNextPage, 67 | } = useInfiniteQuery( 68 | ['getGames', tagSlug], 69 | ({ pageParam = 1 }) => fetchGames({ tagSlug, pageParam }), 70 | { 71 | getNextPageParam: (lastPage, allPages) => { 72 | if (lastPage.length < 40) return undefined; 73 | 74 | if (allPages.length) return allPages.length + 1; 75 | 76 | return undefined; 77 | }, 78 | } 79 | ); 80 | 81 | if (isTagLoading || isGamesLoading) return ; 82 | 83 | if (isTagError || isGamesError) return ; 84 | 85 | return ( 86 | <> 87 | 88 | Tags | {tag?.name} 89 | 90 | 91 | 92 |
    93 | {tag && } 94 | 95 |
    96 | 97 | {games?.pages?.map((page) => 98 | page.map((details) => ( 99 |
    100 | 101 |
    102 | )) 103 | )} 104 |
    105 | 106 | 119 |
    120 |
    121 | 122 | ); 123 | } 124 | 125 | export const getStaticProps: GetStaticProps = async (context) => { 126 | const slug = context.params?.slug as string; 127 | const queryClient = new QueryClient(); 128 | 129 | await queryClient.prefetchQuery(['getTag', slug], () => fetchTag(slug)); 130 | 131 | return { 132 | props: { 133 | dehydratedState: dehydrate(queryClient), 134 | }, 135 | }; 136 | }; 137 | 138 | export const getStaticPaths: GetStaticPaths = async () => ({ 139 | paths: [], 140 | fallback: 'blocking', 141 | }); 142 | -------------------------------------------------------------------------------- /pages/genre/[slug].tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | import { useRouter } from 'next/router'; 3 | import { GetStaticPaths, GetStaticProps } from 'next'; 4 | import { 5 | dehydrate, 6 | QueryClient, 7 | useInfiniteQuery, 8 | useQuery, 9 | } from 'react-query'; 10 | 11 | // Components 12 | import Banner from '../../components/Banner'; 13 | import GameCard from '../../components/GameCard'; 14 | import ErrorCard from '../../components/ErrorCard'; 15 | import LoadingCard from '../../components/LoadingCard'; 16 | import GamesListContainer from '../../components/GamesListContainer'; 17 | 18 | // Helpers 19 | import RAWG from '../../lib/rawg'; 20 | 21 | // Types 22 | import { DataType, GameInterface } from '../../lib/types/index'; 23 | 24 | const API_KEY = process.env.NEXT_PUBLIC_RAWG_API_KEY; 25 | 26 | const fetchGenre = async (slug: string): Promise => { 27 | const { data } = await RAWG.get(`genres/${slug}?key=${API_KEY}`); 28 | 29 | return data; 30 | }; 31 | 32 | const fetchGames = async ({ 33 | genreSlug, 34 | pageParam = 1, 35 | }: { 36 | genreSlug: string; 37 | pageParam: number; 38 | }): Promise => { 39 | const apiKey = process.env.NEXT_PUBLIC_RAWG_API_KEY; 40 | const { data } = await RAWG.get<{ results: GameInterface[] }>( 41 | `/games?key=${apiKey}&discover=true&ordering=popularity&page_size=40&page=${pageParam}&genres=${genreSlug}` 42 | ); 43 | 44 | return data?.results; 45 | }; 46 | 47 | export default function Genre() { 48 | const router = useRouter(); 49 | const genreSlug = 50 | typeof router.query?.slug === 'string' ? router.query.slug : ''; 51 | 52 | const { 53 | data: genre, 54 | isError: isGenreError, 55 | isLoading: isGenreLoading, 56 | } = useQuery(['getGenre', genreSlug], () => fetchGenre(genreSlug), { 57 | enabled: !!genreSlug, 58 | }); 59 | 60 | const { 61 | data: games, 62 | isError: isGamesError, 63 | isLoading: isGamesLoading, 64 | hasNextPage, 65 | fetchNextPage, 66 | isFetchingNextPage, 67 | } = useInfiniteQuery( 68 | ['getGames', genreSlug], 69 | ({ pageParam = 1 }) => fetchGames({ genreSlug, pageParam }), 70 | { 71 | getNextPageParam: (lastPage, allPages) => { 72 | if (lastPage.length < 40) return undefined; 73 | 74 | if (allPages.length) return allPages.length + 1; 75 | 76 | return undefined; 77 | }, 78 | } 79 | ); 80 | 81 | if (isGenreLoading || isGamesLoading) return ; 82 | 83 | if (isGenreError || isGamesError) return ; 84 | 85 | return ( 86 | <> 87 | 88 | Genres | {genre?.name} 89 | 90 | 91 | 92 |
    93 | {genre && } 94 | 95 |
    96 | 97 | {games?.pages?.map((page) => 98 | page.map((details) => ( 99 |
    100 | 101 |
    102 | )) 103 | )} 104 |
    105 | 106 | 119 |
    120 |
    121 | 122 | ); 123 | } 124 | 125 | export const getStaticProps: GetStaticProps = async (context) => { 126 | const slug = context.params?.slug as string; 127 | const queryClient = new QueryClient(); 128 | 129 | await queryClient.prefetchQuery(['getGenre', slug], () => fetchGenre(slug)); 130 | 131 | return { 132 | props: { 133 | dehydratedState: dehydrate(queryClient), 134 | }, 135 | }; 136 | }; 137 | 138 | export const getStaticPaths: GetStaticPaths = async () => ({ 139 | paths: [], 140 | fallback: 'blocking', 141 | }); 142 | -------------------------------------------------------------------------------- /pages/store/[slug].tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | import { useRouter } from 'next/router'; 3 | import { GetStaticPaths, GetStaticProps } from 'next'; 4 | import { 5 | dehydrate, 6 | QueryClient, 7 | useInfiniteQuery, 8 | useQuery, 9 | } from 'react-query'; 10 | 11 | // Components 12 | import Banner from '../../components/Banner'; 13 | import GameCard from '../../components/GameCard'; 14 | import ErrorCard from '../../components/ErrorCard'; 15 | import LoadingCard from '../../components/LoadingCard'; 16 | import GamesListContainer from '../../components/GamesListContainer'; 17 | 18 | // Helpers 19 | import RAWG from '../../lib/rawg'; 20 | 21 | // Types 22 | import { GameInterface, DataType } from '../../lib/types/index'; 23 | 24 | const API_KEY = process.env.NEXT_PUBLIC_RAWG_API_KEY; 25 | 26 | const fetchStore = async (slug: string): Promise => { 27 | const { data } = await RAWG.get(`stores/${slug}?key=${API_KEY}`); 28 | 29 | return data; 30 | }; 31 | 32 | const fetchGames = async ({ 33 | storeId, 34 | pageParam = 1, 35 | }: { 36 | storeId: number; 37 | pageParam: number; 38 | }): Promise => { 39 | const apiKey = process.env.NEXT_PUBLIC_RAWG_API_KEY; 40 | const { data } = await RAWG.get<{ results: GameInterface[] }>( 41 | `/games?key=${apiKey}&ordering=popularity&page_size=40&page=${pageParam}&stores=${storeId}` 42 | ); 43 | 44 | return data?.results; 45 | }; 46 | 47 | export default function Store() { 48 | const router = useRouter(); 49 | const storeSlug = 50 | typeof router.query?.slug === 'string' ? router.query.slug : ''; 51 | 52 | const { 53 | data: store, 54 | isError: isTagError, 55 | isLoading: isTagLoading, 56 | } = useQuery(['getStore', storeSlug], () => fetchStore(storeSlug), { 57 | enabled: !!storeSlug, 58 | }); 59 | 60 | const storeId = store?.id as number; 61 | 62 | const { 63 | data: games, 64 | isError: isGamesError, 65 | isLoading: isGamesLoading, 66 | hasNextPage, 67 | fetchNextPage, 68 | isFetchingNextPage, 69 | } = useInfiniteQuery( 70 | ['getGames', storeId], 71 | ({ pageParam = 1 }) => fetchGames({ storeId, pageParam }), 72 | { 73 | getNextPageParam: (lastPage, allPages) => { 74 | if (lastPage.length < 40) return undefined; 75 | 76 | if (allPages.length) return allPages.length + 1; 77 | 78 | return undefined; 79 | }, 80 | } 81 | ); 82 | 83 | if (isTagLoading || isGamesLoading) return ; 84 | 85 | if (isTagError || isGamesError) return ; 86 | 87 | return ( 88 | <> 89 | 90 | Stores | {store?.name} 91 | 92 | 93 | 94 |
    95 | {store && } 96 | 97 |
    98 | 99 | {games?.pages?.map((page) => 100 | page.map((details) => ( 101 |
    102 | 103 |
    104 | )) 105 | )} 106 |
    107 | 108 | 121 |
    122 |
    123 | 124 | ); 125 | } 126 | 127 | export const getStaticProps: GetStaticProps = async (context) => { 128 | const slug = context.params?.slug as string; 129 | const queryClient = new QueryClient(); 130 | 131 | await queryClient.prefetchQuery(['getStore', slug], () => fetchStore(slug)); 132 | 133 | return { 134 | props: { 135 | dehydratedState: dehydrate(queryClient), 136 | }, 137 | }; 138 | }; 139 | 140 | export const getStaticPaths: GetStaticPaths = async () => ({ 141 | paths: [], 142 | fallback: 'blocking', 143 | }); 144 | -------------------------------------------------------------------------------- /pages/publisher/[slug].tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | import { useRouter } from 'next/router'; 3 | import { GetStaticPaths, GetStaticProps } from 'next'; 4 | import { 5 | dehydrate, 6 | QueryClient, 7 | useInfiniteQuery, 8 | useQuery, 9 | } from 'react-query'; 10 | 11 | // Components 12 | import Banner from '../../components/Banner'; 13 | import GameCard from '../../components/GameCard'; 14 | import ErrorCard from '../../components/ErrorCard'; 15 | import LoadingCard from '../../components/LoadingCard'; 16 | import GamesListContainer from '../../components/GamesListContainer'; 17 | 18 | // Helpers 19 | import RAWG from '../../lib/rawg'; 20 | 21 | // Types 22 | import { DataType, GameInterface } from '../../lib/types/index'; 23 | 24 | const API_KEY = process.env.NEXT_PUBLIC_RAWG_API_KEY; 25 | 26 | const fetchPublisher = async (slug: string): Promise => { 27 | const { data } = await RAWG.get( 28 | `publishers/${slug}?key=${API_KEY}` 29 | ); 30 | 31 | return data; 32 | }; 33 | 34 | const fetchGames = async ({ 35 | publisherSlug, 36 | pageParam = 1, 37 | }: { 38 | publisherSlug: string; 39 | pageParam: number; 40 | }): Promise => { 41 | const apiKey = process.env.NEXT_PUBLIC_RAWG_API_KEY; 42 | const { data } = await RAWG.get<{ results: GameInterface[] }>( 43 | `/games?key=${apiKey}&ordering=popularity&page_size=40&page=${pageParam}&publishers=${publisherSlug}` 44 | ); 45 | 46 | return data?.results; 47 | }; 48 | 49 | export default function Publisher() { 50 | const router = useRouter(); 51 | const publisherSlug = 52 | typeof router.query?.slug === 'string' ? router.query.slug : ''; 53 | 54 | const { 55 | data: publisher, 56 | isError: isTagError, 57 | isLoading: isTagLoading, 58 | } = useQuery( 59 | ['getPublisher', publisherSlug], 60 | () => fetchPublisher(publisherSlug), 61 | { enabled: !!publisherSlug } 62 | ); 63 | 64 | const { 65 | data: games, 66 | isError: isGamesError, 67 | isLoading: isGamesLoading, 68 | 69 | hasNextPage, 70 | fetchNextPage, 71 | isFetchingNextPage, 72 | } = useInfiniteQuery( 73 | ['getGames', publisherSlug], 74 | ({ pageParam = 1 }) => fetchGames({ publisherSlug, pageParam }), 75 | { 76 | getNextPageParam: (lastPage, allPages) => { 77 | if (lastPage.length < 40) return undefined; 78 | 79 | if (allPages.length) return allPages.length + 1; 80 | 81 | return undefined; 82 | }, 83 | } 84 | ); 85 | 86 | if (isTagLoading || isGamesLoading) return ; 87 | 88 | if (isTagError || isGamesError) return ; 89 | 90 | return ( 91 | <> 92 | 93 | Publishers | {publisher?.name} 94 | 95 | 96 | 97 |
    98 | {publisher && } 99 | 100 |
    101 | 102 | {games?.pages?.map((page) => 103 | page.map((details) => ( 104 |
    105 | 106 |
    107 | )) 108 | )} 109 |
    110 | 111 | 124 |
    125 |
    126 | 127 | ); 128 | } 129 | 130 | export const getStaticProps: GetStaticProps = async (context) => { 131 | const slug = context.params?.slug as string; 132 | const queryClient = new QueryClient(); 133 | 134 | await queryClient.prefetchQuery(['getPublisher', slug], () => 135 | fetchPublisher(slug) 136 | ); 137 | 138 | return { 139 | props: { 140 | dehydratedState: dehydrate(queryClient), 141 | }, 142 | }; 143 | }; 144 | 145 | export const getStaticPaths: GetStaticPaths = async () => ({ 146 | paths: [], 147 | fallback: 'blocking', 148 | }); 149 | -------------------------------------------------------------------------------- /pages/platform/[slug].tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | import { useRouter } from 'next/router'; 3 | import { GetStaticPaths, GetStaticProps } from 'next'; 4 | import { 5 | dehydrate, 6 | QueryClient, 7 | useInfiniteQuery, 8 | useQuery, 9 | } from 'react-query'; 10 | 11 | // Components 12 | import Banner from '../../components/Banner'; 13 | import GameCard from '../../components/GameCard'; 14 | import ErrorCard from '../../components/ErrorCard'; 15 | import LoadingCard from '../../components/LoadingCard'; 16 | import GamesListContainer from '../../components/GamesListContainer'; 17 | 18 | // Helpers 19 | import RAWG from '../../lib/rawg'; 20 | 21 | // Types 22 | import { DataType, GameInterface } from '../../lib/types/index'; 23 | 24 | const API_KEY = process.env.NEXT_PUBLIC_RAWG_API_KEY; 25 | 26 | const fetchPlatform = async (slug: string): Promise => { 27 | const { data } = await RAWG.get(`platforms/${slug}?key=${API_KEY}`); 28 | 29 | return data; 30 | }; 31 | 32 | const fetchGames = async ({ 33 | platformId, 34 | pageParam = 1, 35 | }: { 36 | platformId: number; 37 | pageParam: number; 38 | }): Promise => { 39 | const apiKey = process.env.NEXT_PUBLIC_RAWG_API_KEY; 40 | const { data } = await RAWG.get<{ results: GameInterface[] }>( 41 | `/games?key=${apiKey}&ordering=popularity&page_size=40&page=${pageParam}&platforms=${platformId}` 42 | ); 43 | 44 | return data?.results; 45 | }; 46 | 47 | export default function Platform() { 48 | const router = useRouter(); 49 | const platformSlug = 50 | typeof router.query?.slug === 'string' ? router.query.slug : ''; 51 | 52 | const { 53 | data: platform, 54 | isError: isTagError, 55 | isLoading: isTagLoading, 56 | } = useQuery( 57 | ['getPlatform', platformSlug], 58 | () => fetchPlatform(platformSlug), 59 | { 60 | enabled: !!platformSlug, 61 | } 62 | ); 63 | 64 | const platformId = platform?.id as number; 65 | 66 | const { 67 | data: games, 68 | isError: isGamesError, 69 | isLoading: isGamesLoading, 70 | hasNextPage, 71 | fetchNextPage, 72 | isFetchingNextPage, 73 | } = useInfiniteQuery( 74 | ['getGames', platformId], 75 | ({ pageParam = 1 }) => fetchGames({ platformId, pageParam }), 76 | { 77 | getNextPageParam: (lastPage, allPages) => { 78 | if (lastPage.length < 40) return undefined; 79 | 80 | if (allPages.length) return allPages.length + 1; 81 | 82 | return undefined; 83 | }, 84 | } 85 | ); 86 | 87 | if (isTagLoading || isGamesLoading) return ; 88 | 89 | if (isTagError || isGamesError) return ; 90 | 91 | return ( 92 | <> 93 | 94 | Platforms | {platform?.name} 95 | 96 | 97 | 98 |
    99 | {platform && } 100 | 101 |
    102 | 103 | {games?.pages?.map((page) => 104 | page.map((details) => ( 105 |
    106 | 107 |
    108 | )) 109 | )} 110 |
    111 | 112 | 125 |
    126 |
    127 | 128 | ); 129 | } 130 | 131 | export const getStaticProps: GetStaticProps = async (context) => { 132 | const slug = context.params?.slug as string; 133 | const queryClient = new QueryClient(); 134 | 135 | await queryClient.prefetchQuery(['getPlatform', slug], () => 136 | fetchPlatform(slug) 137 | ); 138 | 139 | return { 140 | props: { 141 | dehydratedState: dehydrate(queryClient), 142 | }, 143 | }; 144 | }; 145 | 146 | export const getStaticPaths: GetStaticPaths = async () => ({ 147 | paths: [], 148 | fallback: 'blocking', 149 | }); 150 | -------------------------------------------------------------------------------- /components/MessangerUsers.tsx: -------------------------------------------------------------------------------- 1 | import { Dispatch, SetStateAction, useEffect, useState } from 'react'; 2 | import Image from 'next/image'; 3 | 4 | // Components 5 | import SignInButton from './SignInButton'; 6 | 7 | // Hooks 8 | import useFollow from '../hooks/useFollow'; 9 | import useMessages from '../hooks/useMessages'; 10 | import useUser, { UserInterface } from '../hooks/useUser'; 11 | 12 | // Interfaces 13 | import { MessageType } from '../lib/types/index'; 14 | 15 | interface Props { 16 | sendTo: string; 17 | setSendTo: Dispatch>; 18 | setMessageLimit: Dispatch>; 19 | setCurrentMessages: Dispatch>; 20 | } 21 | 22 | export default function MessangerUsers({ 23 | sendTo, 24 | setSendTo, 25 | setMessageLimit, 26 | setCurrentMessages, 27 | }: Props) { 28 | const { followList } = useFollow(); 29 | const { currentUser, isUserLoading } = useUser(); 30 | const { getUnseenMessages, selectChat } = useMessages(); 31 | const [unseenMessages, setUnseenMessages] = useState(); 32 | 33 | useEffect(() => { 34 | const lastMessageCallback = (d: any) => 35 | setUnseenMessages( 36 | d.docs.map((doc: any) => ({ ...doc.data(), id: doc.id })) 37 | ); 38 | 39 | const lastMessageUnsub = getUnseenMessages(lastMessageCallback); 40 | 41 | return () => lastMessageUnsub(); 42 | }, [sendTo]); 43 | 44 | const unseens = (user: UserInterface) => 45 | unseenMessages?.filter((msg) => msg.from === user?.uid && !msg.seen); 46 | 47 | const mergeUsers = function mergeArraysAndDeduplicate() { 48 | const followers = followList('followers') as UserInterface[]; 49 | const following = followList('following') as UserInterface[]; 50 | const mergedArray = [...followers, ...following]; 51 | 52 | return mergedArray.filter( 53 | (item, index) => 54 | index === mergedArray.findIndex((i) => i.uid === item.uid) 55 | ); 56 | }; 57 | 58 | return ( 59 |
    60 | {currentUser ? ( 61 | <> 62 | Users 63 |
      64 | {mergeUsers()?.map((user) => ( 65 |
    • 66 | 97 |
    • 98 | ))} 99 |
    100 | 101 | ) : ( 102 |
    103 | {!currentUser ? ( 104 |
    105 | 106 | Sign In to chat with others! 107 | 108 | 109 |
    110 | ) : ( 111 | 'You have not created any collections yet' 112 | )} 113 |
    114 | )} 115 |
    116 | ); 117 | } 118 | -------------------------------------------------------------------------------- /hooks/useMessages.ts: -------------------------------------------------------------------------------- 1 | import { SyntheticEvent } from 'react'; 2 | import { 3 | addDoc, 4 | collection, 5 | limit, 6 | onSnapshot, 7 | orderBy, 8 | query, 9 | serverTimestamp, 10 | doc, 11 | setDoc, 12 | getDoc, 13 | updateDoc, 14 | where, 15 | } from 'firebase/firestore'; 16 | import { db } from '../firebase/firebase.config'; 17 | 18 | // Hooks 19 | import useUser from './useUser'; 20 | 21 | export default function useMessages() { 22 | const { currentUser } = useUser(); 23 | 24 | const updateLastMessage = async (targetUserId: string) => { 25 | const currentUserId = currentUser?.uid as string; 26 | 27 | const id = 28 | currentUserId > targetUserId 29 | ? `${currentUserId + targetUserId}` 30 | : `${targetUserId + currentUserId}`; 31 | 32 | const lastMessageDocRef = doc(db, 'lastMessage', id); 33 | const docSnap = await getDoc(lastMessageDocRef); 34 | 35 | if (docSnap.data() && docSnap.data()?.from !== currentUser?.uid) { 36 | await updateDoc(lastMessageDocRef, { seen: true }); 37 | } 38 | }; 39 | 40 | const addNewMessage = async ( 41 | e: SyntheticEvent, 42 | message: string, 43 | targetUserId: string 44 | // eslint-disable-next-line consistent-return 45 | ) => { 46 | e.preventDefault(); 47 | 48 | if (message.trim() === '') return; 49 | 50 | const currentUserId = currentUser?.uid as string; 51 | 52 | const id = 53 | currentUserId > targetUserId 54 | ? `${currentUserId + targetUserId}` 55 | : `${targetUserId + currentUserId}`; 56 | 57 | const userMessagesRef = collection(db, `messages/${id}/chat`); 58 | const lastMessageDocRef = doc(db, `lastMessage/${id}`); 59 | 60 | const createdAt = serverTimestamp(); 61 | const data = { 62 | message, 63 | createdAt, 64 | chatId: id, 65 | seen: false, 66 | to: targetUserId, 67 | from: currentUser?.uid, 68 | photoURL: currentUser?.photoURL, 69 | displayName: currentUser?.displayName, 70 | }; 71 | 72 | if (targetUserId && currentUser) { 73 | await addDoc(userMessagesRef, data); 74 | await setDoc(lastMessageDocRef, data); 75 | } 76 | }; 77 | 78 | const getMessages = ( 79 | targetUserId: string, 80 | callback: (d: any) => void, 81 | msgLimit: number 82 | ) => { 83 | const currentUserId = currentUser?.uid as string; 84 | 85 | const id = 86 | currentUserId > targetUserId 87 | ? `${currentUserId + targetUserId}` 88 | : `${targetUserId + currentUserId}`; 89 | 90 | const userMessagesRef = collection(db, `messages/${id}/chat`); 91 | 92 | const userMessagesQuery = query( 93 | userMessagesRef, 94 | orderBy('createdAt', 'desc'), 95 | limit(msgLimit) 96 | ); 97 | 98 | return onSnapshot(userMessagesQuery, callback); 99 | }; 100 | 101 | const getLastMessage = (targetUserId: string, callback: (d: any) => void) => { 102 | const currentUserId = currentUser?.uid as string; 103 | 104 | const id = 105 | currentUserId > targetUserId 106 | ? `${currentUserId + targetUserId}` 107 | : `${targetUserId + currentUserId}`; 108 | 109 | const lastMessageRef = doc(db, `lastMessage/${id}`); 110 | 111 | return onSnapshot(lastMessageRef, callback); 112 | }; 113 | 114 | const getUnseenMessages = (callback: (d: any) => void) => { 115 | const lastMessageRef = collection(db, `lastMessage`); 116 | 117 | const lastMessageQuery = query(lastMessageRef, where('seen', '==', false)); 118 | 119 | return onSnapshot(lastMessageQuery, callback); 120 | }; 121 | 122 | const selectChat = async ( 123 | targetUserId: string, 124 | setCurrentMessages: (msg: any) => void 125 | ) => { 126 | const currentUserId = currentUser?.uid as string; 127 | 128 | const id = 129 | currentUserId > targetUserId 130 | ? `${currentUserId + targetUserId}` 131 | : `${targetUserId + currentUserId}`; 132 | 133 | const msgsRef = collection(db, 'messages', id, 'chat'); 134 | const q = query(msgsRef, orderBy('createdAt', 'desc')); 135 | 136 | onSnapshot(q, (d: any) => { 137 | setCurrentMessages( 138 | d.docs.map((docs: any) => ({ ...docs.data(), id: docs.id })) 139 | ); 140 | }); 141 | 142 | updateLastMessage(targetUserId); 143 | }; 144 | 145 | return { 146 | selectChat, 147 | getMessages, 148 | addNewMessage, 149 | getLastMessage, 150 | updateLastMessage, 151 | getUnseenMessages, 152 | }; 153 | } 154 | -------------------------------------------------------------------------------- /pages/developer/[slug].tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | import { useRouter } from 'next/router'; 3 | import { GetStaticPaths, GetStaticProps } from 'next'; 4 | import { 5 | dehydrate, 6 | QueryClient, 7 | useInfiniteQuery, 8 | useQuery, 9 | } from 'react-query'; 10 | 11 | // Components 12 | import Banner from '../../components/Banner'; 13 | import GameCard from '../../components/GameCard'; 14 | import ErrorCard from '../../components/ErrorCard'; 15 | import LoadingCard from '../../components/LoadingCard'; 16 | import GamesListContainer from '../../components/GamesListContainer'; 17 | 18 | // Helpers 19 | import RAWG from '../../lib/rawg'; 20 | 21 | // Types 22 | import { DataType, GameInterface } from '../../lib/types/index'; 23 | 24 | const API_KEY = process.env.NEXT_PUBLIC_RAWG_API_KEY; 25 | 26 | const fetchDeveloper = async (slug: string): Promise => { 27 | const { data } = await RAWG.get( 28 | `developers/${slug}?key=${API_KEY}` 29 | ); 30 | 31 | return data; 32 | }; 33 | 34 | const fetchGames = async ({ 35 | developerSlug, 36 | pageParam = 1, 37 | }: { 38 | developerSlug: string; 39 | pageParam: number; 40 | }): Promise => { 41 | const apiKey = process.env.NEXT_PUBLIC_RAWG_API_KEY; 42 | const { data } = await RAWG.get<{ results: GameInterface[] }>( 43 | `/games?key=${apiKey}&ordering=popularity&page_size=40&page=${pageParam}&developers=${developerSlug}` 44 | ); 45 | 46 | return data?.results; 47 | }; 48 | 49 | export default function Developer() { 50 | const router = useRouter(); 51 | const developerSlug = 52 | typeof router.query?.slug === 'string' ? router.query.slug : ''; 53 | 54 | const { 55 | data: developer, 56 | isError: isTagError, 57 | isLoading: isTagLoading, 58 | } = useQuery( 59 | ['getDeveloper', developerSlug], 60 | () => fetchDeveloper(developerSlug), 61 | { enabled: !!developerSlug } 62 | ); 63 | 64 | const { 65 | data: games, 66 | isError: isGamesError, 67 | isLoading: isGamesLoading, 68 | 69 | hasNextPage, 70 | fetchNextPage, 71 | isFetchingNextPage, 72 | } = useInfiniteQuery( 73 | ['getGames', developerSlug], 74 | ({ pageParam = 1 }) => fetchGames({ developerSlug, pageParam }), 75 | { 76 | getNextPageParam: (lastPage, allPages) => { 77 | if (lastPage.length < 40) return undefined; 78 | 79 | if (allPages.length) return allPages.length + 1; 80 | 81 | return undefined; 82 | }, 83 | } 84 | ); 85 | 86 | if (isTagLoading || isGamesLoading) return ; 87 | 88 | if (isTagError || isGamesError) return ; 89 | 90 | return ( 91 | <> 92 | 93 | Developers | {developer?.name} 94 | 98 | 99 | 100 |
    101 | {developer && } 102 | 103 |
    104 | 105 | {games?.pages?.map((page) => 106 | page.map((details) => ( 107 |
    108 | 109 |
    110 | )) 111 | )} 112 |
    113 | 114 | 127 |
    128 |
    129 | 130 | ); 131 | } 132 | 133 | export const getStaticProps: GetStaticProps = async (context) => { 134 | const slug = context.params?.slug as string; 135 | const queryClient = new QueryClient(); 136 | 137 | await queryClient.prefetchQuery(['getDeveloper', slug], () => 138 | fetchDeveloper(slug) 139 | ); 140 | 141 | return { 142 | props: { 143 | dehydratedState: dehydrate(queryClient), 144 | }, 145 | }; 146 | }; 147 | 148 | export const getStaticPaths: GetStaticPaths = async () => ({ 149 | paths: [], 150 | fallback: 'blocking', 151 | }); 152 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Game Zone 2 | 3 | ## Table of contents 4 | 5 | - [Overview](#overview) 6 | - [The challenge](#the-challenge) 7 | - [Screenshot](#screenshot) 8 | - [Links](#links) 9 | - [My process](#my-process) 10 | - [Built with](#built-with) 11 | - [What I learned](#what-i-learned) 12 | - [Continued development](#continued-development) 13 | - [Useful resources](#useful-resources) 14 | - [Author's Links](#authors-links) 15 | 16 | ## Overview 17 | 18 | ### The challenge 19 | 20 | Users should be able to: 21 | 22 | - Add/Remove games to their bookmarks 23 | - Create private and public collections 24 | - Be able to follow and unfollow other users 25 | - Add/Remove games to their created collection 26 | - Search for relevant games and users on all pages 27 | - See hover states for all interactive elements on the page 28 | - View the optimal layout for the app depending on their device's screen size 29 | - If the collection is public it should appear in users' page and be visible for others 30 | - Once the user follow other user ther should be able to have one-on-one chat in Messages page 31 | - Navigate between Home, Bookmarks, Collections, Messages, Users' Profile and other dynamic routes 32 | - **Bonus**: Build this project as a full-stack application 33 | 34 | ### Screenshot 35 | 36 | ![screenshot](./screenshot.png) 37 | 38 | ### Links 39 | 40 | - Live Site URL: [https://game-zone-kens-visuals.vercel.app/](https://game-zone-kens-visuals.vercel.app/) 41 | 42 | ## My process 43 | 44 | ### Built with 45 | 46 | ![NextJS](https://img.shields.io/badge/next.js-000000?style=for-the-badge&logo=nextdotjs&logoColor=white) ![TypeScript](https://img.shields.io/badge/TypeScript-007ACC?style=for-the-badge&logo=typescript&logoColor=white) ![TailwindCSS](https://img.shields.io/badge/Tailwind_CSS-38B2AC?style=for-the-badge&logo=tailwind-css&logoColor=white) ![Firebase](https://img.shields.io/badge/firebase-ffca28?style=for-the-badge&logo=firebase&logoColor=black) ![React Query](https://img.shields.io/badge/React_Query-FF4154?style=for-the-badge&logo=React_Query&logoColor=white) 47 | 48 | ### What I learned 49 | 50 | Well well well, looks like I've got another project to add to my portfolio. This one's a doozy, let me tell you. When I first started, I didn't have a clue how much work was ahead of me. But, as they say, ignorance is bliss. And I must admit, I'm feeling pretty pleased with myself now that it's all said and done. 51 | 52 | I wanted this project to be the epitome of a social media platform for gamers. And I daresay I've succeeded in that regard. I used some new toys in this project, like React Query. It's a real treat to work with. I also got to flex my muscles with Firebase and Framer Motion. I also was able to deepen my understanding of TypeScript. I can honestly say that this project took my skills to a whole new level. 53 | 54 | Now, I'm not one to rest on my laurels. I've got plans for this project, big plans. More functionality, more improvements, you name it. I'm not done yet. And let me tell you, it wasn't always going to be a platform for gamers. Oh no, this project started as a social media site for book lovers. But I soon discovered that the books API I was hoping to use just wasn't up to snuff. So, I pivoted and here we are. 55 | 56 | In conclusion, this project has been one wild ride. And I'm thrilled with the end result. It's proof of my prowess as a web developer, and I'm eager to take on more challenges like this one in the future. 57 | 58 | ### Useful resources 59 | 60 | - [rawg.io](https://rawg.io/apidocs) - RAWG API is a powerful tool for working with video games data that was used in this project 61 | - [React Query Firebase](https://react-query-firebase.invertase.dev/) - React Query Firebase provides a set of easy to use hooks for common Firebase usecases. Each hook wraps around React Query, allowing to easily integrate the hooks into a new or existing project, whilst enjoying the powerful benefits React Query offers. 62 | - [Flowbite](https://flowbite.com/docs/getting-started/introduction/) - Flowbite is an open-source library of UI components based on the utility-first Tailwind CSS framework featuring dark mode support, a Figma design system, and more. 63 | - [Mastering data fetching with React Query and Next.js](https://prateeksurana.me/blog/mastering-data-fetching-with-react-query-and-next-js/) - Really cool article that helped me to get started with Next.JS and React Query. 64 | 65 | ## Author's Links 66 | 67 | - Medium - [@kens_visuals](https://medium.com/@kens_visuals) 68 | - CodePen - [@kens-visuals](https://codepen.io/kens-visuals) 69 | - Codewars - [@kens_visuals](https://www.codewars.com/users/kens_visuals) 70 | - Frontend Mentor - [@kens-visuals](https://www.frontendmentor.io/profile/kens-visuals) 71 | -------------------------------------------------------------------------------- /components/ScreenshotModal.tsx: -------------------------------------------------------------------------------- 1 | import { Dispatch, RefObject, SetStateAction } from 'react'; 2 | import Image from 'next/image'; 3 | 4 | // Hooks 5 | import useOutsideClick from '../hooks/useClickOutside'; 6 | 7 | // Interfaces 8 | import { Screenshots } from '../lib/types/index'; 9 | 10 | interface Props { 11 | screens: Screenshots[] | undefined; 12 | currImg: number; 13 | setCurrImg: Dispatch>; 14 | setIsModalOpen: Dispatch>; 15 | } 16 | 17 | export default function ScreenshotModal({ 18 | screens, 19 | currImg, 20 | setCurrImg, 21 | setIsModalOpen, 22 | }: Props) { 23 | const callback = () => setIsModalOpen(false); 24 | const ref = useOutsideClick(callback) as RefObject; 25 | 26 | const prevImg = () => 27 | setCurrImg( 28 | screens?.length && currImg === 0 ? screens.length - 1 : currImg - 1 29 | ); 30 | const nextImg = () => 31 | setCurrImg( 32 | screens?.length && currImg >= screens.length - 1 ? 0 : currImg + 1 33 | ); 34 | 35 | return ( 36 |
    37 |
    38 |
    39 | {screens?.length && ( 40 | game screenshot 47 | )} 48 |
    49 |
    50 | {screens?.length && ( 51 | game screenshot 58 | )} 59 |
    60 | 61 |
      62 | {screens?.map(({ image }, idx) => ( 63 |
    • 64 |
    • 73 | ))} 74 |
    75 | 76 | 100 | 124 |
    125 |
    126 | ); 127 | } 128 | -------------------------------------------------------------------------------- /components/FollowersTab.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import Image from 'next/image'; 3 | import { Tab } from '@headlessui/react'; 4 | 5 | // Components 6 | import FollowButton from './FollowButton'; 7 | 8 | // Hooks 9 | import useFollow from '../hooks/useFollow'; 10 | 11 | // Helpers 12 | import { formatName } from '../lib/helpers'; 13 | 14 | // Interfaces 15 | import { UserInterface } from '../hooks/useUser'; 16 | 17 | interface Props { 18 | user: UserInterface; 19 | currentUser: UserInterface; 20 | isOwner: boolean; 21 | } 22 | 23 | export default function FollowersTab({ user, currentUser, isOwner }: Props) { 24 | const { followList } = useFollow(); 25 | 26 | const followersArr = [...(followList('followers', user?.uid) || [])]; 27 | const followingArr = [...(followList('following', user?.uid) || [])]; 28 | 29 | const followersList = [followersArr, followingArr]; 30 | 31 | return ( 32 | 33 | 34 | {[ 35 | { name: 'Followers', count: followersArr.length }, 36 | { name: 'Following', count: followingArr.length }, 37 | ].map((follower) => ( 38 | 41 | `flex w-full items-center justify-center gap-2 rounded-lg py-2.5 font-outfit text-sm leading-5 text-white transition-all duration-300 focus:outline-none focus:ring focus:ring-primary-light focus:ring-opacity-60 md:p-4 md:text-h3 42 | ${ 43 | selected 44 | ? 'bg-primary shadow' 45 | : 'text-white/70 hover:bg-white/[0.12] hover:text-white' 46 | }` 47 | } 48 | > 49 | {follower.name}: 50 | {follower.count} 51 | 52 | ))} 53 | 54 | 55 | {followersList.map((users, idx) => ( 56 | 61 | {users.length ? ( 62 |
      63 | {users.map((usr) => ( 64 |
    • 68 |
      69 | {usr.photoURL && ( 70 | {usr.displayName} 78 | )} 79 | 83 | {usr.displayName} 84 | 85 |
      86 | {usr.uid !== currentUser?.uid && ( 87 |
      88 | 92 | Message 93 | 94 | 95 | 96 |
      97 | )} 98 |
    • 99 | ))} 100 |
    101 | ) : ( 102 |
    103 | {isOwner ? ( 104 | 105 | You don't follow anyone 106 | 107 | ) : ( 108 | 109 | {formatName(user.displayName)} doesn't follow anyone 110 | 111 | )} 112 |
    113 | )} 114 |
    115 | ))} 116 |
    117 |
    118 | ); 119 | } 120 | -------------------------------------------------------------------------------- /components/CollectionsDropdown.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import Link from 'next/link'; 3 | 4 | // Hooks 5 | import useCollections from '../hooks/useCollections'; 6 | 7 | // Interfaces 8 | import { GameInterface } from '../lib/types/index'; 9 | 10 | interface Props { 11 | game: GameInterface; 12 | isDropdownOpen: boolean; 13 | } 14 | 15 | export default function CollectionsDropdown({ game, isDropdownOpen }: Props) { 16 | const { collections, manageCollection } = useCollections(); 17 | const [wasAddedToCollection, setWasAddedToCollection] = useState(false); 18 | 19 | useEffect(() => { 20 | const timer = setTimeout(() => setWasAddedToCollection(false), 1500); 21 | 22 | return () => clearTimeout(timer); 23 | }, [wasAddedToCollection]); 24 | 25 | const { 26 | id, 27 | name, 28 | slug, 29 | background_image: backgroundImage, 30 | released, 31 | genres, 32 | } = game; 33 | 34 | return ( 35 |
    40 |
      41 | {collections?.length > 0 ? ( 42 | collections?.map((collection) => ( 43 |
    • 47 | 93 |
    • 94 | )) 95 | ) : ( 96 |
    • 97 | You should create some collections 98 |
    • 99 | )} 100 |
    101 | 102 | 106 | Create New Collection 107 | 113 | 118 | 119 | 120 |
    121 | ); 122 | } 123 | -------------------------------------------------------------------------------- /lib/routes.tsx: -------------------------------------------------------------------------------- 1 | export const mainRoutes = [ 2 | { 3 | name: 'Home', 4 | path: '/', 5 | icon: ( 6 | 11 | ), 12 | }, 13 | { 14 | name: 'Bookmarks', 15 | path: '/bookmarks', 16 | icon: ( 17 | 22 | ), 23 | }, 24 | { 25 | name: 'Collections', 26 | path: '/collections', 27 | icon: ( 28 | 29 | ), 30 | }, 31 | { 32 | name: 'Messages', 33 | path: '/messages', 34 | icon: ( 35 | <> 36 | 37 | 38 | 39 | ), 40 | }, 41 | ]; 42 | 43 | export const pageRoutes = [ 44 | { 45 | name: 'Tags', 46 | icon: ( 47 | 52 | ), 53 | }, 54 | { 55 | name: 'Stores', 56 | icon: ( 57 | <> 58 | 59 | 64 | 65 | ), 66 | }, 67 | { 68 | name: 'Genres', 69 | icon: ( 70 | 71 | ), 72 | }, 73 | { 74 | name: 'Platforms', 75 | icon: ( 76 | 81 | ), 82 | }, 83 | { 84 | name: 'Publishers', 85 | icon: ( 86 | <> 87 | 92 | 93 | 94 | ), 95 | }, 96 | { 97 | name: 'Developers', 98 | icon: ( 99 | 104 | ), 105 | }, 106 | ]; 107 | -------------------------------------------------------------------------------- /pages/messages/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from 'react'; 2 | import Head from 'next/head'; 3 | import Link from 'next/link'; 4 | import Image from 'next/image'; 5 | 6 | // Components 7 | import Message from '../../components/Message'; 8 | import MessangerInput from '../../components/MessangerInput'; 9 | import MessangerUsers from '../../components/MessangerUsers'; 10 | 11 | // Hooks 12 | import useFollow from '../../hooks/useFollow'; 13 | import useMessages from '../../hooks/useMessages'; 14 | 15 | // Interfaces 16 | import { MessageType } from '../../lib/types/index'; 17 | 18 | export default function Messanger() { 19 | const { followList } = useFollow(); 20 | const { getMessages, getLastMessage } = useMessages(); 21 | 22 | const [currentMessages, setCurrentMessages] = useState([]); 23 | const [sendTo, setSendTo] = useState(''); 24 | const [messageLimit, setMessageLimit] = useState(25); 25 | const [lastMessage, setLastMessage] = useState(); 26 | 27 | const scrollRef = useRef(null); 28 | 29 | const otherUser = followList('following') 30 | ?.filter((user) => user.uid === sendTo) 31 | .at(0); 32 | 33 | useEffect(() => { 34 | const lastMessageCallback = (d: any) => 35 | setCurrentMessages( 36 | d.docs.map((doc: any) => ({ ...doc.data(), id: doc.id })) 37 | ); 38 | 39 | const lastMessageUnsub = getMessages( 40 | sendTo, 41 | lastMessageCallback, 42 | messageLimit 43 | ); 44 | 45 | return () => lastMessageUnsub(); 46 | }, [messageLimit]); 47 | 48 | useEffect(() => { 49 | if (!sendTo) return; 50 | 51 | const lastMessageCallback = (doc: any) => setLastMessage(doc.data()); 52 | 53 | const lastMessageUnsub = getLastMessage(sendTo, lastMessageCallback); 54 | 55 | // eslint-disable-next-line consistent-return 56 | return () => { 57 | lastMessageUnsub(); 58 | }; 59 | }, [sendTo, messageLimit]); 60 | 61 | return ( 62 | <> 63 | 64 | GZ | Messages 65 | 66 | 67 | 68 |
    69 | 75 | 76 |
    77 |
    78 | {otherUser && ( 79 | <> 80 | 84 | {otherUser?.displayName} 91 | 92 | {otherUser.displayName} 93 | 94 | 95 | 96 | {lastMessage?.message && ( 97 | 40 ? 'w-80' : 'w-fit' 100 | }`} 101 | > 102 | Last message:{' '} 103 | {lastMessage?.message} 104 | 105 | )} 106 | 107 | )} 108 |
    109 | 110 |
      111 | {currentMessages.length > 0 ? ( 112 | <> 113 |
    • 114 | {currentMessages?.map((msg) => ( 115 | 116 | ))} 117 |
    • currentMessages.length && 'hidden' 120 | }`} 121 | > 122 | 130 |
    • {' '} 131 | 132 | ) : ( 133 |
    • 134 | No messages yet! 135 |
    • 136 | )} 137 |
    138 | 139 | 140 |
    141 |
    142 | 143 | ); 144 | } 145 | -------------------------------------------------------------------------------- /components/UserProfile.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import Image from 'next/image'; 3 | import { useRouter } from 'next/router'; 4 | import { motion } from 'framer-motion'; 5 | 6 | // Hooks 7 | import useUser from '../hooks/useUser'; 8 | import useFollow from '../hooks/useFollow'; 9 | 10 | // Animations 11 | import { fadeInOut } from '../lib/animations'; 12 | 13 | // Interfaces 14 | interface Props { 15 | isSidebarOpen?: boolean; 16 | } 17 | 18 | export default function UserProfile({ isSidebarOpen = false }: Props) { 19 | const { pathname } = useRouter(); 20 | const { currentUser, isUserLoading } = useUser(); 21 | const { followList } = useFollow(); 22 | 23 | const followersCount = 24 | typeof followList('followers') !== undefined && 25 | followList('followers')?.length; 26 | const followingCount = 27 | typeof followList('following') !== undefined && 28 | followList('following')?.length; 29 | 30 | return isUserLoading ? ( 31 |
    32 | 49 | {isSidebarOpen && Loading...} 50 |
    51 | ) : ( 52 | 53 |
    54 | {currentUser && ( 55 | 59 | {currentUser?.displayName} 69 | 70 | )} 71 | 72 |
    77 | 78 | {currentUser?.email} 79 | 80 |
      81 |
    • 82 | 83 | {followersCount || '0'} 84 | 85 | 86 | {followersCount === 1 ? 'Follower' : 'Followers'} 87 | 88 |
    • 89 | 90 |
    • 91 | 92 | {followingCount || '0'} 93 | 94 | Following 95 |
    • 96 |
    97 |
    98 |
    99 | 100 | 106 | {currentUser?.displayName} 107 | 113 | 118 | 119 | 120 |
    121 | ); 122 | } 123 | -------------------------------------------------------------------------------- /components/Navbar.tsx: -------------------------------------------------------------------------------- 1 | // import Link from 'next/link'; 2 | import { useRouter } from 'next/router'; 3 | import { useEffect, useState } from 'react'; 4 | import { AnimatePresence, motion } from 'framer-motion'; 5 | 6 | // Components 7 | import Logo from './Logo'; 8 | import Drawer from './Drawer'; 9 | import Footer from './Footer'; 10 | import Divider from './Divider'; 11 | import MainNav from './MainNav'; 12 | import PagesNav from './PagesNav'; 13 | import UserProfile from './UserProfile'; 14 | import SignInButton from './SignInButton'; 15 | import SignOutButton from './SignOutButton'; 16 | 17 | // Hooks 18 | import useUser from '../hooks/useUser'; 19 | import useMediaQuery from '../hooks/useMediaQuery'; 20 | 21 | // Animations 22 | import { navVariants } from '../lib/animations'; 23 | 24 | // Interface 25 | interface Props { 26 | isSidebarOpen: boolean; 27 | setIsSidebarOpen: (isSidebarOpen: any) => void; 28 | } 29 | 30 | export default function Navbar({ isSidebarOpen, setIsSidebarOpen }: Props) { 31 | const { pathname } = useRouter(); 32 | const { currentUser, isUserLoading } = useUser(); 33 | const matches = useMediaQuery('(min-width: 768px)'); 34 | 35 | const [isDrawerOpen, setIsDrawerOpen] = useState(false); 36 | 37 | useEffect(() => setIsDrawerOpen(false), [pathname]); 38 | 39 | const handleDrawerClick = () => 40 | setIsDrawerOpen((drawerState) => !drawerState); 41 | 42 | return ( 43 | <> 44 | {isDrawerOpen && } 45 | 46 | 55 | 56 | 57 | {currentUser && ( 58 |
    63 | 64 |
    65 | )} 66 | 67 | 68 | 69 |
    70 | 71 |
    72 | 73 | 74 | 75 | 114 | 115 | 145 | 146 |
    147 | {currentUser ? ( 148 | 149 | ) : ( 150 | 154 | )} 155 |
    156 | 157 |
    158 |
    159 |
    160 |
    161 | 162 | ); 163 | } 164 | -------------------------------------------------------------------------------- /components/CollectionItem.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import Link from 'next/link'; 3 | import Image from 'next/image'; 4 | 5 | // Hooks 6 | import { CollectionInfo } from '../hooks/useCollections'; 7 | 8 | // Assets 9 | import placeholderImg from '../public/assets/placeholder.avif'; 10 | 11 | interface Props { 12 | isOwner: boolean; 13 | collection: CollectionInfo; 14 | removeCollection: (id: string) => void; 15 | } 16 | 17 | export default function CollectionItem({ 18 | isOwner = true, 19 | collection, 20 | removeCollection, 21 | }: Props) { 22 | const [readMore, setReadMore] = useState(false); 23 | 24 | return ( 25 | <> 26 |
    27 |
    28 |
    29 | Name: 30 | 31 | {collection.name} 32 | 33 |
    34 |
    35 | By: 36 | 37 | {collection.createdBy} 38 | 39 |
    40 | {collection.description && ( 41 | <> 42 |
    47 | Description: 48 |

    49 | {collection.description} 50 |

    51 |
    52 | {collection.description.length > 40 && ( 53 | 61 | )} 62 | 63 | )} 64 |
    65 | 66 |
    67 | {collection.isPublic ? ( 68 |
    69 | 75 | 76 | 81 | 82 |
    83 | ) : ( 84 |
    85 | 91 | 92 | 93 | 94 | 95 |
    96 | )} 97 | 98 | {isOwner && ( 99 | 117 | )} 118 |
    119 |
    120 | 121 | {collection.games ? 'Games' : 'No games yet!'} 122 |
      123 | {collection?.games?.map((game) => ( 124 |
    • 125 | 126 | {game.name} 134 | 135 |
    • 136 | ))} 137 |
    138 | 139 | ); 140 | } 141 | -------------------------------------------------------------------------------- /components/GameCard.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import Image from 'next/image'; 3 | import { useEffect, useState } from 'react'; 4 | import { useRouter } from 'next/router'; 5 | 6 | // Hooks 7 | import useUser from '../hooks/useUser'; 8 | import useUserBookmarks from '../hooks/useUserBookmarks'; 9 | import useBookmarkMutation from '../hooks/useBookmarkMutation'; 10 | 11 | // Types 12 | import { GameInterface } from '../lib/types/index'; 13 | 14 | // Assets 15 | import CollectionsDropdown from './CollectionsDropdown'; 16 | import placeholderImage from '../public/assets/placeholder.avif'; 17 | 18 | interface Props { 19 | details: GameInterface; 20 | isFromBookmark?: boolean; 21 | isTrending?: boolean; 22 | isFromUser?: boolean; 23 | } 24 | 25 | export default function GameCard({ 26 | details, 27 | isFromBookmark = false, 28 | isTrending = false, 29 | isFromUser = false, 30 | }: Props) { 31 | const router = useRouter(); 32 | const { currentUser } = useUser(); 33 | const { bookmarksData } = useUserBookmarks(); 34 | const { handleAddBookmark, removeBookmark } = useBookmarkMutation(); 35 | 36 | const [isDropdownOpen, setIsDropdownOpen] = useState(false); 37 | const [wasBookmarked, setWasBookmarked] = useState(false); 38 | 39 | const { 40 | id, 41 | name, 42 | slug, 43 | background_image: backgroundImage, 44 | released, 45 | genres, 46 | } = details; 47 | 48 | const handleClick = () => { 49 | if (!currentUser) return router.push('/bookmarks'); 50 | 51 | setWasBookmarked(true); 52 | 53 | return isFromBookmark 54 | ? removeBookmark(id!) 55 | : handleAddBookmark(bookmarksData, details); 56 | }; 57 | 58 | useEffect(() => { 59 | const timer = setTimeout(() => setWasBookmarked(false), 1500); 60 | 61 | return () => clearTimeout(timer); 62 | }, [wasBookmarked]); 63 | 64 | return ( 65 |
    70 | {name} 78 | 79 |
    80 |
    81 | 85 | {name} 86 | 87 | 88 | {released && ( 89 | {released.slice(0, 4)} 90 | )} 91 |
    92 | 93 | {genres && ( 94 |
      95 | {genres?.slice(0, 3).map((genre) => ( 96 |
    • 100 | {genre.name} 101 |
    • 102 | ))} 103 |
    104 | )} 105 |
    106 | 107 | {!isFromUser && ( 108 | <> 109 | 152 | 153 | 177 | 178 | )} 179 | 180 | 181 |
    182 | ); 183 | } 184 | -------------------------------------------------------------------------------- /pages/collections/index.tsx: -------------------------------------------------------------------------------- 1 | import { FormEvent, useId, useState } from 'react'; 2 | import Head from 'next/head'; 3 | 4 | // Components 5 | import Divider from '../../components/Divider'; 6 | import ErrorCard from '../../components/ErrorCard'; 7 | import LoadingCard from '../../components/LoadingCard'; 8 | import PageHeading from '../../components/PageHeading'; 9 | import useCollections from '../../hooks/useCollections'; 10 | import SignInButton from '../../components/SignInButton'; 11 | import CollectionItem from '../../components/CollectionItem'; 12 | 13 | // Hooks 14 | import useUser from '../../hooks/useUser'; 15 | 16 | export default function AddNewCollection() { 17 | const initialState = { 18 | name: '', 19 | description: '', 20 | isPublic: true, 21 | createdBy: '', 22 | }; 23 | const isPublicId = useId(); 24 | const { currentUser, isUserLoading } = useUser(); 25 | const { addNewCollection, removeCollection, collections, status } = 26 | useCollections(); 27 | 28 | const [isError, setIsError] = useState(false); 29 | const [collectionInfo, setCollectionInfo] = useState(initialState); 30 | 31 | const handleSubmit = (e: FormEvent) => { 32 | e.preventDefault(); 33 | 34 | if (!collectionInfo.name || collectionInfo.name === '') { 35 | setIsError(true); 36 | } 37 | 38 | if (collectionInfo.name) { 39 | addNewCollection({ ...collectionInfo }); 40 | setCollectionInfo(initialState); 41 | setIsError(false); 42 | } 43 | }; 44 | 45 | if (status === 'loading') return ; 46 | if (status === 'error') return ; 47 | 48 | return ( 49 | <> 50 | 51 | GZ | Collections 52 | 56 | 57 | 58 |
    59 | 60 | 61 | 62 | {currentUser && ( 63 | <> 64 |

    Create New Collection

    65 |
    70 | 75 | setCollectionInfo((prevState) => ({ 76 | ...prevState, 77 | name: e.target.value, 78 | })) 79 | } 80 | placeholder="Collection name, i.g. Fav Games" 81 | className="w-full rounded-lg border border-transparent bg-primary-dark p-4 text-white placeholder:opacity-50 focus:border-white focus-visible:outline-none" 82 | /> 83 | {isError && ( 84 | 85 | Please name your collection! 86 | 87 | )} 88 |