├── src ├── index.scss ├── layouts │ ├── index.js │ ├── PublicLayout.js │ └── PublicRoute.js ├── hooks │ ├── index.js │ ├── useQuery.js │ └── useLazyLoadingImage.js ├── utils │ ├── helpers │ │ ├── history.js │ │ ├── preventClick.js │ │ ├── index.js │ │ ├── elementInViewport.js │ │ ├── formatImageUrl.js │ │ ├── setMetacriticColor.js │ │ └── platformIcon.js │ ├── apiCaller.js │ ├── RouterUtils.js │ └── GamesUtils.js ├── scss │ ├── _utilities.scss │ ├── components │ │ ├── _game-rendered.scss │ │ ├── _background.scss │ │ ├── _not-found.scss │ │ ├── _settings.scss │ │ ├── _full-video.scss │ │ ├── _icon.scss │ │ ├── _game-item.scss │ │ ├── _video.scss │ │ ├── _header.scss │ │ ├── _form.scss │ │ ├── _search-bar.scss │ │ ├── _loading.scss │ │ ├── _user.scss │ │ ├── _header-genres.scss │ │ └── _game.scss │ ├── _modified.scss │ ├── _variables.scss │ ├── _mixins.scss │ ├── main.scss │ └── _base.scss ├── constants │ ├── KeyConstants.js │ ├── urlApi.js │ ├── ActionTypes.js │ └── GlobalConstants.js ├── setupTests.js ├── App.test.js ├── selectors │ ├── GamesSelectors.js │ ├── UserSelectors.js │ ├── GameSelectors.js │ └── CommonSelectors.js ├── containers │ ├── LoginContainer.js │ ├── SignupContainer.js │ ├── NotFound.js │ ├── UserSettingsContainer.js │ ├── HeaderContainer.js │ ├── GameContainer.js │ ├── UserContainer.js │ └── GamesContainer.js ├── store │ └── index.js ├── index.js ├── actions │ ├── AppActions.js │ ├── RouterActions.js │ ├── GameActions.js │ ├── GamesActions.js │ └── UserActions.js ├── components │ ├── index.js │ ├── HOCs │ │ ├── withLogged.js │ │ ├── withAuthenticated.js │ │ └── withInfiniteScroll.js │ ├── CustomTextField.js │ ├── Loading.js │ ├── FullVideo.js │ ├── Video.js │ ├── Background.js │ ├── CustomLink.js │ ├── UserSettings.js │ ├── CustomImageInput.js │ ├── HeaderGenres.js │ ├── UserLikesTab.js │ ├── Games.js │ ├── GamesRendered.js │ ├── Login.js │ ├── Header.js │ ├── Signup.js │ ├── SettingsProfileTab.js │ ├── SettingsPasswordTab.js │ ├── GameItem.js │ ├── User.js │ └── Game.js ├── reducers │ ├── index.js │ ├── RouterReducer.js │ ├── ProfileReducer.js │ ├── AppReducer.js │ ├── GamesReducer.js │ └── UserReducer.js ├── firebase.js ├── routes.js ├── App.js └── serviceWorker.js ├── .vscode └── settings.json ├── public ├── favicon.ico ├── logo192.png ├── logo512.png ├── robots.txt ├── manifest.json └── index.html ├── .editorconfig ├── README.md ├── .prettierrc ├── .gitignore └── package.json /src/index.scss: -------------------------------------------------------------------------------- 1 | @import './scss/main'; 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true 3 | } 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nttanh6299/rawg-client/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nttanh6299/rawg-client/HEAD/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nttanh6299/rawg-client/HEAD/public/logo512.png -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/layouts/index.js: -------------------------------------------------------------------------------- 1 | export { PublicLayout } from './PublicLayout'; 2 | export { PublicRoute } from './PublicRoute'; 3 | -------------------------------------------------------------------------------- /src/hooks/index.js: -------------------------------------------------------------------------------- 1 | export { useQuery } from './useQuery'; 2 | export { useLazyLoadingImage } from './useLazyLoadingImage'; 3 | -------------------------------------------------------------------------------- /src/utils/helpers/history.js: -------------------------------------------------------------------------------- 1 | import { createHashHistory } from 'history'; 2 | 3 | export const history = createHashHistory(); 4 | -------------------------------------------------------------------------------- /src/utils/helpers/preventClick.js: -------------------------------------------------------------------------------- 1 | export function preventClick(e) { 2 | e.preventDefault(); 3 | e.stopPropagation(); 4 | } 5 | -------------------------------------------------------------------------------- /src/scss/_utilities.scss: -------------------------------------------------------------------------------- 1 | .u-text-center { 2 | text-align: center; 3 | } 4 | 5 | .u-uppercase { 6 | text-transform: uppercase; 7 | } 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | -------------------------------------------------------------------------------- /src/hooks/useQuery.js: -------------------------------------------------------------------------------- 1 | import { useLocation } from 'react-router-dom'; 2 | 3 | export const useQuery = () => { 4 | return new URLSearchParams(useLocation().search); 5 | }; 6 | -------------------------------------------------------------------------------- /src/scss/components/_game-rendered.scss: -------------------------------------------------------------------------------- 1 | .games-rendered { 2 | overflow: hidden; 3 | display: grid; 4 | grid-template-columns: repeat(4, minmax(30rem, 50rem)); 5 | column-gap: 2rem; 6 | justify-content: center; 7 | } 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React + Redux + Firebase + RAWG API (https://api.rawg.io/docs/). 2 | #### Live demo [here](https://nttanh6299.github.io/rawg-client/#/) 3 | #### Usage 4 | 1. `npm i` 5 | 2. `npm run start` or `npm run build` for production build 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "tabWidth": 2, 5 | "useTabs": false, 6 | "bracketSpacing": true, 7 | "jsxBracketSameLine": false, 8 | "arrowParens": "avoid", 9 | "trailingComma": "none" 10 | } 11 | -------------------------------------------------------------------------------- /src/scss/_modified.scss: -------------------------------------------------------------------------------- 1 | .-metacritic-red { 2 | color: rgb(236, 22, 22); 3 | } 4 | 5 | .-metacritic-yellow { 6 | color: #fddb3a; 7 | } 8 | 9 | .-metacritic-blue { 10 | color: #00909e; 11 | } 12 | 13 | .-metacritic-green { 14 | color: #6dc849; 15 | } 16 | -------------------------------------------------------------------------------- /src/scss/components/_background.scss: -------------------------------------------------------------------------------- 1 | .background { 2 | height: 200px; 3 | background-size: cover; 4 | background-repeat: no-repeat; 5 | background-position: top center; 6 | position: relative; 7 | z-index: 0; 8 | transition: opacity 0.2s, transform 0.2s; 9 | } 10 | -------------------------------------------------------------------------------- /src/constants/KeyConstants.js: -------------------------------------------------------------------------------- 1 | export const GENRE_COLLECTION_TYPE = 'GENRE_COLLECTION_TYPE'; 2 | export const SEARCH_COLLECTION_TYPE = 'GENRE_COLLECTION_TYPE'; 3 | export const TAG_COLLECTION_TYPE = 'TAG_COLLECTION_TYPE'; 4 | 5 | export const GAME_COLLECTION_TYPE = 'GAME_COLLECTION_TYPE'; 6 | -------------------------------------------------------------------------------- /src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect'; 6 | -------------------------------------------------------------------------------- /src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import App from './App'; 4 | 5 | test('renders learn react link', () => { 6 | const { getByText } = render(); 7 | const linkElement = getByText(/learn react/i); 8 | expect(linkElement).toBeInTheDocument(); 9 | }); 10 | -------------------------------------------------------------------------------- /src/utils/helpers/index.js: -------------------------------------------------------------------------------- 1 | export { history } from './history'; 2 | export { setMetacriticColor } from './setMetacriticColor'; 3 | export { elementInViewport } from './elementInViewport'; 4 | export { preventClick } from './preventClick'; 5 | export { formatImageUrl } from './formatImageUrl'; 6 | export { platformIcon } from './platformIcon'; 7 | -------------------------------------------------------------------------------- /src/scss/components/_not-found.scss: -------------------------------------------------------------------------------- 1 | .not-found { 2 | display: flex; 3 | flex-direction: column; 4 | justify-content: center; 5 | align-items: center; 6 | font-size: 3rem; 7 | height: calc(100vh - #{$header-height}* 3); 8 | 9 | &__title { 10 | margin-top: -1rem; 11 | text-transform: uppercase; 12 | font-weight: 700; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/helpers/elementInViewport.js: -------------------------------------------------------------------------------- 1 | export function elementInViewport(el) { 2 | const rect = el.getBoundingClientRect(); 3 | const windowHeight = 4 | window.innerHeight || document.documentElement.clientHeight; 5 | const offsetTop = 150; 6 | 7 | return ( 8 | rect.top >= 0 && rect.left >= 0 && rect.top <= windowHeight + offsetTop 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /src/utils/helpers/formatImageUrl.js: -------------------------------------------------------------------------------- 1 | import { IMAGE_URL, LIGHTWEIGHT_IMAGE_URL } from '../../constants/urlApi'; 2 | 3 | export function formatImageUrl(url, clipExists = true) { 4 | const urlReplaced = clipExists 5 | ? LIGHTWEIGHT_IMAGE_URL.VIDEO 6 | : LIGHTWEIGHT_IMAGE_URL.NO_VIDEO; 7 | 8 | return !!url ? url.replace(IMAGE_URL, urlReplaced) : ''; 9 | } 10 | -------------------------------------------------------------------------------- /src/selectors/GamesSelectors.js: -------------------------------------------------------------------------------- 1 | import { createSelector } from 'reselect'; 2 | import { getGames, getGenre, getSearch, getTag } from './CommonSelectors'; 3 | import { gameCollectionData } from '../utils/GamesUtils'; 4 | 5 | export const getGameCollectionData = createSelector( 6 | getGames, 7 | getGenre, 8 | getSearch, 9 | getTag, 10 | gameCollectionData 11 | ); 12 | -------------------------------------------------------------------------------- /src/scss/_variables.scss: -------------------------------------------------------------------------------- 1 | :root { 2 | --color-dark: #151515; 3 | 4 | --color-grey-dark-1: #333; 5 | --color-grey-dark-2: #777; 6 | --color-grey-dark-3: #999; 7 | 8 | --color-pink: #d17eef; 9 | } 10 | 11 | $desktop: desktop; 12 | $laptop: laptop; 13 | $tablet: tablet; 14 | $phablet: phablet; 15 | $mobile: mobile; 16 | 17 | $header-height: 10rem; 18 | $screen-max-width: 166rem; 19 | -------------------------------------------------------------------------------- /src/utils/helpers/setMetacriticColor.js: -------------------------------------------------------------------------------- 1 | export function setMetacriticColor(num) { 2 | if (num < 0) return ''; 3 | 4 | let color = '-metacritic-'; 5 | if (num >= 75) { 6 | color += 'green'; 7 | } else if (num >= 50) { 8 | color += 'yellow'; 9 | } else if (num >= 25) { 10 | color += 'blue'; 11 | } else { 12 | color += 'red'; 13 | } 14 | 15 | return color; 16 | } 17 | -------------------------------------------------------------------------------- /src/containers/LoginContainer.js: -------------------------------------------------------------------------------- 1 | import { Login } from '../components'; 2 | import { connect } from 'react-redux'; 3 | import { login } from '../actions/UserActions'; 4 | import { getUser } from '../selectors/CommonSelectors'; 5 | 6 | const mapStateToProps = state => { 7 | return { 8 | ...getUser(state), 9 | login 10 | }; 11 | }; 12 | 13 | export default connect(mapStateToProps)(Login); 14 | -------------------------------------------------------------------------------- /src/containers/SignupContainer.js: -------------------------------------------------------------------------------- 1 | import { Signup } from '../components'; 2 | import { connect } from 'react-redux'; 3 | import { signUp } from '../actions/UserActions'; 4 | import { getUser } from '../selectors/CommonSelectors'; 5 | 6 | const mapStateToProps = state => { 7 | return { 8 | ...getUser(state) 9 | }; 10 | }; 11 | 12 | export default connect(mapStateToProps, { signUp })(Signup); 13 | -------------------------------------------------------------------------------- /src/selectors/UserSelectors.js: -------------------------------------------------------------------------------- 1 | import { createSelector } from 'reselect'; 2 | import { getUser, getUsername } from './CommonSelectors'; 3 | 4 | export const isCurrentUser = createSelector( 5 | getUser, 6 | getUsername, 7 | (user, username) => { 8 | if (!user.currentUser) { 9 | return false; 10 | } 11 | 12 | return user.currentUser.displayName === username; 13 | } 14 | ); 15 | -------------------------------------------------------------------------------- /src/layouts/PublicLayout.js: -------------------------------------------------------------------------------- 1 | import React, { Suspense } from 'react'; 2 | import { Loading } from '../components'; 3 | 4 | const PublicLayout = ({ children }) => { 5 | return ( 6 |
7 | }> 8 | {children} 9 | 10 |
11 | ); 12 | }; 13 | 14 | export { PublicLayout }; 15 | -------------------------------------------------------------------------------- /src/store/index.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware } from 'redux'; 2 | import thunk from 'redux-thunk'; 3 | import { composeWithDevTools } from 'redux-devtools-extension'; 4 | import rootReducer from '../reducers'; 5 | 6 | const middlewares = [thunk]; 7 | 8 | const store = createStore( 9 | rootReducer, 10 | composeWithDevTools(applyMiddleware(...middlewares)) 11 | ); 12 | 13 | export default store; 14 | -------------------------------------------------------------------------------- /.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 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /src/layouts/PublicRoute.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Route } from 'react-router-dom'; 3 | 4 | const PublicRoute = ({ component: Component, layout: Layout, ...rest }) => { 5 | return ( 6 | ( 9 | 10 | 11 | 12 | )} 13 | /> 14 | ); 15 | }; 16 | 17 | export { PublicRoute }; 18 | -------------------------------------------------------------------------------- /src/hooks/useLazyLoadingImage.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | 3 | export const useLazyLoadingImage = sourceImage => { 4 | const [src, setSrc] = useState(null); 5 | 6 | useEffect(() => { 7 | const imageLoader = new Image(); 8 | imageLoader.src = sourceImage; 9 | 10 | imageLoader.onload = () => { 11 | setSrc(sourceImage); 12 | }; 13 | }, [sourceImage]); 14 | 15 | return src; 16 | }; 17 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import * as serviceWorker from './serviceWorker'; 4 | import App from './App'; 5 | import { Provider } from 'react-redux'; 6 | import store from './store'; 7 | 8 | import './index.scss'; 9 | 10 | ReactDOM.render( 11 | 12 | 13 | , 14 | document.getElementById('root') 15 | ); 16 | 17 | serviceWorker.unregister(); 18 | -------------------------------------------------------------------------------- /src/actions/AppActions.js: -------------------------------------------------------------------------------- 1 | import { 2 | PLAY_FULL_VIDEO, 3 | CLOSE_FULL_VIDEO, 4 | WINDOW_RESIZE 5 | } from '../constants/ActionTypes'; 6 | 7 | export const playFullVideo = videoId => ({ 8 | type: PLAY_FULL_VIDEO, 9 | payload: { videoId } 10 | }); 11 | 12 | export const closeFullVideo = () => ({ 13 | type: CLOSE_FULL_VIDEO 14 | }); 15 | 16 | export const windowResize = size => ({ 17 | type: WINDOW_RESIZE, 18 | payload: { size } 19 | }); 20 | -------------------------------------------------------------------------------- /src/components/index.js: -------------------------------------------------------------------------------- 1 | export { default as Header } from './Header'; 2 | export { default as Games } from './Games'; 3 | export { default as Game } from './Game'; 4 | export { default as Loading } from './Loading'; 5 | export { default as FullVideo } from './FullVideo'; 6 | export { default as Login } from './Login'; 7 | export { default as Signup } from './Signup'; 8 | export { default as User } from './User'; 9 | export { default as UserSettings } from './UserSettings'; 10 | -------------------------------------------------------------------------------- /src/containers/NotFound.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { GiGamepadCross } from 'react-icons/gi'; 3 | 4 | const NotFound = () => { 5 | return ( 6 |
7 |
8 | 4 9 | 10 | 4 11 |
12 | Page not found 13 |
14 | ); 15 | }; 16 | 17 | export default NotFound; 18 | -------------------------------------------------------------------------------- /src/containers/UserSettingsContainer.js: -------------------------------------------------------------------------------- 1 | import { UserSettings } from '../components'; 2 | import { connect } from 'react-redux'; 3 | import { getUser } from '../selectors/CommonSelectors'; 4 | import { updateUser, changePassword } from '../actions/UserActions'; 5 | 6 | const mapStateToProps = state => { 7 | return { 8 | ...getUser(state) 9 | }; 10 | }; 11 | 12 | export default connect(mapStateToProps, { updateUser, changePassword })( 13 | UserSettings 14 | ); 15 | -------------------------------------------------------------------------------- /src/utils/helpers/platformIcon.js: -------------------------------------------------------------------------------- 1 | import { 2 | FaWindows, 3 | FaPlaystation, 4 | FaXbox, 5 | FaLinux, 6 | FaApple, 7 | FaAndroid 8 | } from 'react-icons/fa'; 9 | 10 | const platforms = new Map([ 11 | ['pc', FaWindows], 12 | ['playstation', FaPlaystation], 13 | ['xbox', FaXbox], 14 | ['ios', FaApple], 15 | ['android', FaAndroid], 16 | ['linux', FaLinux] 17 | ]); 18 | 19 | export function platformIcon(slug) { 20 | return platforms.get(slug); 21 | } 22 | -------------------------------------------------------------------------------- /src/containers/HeaderContainer.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { Header } from '../components'; 3 | import { changeRoute } from '../actions/RouterActions'; 4 | import { logOut } from '../actions/UserActions'; 5 | import { getUser } from '../selectors/CommonSelectors'; 6 | 7 | const mapStateToProps = state => { 8 | return { 9 | ...getUser(state), 10 | logOut 11 | }; 12 | }; 13 | 14 | export default connect(mapStateToProps, { changeRoute })(Header); 15 | -------------------------------------------------------------------------------- /src/reducers/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import GamesReducer from './GamesReducer'; 3 | import RouterReducer from './RouterReducer'; 4 | import AppReducer from './AppReducer'; 5 | import UserReducer from './UserReducer'; 6 | import profileReducer from './ProfileReducer'; 7 | 8 | export default combineReducers({ 9 | games: GamesReducer, 10 | router: RouterReducer, 11 | app: AppReducer, 12 | user: UserReducer, 13 | profile: profileReducer 14 | }); 15 | -------------------------------------------------------------------------------- /src/reducers/RouterReducer.js: -------------------------------------------------------------------------------- 1 | import { CHANGE_ROUTE } from '../constants/ActionTypes'; 2 | 3 | const initialState = { 4 | route: { 5 | keys: {}, 6 | options: {}, 7 | path: '' 8 | } 9 | }; 10 | 11 | function router(state = initialState, { type, payload }) { 12 | switch (type) { 13 | case CHANGE_ROUTE: 14 | return { 15 | ...state, 16 | route: payload.route 17 | }; 18 | default: 19 | return state; 20 | } 21 | } 22 | 23 | export default router; 24 | -------------------------------------------------------------------------------- /src/scss/_mixins.scss: -------------------------------------------------------------------------------- 1 | //responsivedesign 2 | @mixin mq($point) { 3 | @if $point == desktop { 4 | @media (max-width: 1200px) { 5 | @content; 6 | } 7 | } @else if $point == laptop { 8 | @media (max-width: 992px) { 9 | @content; 10 | } 11 | } @else if $point == tablet { 12 | @media (max-width: 768px) { 13 | @content; 14 | } 15 | } @else if $point == phablet { 16 | @media (max-width: 480px) { 17 | @content; 18 | } 19 | } @else if $point == mobile { 20 | @media (max-width: 320px) { 21 | @content; 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /src/reducers/ProfileReducer.js: -------------------------------------------------------------------------------- 1 | import { 2 | FETCH_VISITED_USER_SUCCESS, 3 | FETCH_VISITED_USER_REQUEST 4 | } from '../constants/ActionTypes'; 5 | 6 | const initialState = { 7 | loadingUserProfile: true, 8 | visitedUserProfile: null 9 | }; 10 | 11 | function profileReducer(state = initialState, { type, payload }) { 12 | switch (type) { 13 | case FETCH_VISITED_USER_REQUEST: 14 | return { 15 | ...initialState 16 | }; 17 | case FETCH_VISITED_USER_SUCCESS: 18 | return { 19 | ...state, 20 | loadingUserProfile: false, 21 | visitedUserProfile: payload.user 22 | }; 23 | default: 24 | return state; 25 | } 26 | } 27 | 28 | export default profileReducer; 29 | -------------------------------------------------------------------------------- /src/components/HOCs/withLogged.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Redirect } from 'react-router-dom'; 3 | import Loading from '../Loading'; 4 | 5 | const withLogged = (InnerComponent, redirectTo = '/') => { 6 | const Logged = ({ currentUser, loadedAuth, ...props }) => { 7 | if (!loadedAuth) { 8 | return ; 9 | } else if (loadedAuth && currentUser) { 10 | return ; 11 | } 12 | return ( 13 | 18 | ); 19 | }; 20 | 21 | return Logged; 22 | }; 23 | 24 | export default withLogged; 25 | -------------------------------------------------------------------------------- /src/components/HOCs/withAuthenticated.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Redirect } from 'react-router-dom'; 3 | import Loading from '../Loading'; 4 | 5 | const withAuthenticated = (InnerComponent, redirectTo = '/login') => { 6 | const Authenticated = ({ currentUser, loadedAuth, ...props }) => { 7 | if (!loadedAuth) { 8 | return ; 9 | } else if (loadedAuth && !currentUser) { 10 | return ; 11 | } 12 | return ( 13 | 18 | ); 19 | }; 20 | 21 | return Authenticated; 22 | }; 23 | 24 | export default withAuthenticated; 25 | -------------------------------------------------------------------------------- /src/constants/urlApi.js: -------------------------------------------------------------------------------- 1 | export const API_URL = 'https://api.rawg.io/api'; 2 | export const IMAGE_URL = 'https://media.rawg.io/media/'; 3 | export const SMALL_IMAGE_URL = 'https://media.rawg.io/media/resize/200/-/'; 4 | export const MEDIUM_IMAGE_URL = 'https://media.rawg.io/media/resize/640/-/'; 5 | export const LARGE_IMAGE_URL = 'https://media.rawg.io/media/resize/1280/-/'; 6 | export const LIGHTWEIGHT_IMAGE_URL = { 7 | VIDEO: 'https://media.rawg.io/media/crop/600/400/', 8 | NO_VIDEO: 'https://media.rawg.io/media/resize/640/-/' 9 | }; 10 | 11 | export const INDEX_PATH = '/'; 12 | export const GAMES_PATH = '/games'; 13 | export const GAME_PATH = '/games/:slug'; 14 | export const USER_PATH = '/@:username'; 15 | 16 | export const PUBLIC_API_KEY = '88875ce36a0e44c7836738cb513a6b43'; 17 | -------------------------------------------------------------------------------- /src/actions/RouterActions.js: -------------------------------------------------------------------------------- 1 | import { CHANGE_ROUTE } from '../constants/ActionTypes'; 2 | import { parseRoute } from '../utils/RouterUtils'; 3 | 4 | export const changeRoute = route => ({ 5 | type: CHANGE_ROUTE, 6 | payload: { route } 7 | }); 8 | 9 | export const initRouter = paths => dispatch => { 10 | window.onpopstate = () => { 11 | const hash = window.location.hash ? window.location.hash.slice(1) : ''; 12 | const [pathname, search] = hash.split('?'); 13 | const route = parseRoute(paths, pathname, search); 14 | dispatch(changeRoute(route)); 15 | }; 16 | 17 | const hash = window.location.hash ? window.location.hash.slice(1) : ''; 18 | const [pathname, search] = hash.split('?'); 19 | const route = parseRoute(paths, pathname, search); 20 | dispatch(changeRoute(route)); 21 | }; 22 | -------------------------------------------------------------------------------- /src/scss/main.scss: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Open+Sans:wght@300;400;600&display=swap'); 2 | 3 | @import './mixins'; 4 | @import './variables'; 5 | @import './base'; 6 | 7 | @import './components/header'; 8 | @import './components/search-bar'; 9 | @import './components/header-genres'; 10 | @import './components/game-rendered'; 11 | @import './components/game-item'; 12 | @import './components/background'; 13 | @import './components/video'; 14 | @import './components/loading'; 15 | @import './components/icon'; 16 | @import './components/full-video'; 17 | @import './components/game'; 18 | @import './components/form'; 19 | @import './components/not-found'; 20 | @import './components/user'; 21 | @import './components/settings'; 22 | 23 | @import './utilities'; 24 | @import './modified'; 25 | -------------------------------------------------------------------------------- /src/reducers/AppReducer.js: -------------------------------------------------------------------------------- 1 | import { 2 | PLAY_FULL_VIDEO, 3 | CLOSE_FULL_VIDEO, 4 | WINDOW_RESIZE 5 | } from '../constants/ActionTypes'; 6 | import { WINDOW_SIZE } from '../constants/GlobalConstants'; 7 | 8 | const initialState = { 9 | videoId: '', 10 | windowSize: WINDOW_SIZE.all 11 | }; 12 | 13 | function app(state = initialState, { type, payload }) { 14 | switch (type) { 15 | case PLAY_FULL_VIDEO: 16 | return { 17 | ...state, 18 | videoId: payload.videoId 19 | }; 20 | case CLOSE_FULL_VIDEO: 21 | return { 22 | ...state, 23 | videoId: '' 24 | }; 25 | case WINDOW_RESIZE: 26 | return { 27 | ...state, 28 | windowSize: payload.size 29 | }; 30 | default: 31 | return state; 32 | } 33 | } 34 | 35 | export default app; 36 | -------------------------------------------------------------------------------- /src/selectors/GameSelectors.js: -------------------------------------------------------------------------------- 1 | import { createSelector } from 'reselect'; 2 | import { getSlug, getGames } from './CommonSelectors'; 3 | import { GAME_COLLECTION_TYPE } from '../constants/KeyConstants'; 4 | 5 | export const getCollectionKey = createSelector(getSlug, slug => 6 | [GAME_COLLECTION_TYPE, slug].join('|') 7 | ); 8 | 9 | export const getGame = createSelector( 10 | getGames, 11 | getCollectionKey, 12 | (games, key) => { 13 | const collection = games[key]; 14 | return collection && collection.games.length > 0 15 | ? collection.games[0] 16 | : null; 17 | } 18 | ); 19 | 20 | export const getScreenshots = createSelector( 21 | [getGames, getCollectionKey], 22 | (games, key) => { 23 | const collection = games[key]; 24 | return collection && collection.screenshots ? collection.screenshots : []; 25 | } 26 | ); 27 | -------------------------------------------------------------------------------- /src/firebase.js: -------------------------------------------------------------------------------- 1 | import app from 'firebase/app'; 2 | import 'firebase/auth'; 3 | import 'firebase/firebase-firestore'; 4 | import 'firebase/storage'; 5 | 6 | const firebaseConfig = { 7 | apiKey: 'AIzaSyAb531xDPpt8fMbqnrHRTn521bxx7rTezk', 8 | authDomain: 'rawg-client.firebaseapp.com', 9 | databaseURL: 'https://rawg-client.firebaseio.com', 10 | projectId: 'rawg-client', 11 | storageBucket: 'rawg-client.appspot.com', 12 | messagingSenderId: '350468735304', 13 | appId: '1:350468735304:web:d51ff41d2f0d0b5196f37d', 14 | measurementId: 'G-PGYDM791MF' 15 | }; 16 | 17 | class Firebase { 18 | constructor() { 19 | app.initializeApp(firebaseConfig); 20 | this.EmailAuthProvider = app.auth.EmailAuthProvider; 21 | this.auth = app.auth(); 22 | this.db = app.firestore(); 23 | this.storage = app.storage(); 24 | } 25 | } 26 | 27 | export default new Firebase(); 28 | -------------------------------------------------------------------------------- /src/scss/components/_settings.scss: -------------------------------------------------------------------------------- 1 | .settings { 2 | width: 100%; 3 | display: flex; 4 | flex-direction: column; 5 | justify-content: center; 6 | 7 | @include mq($tablet) { 8 | margin: 3rem 0; 9 | align-items: center; 10 | } 11 | 12 | &__tabs { 13 | display: flex; 14 | align-items: center; 15 | justify-content: center; 16 | margin: 2rem 0; 17 | } 18 | 19 | &__tab { 20 | padding: 0.6rem 0; 21 | font-size: 2rem; 22 | color: var(--color-grey-dark-3); 23 | border-bottom: 2px solid transparent; 24 | 25 | & + & { 26 | margin-left: 1.4rem; 27 | } 28 | 29 | &:hover { 30 | border-bottom: 2px solid #fff; 31 | cursor: pointer; 32 | } 33 | 34 | &--active { 35 | color: #fff; 36 | border-bottom: 2px solid #fff; 37 | } 38 | } 39 | 40 | &__content { 41 | width: 100%; 42 | margin-top: 2rem; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/selectors/CommonSelectors.js: -------------------------------------------------------------------------------- 1 | // GAMES 2 | export const getGames = state => state.games; 3 | 4 | // ROUTER 5 | export const getGenre = state => { 6 | const { query, tag, genre } = state.router.route.options; 7 | return query || tag ? '' : genre || 'action'; 8 | }; 9 | 10 | export const getSearch = state => state.router.route.options.query; 11 | 12 | export const getSlug = state => state.router.route.keys.slug || ''; 13 | 14 | export const getTag = state => { 15 | return state.router.route.options.tag; 16 | }; 17 | 18 | export const getUsername = state => state.router.route.keys.username; 19 | 20 | // APP 21 | export const getWindowSize = state => state.app.windowSize; 22 | 23 | // USER 24 | export const getUser = state => state.user; 25 | 26 | export const getLikes = state => state.user.likes; 27 | 28 | export const getIsAuthenticated = state => !!state.user.currentUser; 29 | 30 | // USER PROFILE 31 | export const getUserProfile = state => state.profile; 32 | -------------------------------------------------------------------------------- /src/components/CustomTextField.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useField } from 'formik'; 3 | 4 | const CustomTextField = ({ placeholder, className, style, ...props }) => { 5 | const [field, meta] = useField(props); 6 | const errorText = meta.error && meta.touched ? meta.error : ''; 7 | 8 | return ( 9 |
10 | 17 | {errorText && ( 18 |
27 | {errorText} 28 |
29 | )} 30 |
31 | ); 32 | }; 33 | 34 | export default CustomTextField; 35 | -------------------------------------------------------------------------------- /src/components/Loading.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | const propTypes = { 5 | loading: PropTypes.bool.isRequired, 6 | className: PropTypes.string, 7 | style: PropTypes.object 8 | }; 9 | 10 | const defaultProps = { 11 | className: '', 12 | style: {} 13 | }; 14 | 15 | const Loading = ({ loading, className, style }) => { 16 | if (!loading) { 17 | return null; 18 | } 19 | 20 | return ( 21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 | ); 38 | }; 39 | 40 | Loading.propTypes = propTypes; 41 | Loading.defaultProps = defaultProps; 42 | 43 | export default React.memo(Loading); 44 | -------------------------------------------------------------------------------- /src/containers/GameContainer.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { Game } from '../components'; 3 | import { fetchGameIfNeeded } from '../actions/GameActions'; 4 | import { playFullVideo } from '../actions/AppActions'; 5 | import { changeRoute } from '../actions/RouterActions'; 6 | import { toggleLike } from '../actions/UserActions'; 7 | import { 8 | getGame, 9 | getCollectionKey, 10 | getScreenshots 11 | } from '../selectors/GameSelectors'; 12 | import { 13 | getSlug, 14 | getIsAuthenticated, 15 | getLikes 16 | } from '../selectors/CommonSelectors'; 17 | 18 | const mapStateToProps = state => { 19 | return { 20 | game: getGame(state), 21 | collectionKey: getCollectionKey(state), 22 | slug: getSlug(state), 23 | screenshots: getScreenshots(state), 24 | isAuthenticated: getIsAuthenticated(state), 25 | likes: getLikes(state) 26 | }; 27 | }; 28 | 29 | export default connect(mapStateToProps, { 30 | fetchGameIfNeeded, 31 | playFullVideo, 32 | changeRoute, 33 | toggleLike 34 | })(Game); 35 | -------------------------------------------------------------------------------- /src/scss/components/_full-video.scss: -------------------------------------------------------------------------------- 1 | .full-video { 2 | font-size: 3rem; 3 | position: fixed; 4 | width: 100vw; 5 | height: 100vh; 6 | top: 0; 7 | left: 0; 8 | z-index: 1000; 9 | background-color: var(--color-dark); 10 | 11 | &__frame { 12 | position: absolute; 13 | top: 10vh; 14 | left: 10vw; 15 | width: 70vw; 16 | height: 80vh; 17 | 18 | @include mq($tablet) { 19 | top: 10vh; 20 | left: 0; 21 | width: 100vw; 22 | } 23 | } 24 | 25 | &__close { 26 | position: absolute; 27 | top: 10vh; 28 | right: 10vw; 29 | width: 5rem; 30 | height: 5rem; 31 | display: flex; 32 | justify-content: center; 33 | align-items: center; 34 | border-radius: 100rem; 35 | background-color: var(--color-grey-dark-1); 36 | cursor: pointer; 37 | 38 | &:hover { 39 | transition: 0.3s; 40 | background-color: #fff; 41 | color: var(--color-dark); 42 | } 43 | 44 | @include mq($tablet) { 45 | top: 2.5vh; 46 | right: 2.5vw; 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/containers/UserContainer.js: -------------------------------------------------------------------------------- 1 | import { User } from '../components'; 2 | import { connect } from 'react-redux'; 3 | import { fetchUser, fetchUserLikes } from '../actions/UserActions'; 4 | import { changeRoute } from '../actions/RouterActions'; 5 | import { 6 | getUserProfile, 7 | getUsername, 8 | getWindowSize, 9 | getIsAuthenticated, 10 | getLikes 11 | } from '../selectors/CommonSelectors'; 12 | import { isCurrentUser } from '../selectors/UserSelectors'; 13 | import { playFullVideo } from '../actions/AppActions'; 14 | import { toggleLike } from '../actions/UserActions'; 15 | 16 | const mapStateToProps = state => { 17 | return { 18 | ...getUserProfile(state), 19 | username: getUsername(state), 20 | isCurrentUser: isCurrentUser(state), 21 | windowSize: getWindowSize(state), 22 | likes: getLikes(state), 23 | isAuthenticated: getIsAuthenticated(state), 24 | fetchUserLikes 25 | }; 26 | }; 27 | 28 | export default connect(mapStateToProps, { 29 | fetchUser, 30 | changeRoute, 31 | playFullVideo, 32 | toggleLike 33 | })(User); 34 | -------------------------------------------------------------------------------- /src/scss/components/_icon.scss: -------------------------------------------------------------------------------- 1 | .icon { 2 | box-sizing: content-box; 3 | width: 1.5rem; 4 | height: 1.5rem; 5 | fill: currentColor; 6 | 7 | &--play { 8 | position: absolute; 9 | bottom: 1rem; 10 | left: 1rem; 11 | display: block; 12 | padding: 0.8rem; 13 | border-radius: 0.4rem; 14 | background-color: rgba(0, 0, 0, 0.7); 15 | } 16 | 17 | &--like { 18 | color: var(--color-grey-dark-2); 19 | width: 2.5rem; 20 | height: 2.5rem; 21 | cursor: pointer; 22 | 23 | &:hover { 24 | transition: 0.2s; 25 | color: var(--color-pink); 26 | } 27 | } 28 | 29 | &--liked { 30 | color: var(--color-pink); 31 | } 32 | 33 | &--logout { 34 | width: 2.5rem; 35 | height: 2.5rem; 36 | } 37 | 38 | &--loading { 39 | margin-right: 0.6rem; 40 | animation: icon-loading 1s infinite linear; 41 | 42 | @keyframes icon-loading { 43 | from { 44 | transform: rotate(0deg); 45 | } 46 | to { 47 | transform: rotate(360deg); 48 | } 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/constants/ActionTypes.js: -------------------------------------------------------------------------------- 1 | // GAMES 2 | export const FETCH_GAMES_REQUEST = 'FETCH_GAMES_REQUEST'; 3 | export const FETCH_GAMES_SUCCESS = 'FETCH_GAMES_SUCCESS'; 4 | 5 | // ROUTER 6 | export const CHANGE_ROUTE = 'CHANGE_ROUTE'; 7 | 8 | // GAME 9 | export const FETCH_GAME_SCREENSHOTS_SUCCESS = 'FETCH_GAME_SCREENSHOTS_SUCCESS'; 10 | 11 | // APP 12 | export const PLAY_FULL_VIDEO = 'PLAY_FULL_VIDEO'; 13 | export const CLOSE_FULL_VIDEO = 'CLOSE_FULL_VIDEO'; 14 | export const WINDOW_RESIZE = 'WINDOW_RESIZE'; 15 | 16 | // USER 17 | export const LOGIN_SUCCESS = 'LOGIN_SUCCESS'; 18 | export const CLEAR_USER = 'CLEAR_USER'; 19 | export const UPDATE_USER_USERNAME = 'UPDATE_USER_USERNAME'; 20 | export const FETCH_USER_LIKES_SUCCESS = 'FETCH_USER_LIKES_SUCCESS'; 21 | export const TOGGLE_LIKE = 'TOGGLE_LIKE'; 22 | 23 | // USER PROFILE 24 | export const FETCH_VISITED_USER_REQUEST = 'FETCH_VISITED_USER_REQUEST'; 25 | export const FETCH_VISITED_USER_SUCCESS = 'FETCH_VISITED_USER_SUCCESS'; 26 | 27 | // USER SETTINGS 28 | export const UPDATE_USER_PROFILE_SUCCESS = 'UPDATE_USER_PROFILE_SUCCESS'; 29 | -------------------------------------------------------------------------------- /src/components/FullVideo.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | const propTypes = { 5 | onClose: PropTypes.func.isRequired, 6 | videoId: PropTypes.string 7 | }; 8 | 9 | const defaultProps = {}; 10 | 11 | const FullVideo = ({ onClose, videoId }) => { 12 | if (!videoId) { 13 | return null; 14 | } 15 | 16 | return ( 17 |
18 | 19 | × 20 | 21 |
22 | 31 |
32 |
33 | ); 34 | }; 35 | 36 | FullVideo.propTypes = propTypes; 37 | FullVideo.defaultProps = defaultProps; 38 | 39 | export default React.memo(FullVideo); 40 | -------------------------------------------------------------------------------- /src/containers/GamesContainer.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { Games } from '../components'; 3 | import { 4 | getGenre, 5 | getWindowSize, 6 | getLikes, 7 | getIsAuthenticated 8 | } from '../selectors/CommonSelectors'; 9 | import { getGameCollectionData } from '../selectors/GamesSelectors'; 10 | import { GENRES } from '../constants/GlobalConstants'; 11 | import { changeRoute } from '../actions/RouterActions'; 12 | import { fetchGamesNext, fetchGamesIfNeeded } from '../actions/GamesActions'; 13 | import { playFullVideo } from '../actions/AppActions'; 14 | import { toggleLike } from '../actions/UserActions'; 15 | 16 | const mapStateToProps = state => { 17 | return { 18 | ...getGameCollectionData(state), 19 | genres: GENRES, 20 | genre: getGenre(state), 21 | windowSize: getWindowSize(state), 22 | likes: getLikes(state), 23 | isAuthenticated: getIsAuthenticated(state) 24 | }; 25 | }; 26 | 27 | export default connect(mapStateToProps, { 28 | fetchGamesNext, 29 | fetchGamesIfNeeded, 30 | changeRoute, 31 | playFullVideo, 32 | toggleLike 33 | })(Games); 34 | -------------------------------------------------------------------------------- /src/scss/components/_game-item.scss: -------------------------------------------------------------------------------- 1 | .game-item { 2 | margin-top: 2rem; 3 | border-radius: 1rem; 4 | overflow: hidden; 5 | position: relative; 6 | color: #fff; 7 | transition: box-shadow 0.2s; 8 | 9 | &:hover { 10 | box-shadow: 0 14px 28px rgba(0, 0, 0, 0.25), 0 10px 10px rgba(0, 0, 0, 0.22); 11 | } 12 | 13 | &__info { 14 | display: flex; 15 | flex-direction: column; 16 | padding: 1.5rem 1rem; 17 | background-color: var(--color-grey-dark-1); 18 | 19 | &__top, 20 | &__bottom { 21 | display: flex; 22 | align-items: center; 23 | justify-content: space-between; 24 | } 25 | 26 | &__top { 27 | margin-bottom: 0.6rem; 28 | } 29 | } 30 | 31 | &__name { 32 | color: #fff; 33 | font-weight: 400; 34 | cursor: pointer; 35 | margin-right: 1rem; 36 | width: 80%; 37 | } 38 | 39 | &__meta { 40 | font-size: 1.4rem; 41 | font-weight: 600; 42 | display: inline-block; 43 | padding: 0 0.5rem; 44 | color: currentColor; 45 | border: 1px solid currentColor; 46 | border-radius: 0.4rem; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/scss/components/_video.scss: -------------------------------------------------------------------------------- 1 | .video { 2 | font-size: 1.7rem; 3 | position: absolute; 4 | top: 0; 5 | left: 0; 6 | width: 100%; 7 | display: flex; 8 | justify-content: center; 9 | align-items: center; 10 | 11 | &__frame { 12 | height: 200px; 13 | width: 100%; 14 | object-fit: fill; 15 | } 16 | 17 | &__full-frame { 18 | position: absolute; 19 | right: 1rem; 20 | bottom: 1rem; 21 | background-color: rgba(0, 0, 0, 0.45); 22 | color: #fff; 23 | border: 2px solid transparent; 24 | outline: none; 25 | padding: 0.6rem 1rem; 26 | border-radius: 0.4rem; 27 | font-size: 1.5rem; 28 | cursor: pointer; 29 | transition: border 0.2s; 30 | display: flex; 31 | align-items: center; 32 | 33 | &:hover { 34 | border: 2px solid rgba(255, 255, 255, 0.7); 35 | } 36 | 37 | &:active { 38 | transform: translate(1px, 1px); 39 | } 40 | 41 | span { 42 | margin-left: 0.5rem; 43 | } 44 | } 45 | 46 | &__loading { 47 | position: absolute; 48 | top: 0; 49 | left: 0; 50 | width: 100%; 51 | height: 100%; 52 | background: transparent; 53 | display: flex; 54 | justify-content: center; 55 | align-items: center; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/scss/components/_header.scss: -------------------------------------------------------------------------------- 1 | .header { 2 | height: $header-height; 3 | display: flex; 4 | align-items: center; 5 | flex-wrap: wrap; 6 | 7 | @include mq($tablet) { 8 | margin: 2rem 0; 9 | } 10 | 11 | &__logo { 12 | flex: 0 0 10%; 13 | margin-right: 4rem; 14 | font-size: 2.4rem; 15 | letter-spacing: 0.4rem; 16 | cursor: pointer; 17 | 18 | & > * { 19 | color: #fff; 20 | } 21 | 22 | @include mq($tablet) { 23 | margin-right: auto; 24 | } 25 | } 26 | 27 | &__user { 28 | display: flex; 29 | align-items: center; 30 | justify-content: center; 31 | 32 | &__info { 33 | margin-right: 1rem; 34 | } 35 | 36 | &__avatar { 37 | background-size: cover; 38 | background-position: center center; 39 | width: 4rem; 40 | height: 4rem; 41 | background-color: #fff; 42 | border-radius: 10rem; 43 | color: #000; 44 | display: flex; 45 | justify-content: center; 46 | align-items: center; 47 | font-size: 1.6rem; 48 | } 49 | 50 | &__username { 51 | margin-left: 0.6rem; 52 | font-size: 1.6rem; 53 | font-weight: 400; 54 | 55 | @include mq($tablet) { 56 | display: none; 57 | } 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/utils/apiCaller.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { API_URL, PUBLIC_API_KEY } from '../constants/urlApi'; 3 | const camelize = require('camelize'); 4 | 5 | const instanceNext = axios.create({ 6 | baseURL: API_URL, 7 | timeout: 10000 8 | }); 9 | 10 | instanceNext.interceptors.request.use( 11 | config => { 12 | let newUrl = `${config.url}&key=${PUBLIC_API_KEY}`; 13 | if (!config.url.includes('?')) { 14 | newUrl = newUrl.replace('&', '?'); 15 | } 16 | config.url = newUrl; 17 | return Promise.resolve(config); 18 | }, 19 | error => Promise.reject(error) 20 | ); 21 | 22 | instanceNext.interceptors.response.use( 23 | response => camelize(response.data), 24 | error => { 25 | if (error.response) { 26 | return Promise.reject(error.response); 27 | } 28 | if (error.request) { 29 | return Promise.reject(error.request); 30 | } 31 | return Promise.reject(error.message); 32 | } 33 | ); 34 | 35 | export async function fetchApi( 36 | endpoint, 37 | method = 'GET', 38 | body, 39 | params, 40 | sourceToken 41 | ) { 42 | return instanceNext({ 43 | method: method, 44 | url: endpoint, 45 | data: body, 46 | params: params, 47 | cancelToken: sourceToken 48 | }); 49 | } 50 | 51 | export async function fetchAllApi(requests = []) { 52 | return axios.all(requests); 53 | } 54 | -------------------------------------------------------------------------------- /src/reducers/GamesReducer.js: -------------------------------------------------------------------------------- 1 | import { 2 | FETCH_GAMES_REQUEST, 3 | FETCH_GAMES_SUCCESS, 4 | FETCH_GAME_SCREENSHOTS_SUCCESS 5 | } from '../constants/ActionTypes'; 6 | 7 | const initialState = { 8 | loading: false, 9 | nextUrl: null, 10 | games: [] 11 | }; 12 | 13 | function collection(state = initialState, { type, payload }) { 14 | switch (type) { 15 | case FETCH_GAMES_REQUEST: 16 | return { 17 | ...state, 18 | loading: true 19 | }; 20 | case FETCH_GAMES_SUCCESS: 21 | return { 22 | ...state, 23 | loading: false, 24 | nextUrl: payload.nextUrl, 25 | games: [...state.games, ...payload.fetchedData] 26 | }; 27 | case FETCH_GAME_SCREENSHOTS_SUCCESS: 28 | return { 29 | ...state, 30 | screenshots: [...payload.screenshots] 31 | }; 32 | default: 33 | return state; 34 | } 35 | } 36 | 37 | function games(state = {}, { type, payload }) { 38 | switch (type) { 39 | case FETCH_GAMES_REQUEST: 40 | case FETCH_GAMES_SUCCESS: 41 | case FETCH_GAME_SCREENSHOTS_SUCCESS: 42 | return { 43 | ...state, 44 | [payload.collectionKey]: collection(state[payload.collectionKey], { 45 | type, 46 | payload 47 | }) 48 | }; 49 | default: 50 | return state; 51 | } 52 | } 53 | 54 | export default games; 55 | -------------------------------------------------------------------------------- /src/reducers/UserReducer.js: -------------------------------------------------------------------------------- 1 | import { 2 | LOGIN_SUCCESS, 3 | CLEAR_USER, 4 | UPDATE_USER_USERNAME, 5 | FETCH_USER_LIKES_SUCCESS, 6 | TOGGLE_LIKE, 7 | UPDATE_USER_PROFILE_SUCCESS 8 | } from '../constants/ActionTypes'; 9 | 10 | const initialState = { 11 | currentUser: null, 12 | loadedAuth: false, 13 | likes: {} 14 | }; 15 | 16 | export default function user(state = initialState, { type, payload }) { 17 | switch (type) { 18 | case LOGIN_SUCCESS: 19 | return { 20 | ...state, 21 | currentUser: payload.user, 22 | loadedAuth: true 23 | }; 24 | case CLEAR_USER: 25 | return { 26 | ...initialState, 27 | loadedAuth: true 28 | }; 29 | case UPDATE_USER_USERNAME: 30 | return { 31 | ...state, 32 | currentUser: { ...state.currentUser, displayName: payload.username } 33 | }; 34 | case FETCH_USER_LIKES_SUCCESS: 35 | return { 36 | ...state, 37 | likes: { ...payload.likes } 38 | }; 39 | case TOGGLE_LIKE: 40 | return { 41 | ...state, 42 | likes: { ...state.likes, [payload.id]: payload.liked } 43 | }; 44 | case UPDATE_USER_PROFILE_SUCCESS: 45 | return { 46 | ...state, 47 | currentUser: { ...state.currentUser, ...payload.props } 48 | }; 49 | default: 50 | return state; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/components/Video.js: -------------------------------------------------------------------------------- 1 | import React, { useRef, useState, useEffect } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import Loading from './Loading'; 4 | import { FaPlay } from 'react-icons/fa'; 5 | 6 | const propTypes = { 7 | src: PropTypes.string, 8 | playFullVideo: PropTypes.func.isRequired 9 | }; 10 | 11 | const defaultProps = { 12 | src: '' 13 | }; 14 | 15 | const Video = ({ src, videoId, playFullVideo }) => { 16 | const [loading, setLoading] = useState(true); 17 | const videoRef = useRef(null); 18 | 19 | useEffect(() => { 20 | videoRef.current.volume = 0; 21 | }, []); 22 | 23 | const handleLoadedData = () => { 24 | setLoading(false); 25 | videoRef.current.play(); 26 | }; 27 | 28 | return ( 29 |
30 | 31 |
46 | ); 47 | }; 48 | 49 | Video.propType = propTypes; 50 | Video.defaultProps = defaultProps; 51 | 52 | export default React.memo(Video); 53 | -------------------------------------------------------------------------------- /src/components/Background.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef, useEffect } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { elementInViewport, formatImageUrl } from '../utils/helpers'; 4 | 5 | const propTypes = { 6 | backgroundImage: PropTypes.string, 7 | hasVideo: PropTypes.bool.isRequired, 8 | className: PropTypes.string, 9 | children: PropTypes.node 10 | }; 11 | 12 | const defaultProps = { 13 | className: '', 14 | backgroundImage: '' 15 | }; 16 | 17 | const Background = ({ backgroundImage, hasVideo, className, children }) => { 18 | const [loaded, setLoaded] = useState(false); 19 | const bgRef = useRef(null); 20 | 21 | useEffect(() => { 22 | const handleScroll = () => { 23 | if (!loaded && bgRef.current && elementInViewport(bgRef.current)) { 24 | const bgUrl = formatImageUrl(backgroundImage, hasVideo); 25 | bgRef.current.style.backgroundImage = `url(${bgUrl})`; 26 | setLoaded(true); 27 | } 28 | }; 29 | 30 | handleScroll(); 31 | window.addEventListener('scroll', handleScroll); 32 | 33 | return () => { 34 | window.removeEventListener('scroll', handleScroll); 35 | }; 36 | }, [loaded, backgroundImage, hasVideo]); 37 | 38 | return ( 39 |
40 | {children} 41 |
42 | ); 43 | }; 44 | 45 | Background.propTypes = propTypes; 46 | Background.defaultProps = defaultProps; 47 | 48 | export default React.memo(Background); 49 | -------------------------------------------------------------------------------- /src/components/CustomLink.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Link } from 'react-router-dom'; 4 | import { preventClick } from '../utils/helpers'; 5 | import { compileRoute } from '../utils/RouterUtils'; 6 | 7 | const propTypes = { 8 | children: PropTypes.node.isRequired, 9 | className: PropTypes.string, 10 | active: PropTypes.bool, 11 | path: PropTypes.string, 12 | options: PropTypes.object, 13 | keys: PropTypes.object, 14 | changeRoute: PropTypes.func.isRequired, 15 | onClick: PropTypes.func 16 | }; 17 | 18 | const defaultProps = { 19 | active: false, 20 | className: '', 21 | onClick: () => {}, 22 | path: '', 23 | keys: {}, 24 | options: {} 25 | }; 26 | 27 | const CustomLink = ({ 28 | children, 29 | className, 30 | active, 31 | path, 32 | options, 33 | keys, 34 | changeRoute, 35 | onClick, 36 | style, 37 | ...props 38 | }) => { 39 | const route = { path, keys, options }; 40 | 41 | const handleClick = () => { 42 | onClick(); 43 | changeRoute(route); 44 | }; 45 | 46 | return ( 47 | 54 | {children} 55 | 56 | ); 57 | }; 58 | 59 | CustomLink.propTypes = propTypes; 60 | CustomLink.defaultProps = defaultProps; 61 | 62 | export default React.memo(CustomLink); 63 | -------------------------------------------------------------------------------- /src/scss/components/_form.scss: -------------------------------------------------------------------------------- 1 | .form { 2 | width: 100%; 3 | max-width: 40rem; 4 | margin: 0 auto; 5 | font-size: 1.6rem; 6 | display: flex; 7 | flex-direction: column; 8 | 9 | &__form { 10 | margin: 1rem 0; 11 | display: flex; 12 | flex-direction: column; 13 | } 14 | 15 | &__input { 16 | width: 100%; 17 | font-size: inherit; 18 | border: none; 19 | outline: none; 20 | background-color: var(--color-grey-dark-1); 21 | padding: 1rem; 22 | color: #fff; 23 | margin-bottom: 1.2rem; 24 | border-radius: 0.4rem; 25 | } 26 | 27 | &__img { 28 | display: block; 29 | width: 5rem; 30 | height: 5rem; 31 | border-radius: 50%; 32 | display: flex; 33 | justify-content: center; 34 | align-items: center; 35 | background-color: var(--color-grey-dark-1); 36 | margin-right: 1rem; 37 | } 38 | 39 | &__button { 40 | background-color: transparent; 41 | color: var(--color-grey-dark-3); 42 | border: none; 43 | outline: none; 44 | font-size: 1.6rem; 45 | border-bottom: 2px solid transparent; 46 | padding: 0.6rem 0; 47 | 48 | & + & { 49 | margin-left: 1rem; 50 | } 51 | 52 | &:hover { 53 | cursor: pointer; 54 | border-bottom: 2px solid var(--color-grey-dark-3); 55 | } 56 | } 57 | 58 | &__submit { 59 | color: var(--color-dark); 60 | background-color: #fff; 61 | border-radius: 0.4rem; 62 | text-transform: uppercase; 63 | padding: 1rem; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/scss/components/_search-bar.scss: -------------------------------------------------------------------------------- 1 | .search-bar { 2 | flex: 0 0 40%; 3 | margin-right: auto; 4 | display: flex; 5 | align-items: center; 6 | border-radius: 10rem; 7 | font-size: 1.5rem; 8 | background-color: hsla(0, 0%, 100%, 0.14); 9 | transition: background-color 0.2s linear; 10 | 11 | &:hover { 12 | background: #fff; 13 | } 14 | 15 | &:hover > &__button, 16 | &:hover > &__input, 17 | &:hover > &__input::-webkit-input-placeholder { 18 | color: var(--color-dark); 19 | } 20 | 21 | @include mq($desktop) { 22 | flex: 0 0 50%; 23 | } 24 | 25 | @include mq($laptop) { 26 | flex: 0 0 60%; 27 | } 28 | 29 | @include mq($tablet) { 30 | flex: 0 0 100%; 31 | order: 2; 32 | margin-right: 0; 33 | } 34 | 35 | &__button { 36 | display: flex; 37 | justify-content: center; 38 | align-items: center; 39 | color: var(--color-grey-dark-3); 40 | background-color: transparent; 41 | outline: none; 42 | border: none; 43 | cursor: pointer; 44 | margin-left: 1.5rem; 45 | } 46 | 47 | &__icon { 48 | height: 1.2rem; 49 | width: 1.2rem; 50 | fill: currentColor; 51 | } 52 | 53 | &__input { 54 | font-family: inherit; 55 | font-size: inherit; 56 | color: inherit; 57 | background-color: transparent; 58 | border: none; 59 | padding: 1rem; 60 | width: 100%; 61 | 62 | &:focus { 63 | outline: none; 64 | } 65 | 66 | &::-webkit-input-placeholder { 67 | font-weight: 100; 68 | color: var(--color-grey-dark-3); 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/components/HOCs/withInfiniteScroll.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | const propTypes = { 5 | loading: PropTypes.bool.isRequired, 6 | gamesNextUrl: PropTypes.string, 7 | games: PropTypes.array, 8 | fetchGamesNext: PropTypes.func.isRequired, 9 | collectionKey: PropTypes.string 10 | }; 11 | 12 | const defaultProps = { 13 | className: '' 14 | }; 15 | 16 | const withInfiniteScroll = (InnerComponent, debounce = 0) => { 17 | const InfiniteScroll = ({ 18 | fetchGamesNext, 19 | gamesNextUrl, 20 | collectionKey, 21 | className, 22 | ...props 23 | }) => { 24 | useEffect(() => { 25 | const handleScroll = () => { 26 | if ( 27 | window.innerHeight + window.scrollY >= 28 | document.body.offsetHeight - 200 29 | ) { 30 | setTimeout( 31 | () => fetchGamesNext(collectionKey, gamesNextUrl), 32 | debounce 33 | ); 34 | } 35 | }; 36 | 37 | window.addEventListener('scroll', handleScroll); 38 | 39 | return () => { 40 | window.removeEventListener('scroll', handleScroll); 41 | }; 42 | }, [fetchGamesNext, collectionKey, gamesNextUrl]); 43 | 44 | return ( 45 |
46 | 47 |
48 | ); 49 | }; 50 | 51 | return InfiniteScroll; 52 | }; 53 | 54 | withInfiniteScroll.propTypes = propTypes; 55 | withInfiniteScroll.defaultProps = defaultProps; 56 | 57 | export default withInfiniteScroll; 58 | -------------------------------------------------------------------------------- /src/actions/GameActions.js: -------------------------------------------------------------------------------- 1 | import { FETCH_GAME_SCREENSHOTS_SUCCESS } from '../constants/ActionTypes'; 2 | import { gamesFetchSuccess } from '../actions/GamesActions'; 3 | import { getGames } from '../selectors/CommonSelectors'; 4 | import { GAME_PATH } from '../constants/urlApi'; 5 | import { fetchApi } from '../utils/apiCaller'; 6 | 7 | const fetchGameScreenshotsSuccess = (collectionKey, screenshots) => ({ 8 | type: FETCH_GAME_SCREENSHOTS_SUCCESS, 9 | payload: { collectionKey, screenshots } 10 | }); 11 | 12 | const fetchGameScreenshots = (id, collectionKey) => async dispatch => { 13 | const url = `${GAME_PATH.replace(':slug', id)}/screenshots?page_size=20`; 14 | const data = await fetchApi(url); 15 | const { results } = data; 16 | dispatch(fetchGameScreenshotsSuccess(collectionKey, results)); 17 | }; 18 | 19 | const fetchGame = (collectionKey, url) => async dispatch => { 20 | const data = await fetchApi(url); 21 | const { id } = data; 22 | if (id) { 23 | dispatch(gamesFetchSuccess(collectionKey, [data], null)); 24 | dispatch(fetchGameScreenshots(id, collectionKey)); 25 | } 26 | }; 27 | 28 | export const fetchGameIfNeeded = (slug, collectionKey) => async ( 29 | dispatch, 30 | getState 31 | ) => { 32 | const state = getState(); 33 | const games = getGames(state); 34 | const collection = games[collectionKey]; 35 | const isExists = !!collection; 36 | const hasItems = isExists ? collection.games.length > 0 : false; 37 | if (!isExists || !hasItems) { 38 | const url = GAME_PATH.replace(':slug', slug); 39 | dispatch(fetchGame(collectionKey, url)); 40 | } 41 | }; 42 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rawg-client", 3 | "version": "0.1.0", 4 | "homepage": "https://nttanh6299.github.io/rawg-client", 5 | "main": "index.js", 6 | "private": true, 7 | "dependencies": { 8 | "@testing-library/jest-dom": "^4.2.4", 9 | "@testing-library/react": "^9.3.2", 10 | "@testing-library/user-event": "^7.1.2", 11 | "axios": "^0.19.2", 12 | "camelize": "^1.0.0", 13 | "dayjs": "^1.8.27", 14 | "firebase": "^7.14.5", 15 | "formik": "^2.1.4", 16 | "gh-pages": "^3.0.0", 17 | "node-sass": "^4.14.0", 18 | "path-to-regexp": "^6.1.0", 19 | "react": "^16.13.1", 20 | "react-dom": "^16.13.1", 21 | "react-icons": "^3.10.0", 22 | "react-redux": "^7.2.0", 23 | "react-router-dom": "^5.1.2", 24 | "react-scripts": "3.4.1", 25 | "react-toastify": "^6.0.5", 26 | "redux": "^4.0.5", 27 | "redux-devtools-extension": "^2.13.8", 28 | "redux-thunk": "^2.3.0", 29 | "reselect": "^4.0.0", 30 | "yup": "^0.29.1" 31 | }, 32 | "scripts": { 33 | "start": "react-scripts start", 34 | "build": "react-scripts build", 35 | "test": "react-scripts test", 36 | "eject": "react-scripts eject", 37 | "predeploy": "npm run build", 38 | "deploy": "gh-pages -d build" 39 | }, 40 | "eslintConfig": { 41 | "extends": "react-app" 42 | }, 43 | "browserslist": { 44 | "production": [ 45 | ">0.2%", 46 | "not dead", 47 | "not op_mini all" 48 | ], 49 | "development": [ 50 | "last 1 chrome version", 51 | "last 1 firefox version", 52 | "last 1 safari version" 53 | ] 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/scss/_base.scss: -------------------------------------------------------------------------------- 1 | * { 2 | margin: 0; 3 | padding: 0; 4 | } 5 | 6 | *, 7 | *:before, 8 | *:after { 9 | box-sizing: border-box; 10 | } 11 | 12 | html { 13 | font-size: 62.5%; 14 | 15 | @include mq($desktop) { 16 | font-size: 55%; 17 | } 18 | 19 | @include mq($laptop) { 20 | font-size: 50%; 21 | } 22 | } 23 | 24 | body, 25 | button, 26 | input, 27 | textarea { 28 | font-family: 'Open Sans', sans-serif; 29 | } 30 | 31 | body { 32 | font-weight: 400; 33 | line-height: 1.6; 34 | background-color: var(--color-dark); 35 | color: #fff; 36 | } 37 | 38 | .App { 39 | max-width: $screen-max-width; 40 | padding: 0 5rem; 41 | margin: 0 auto; 42 | } 43 | 44 | .column { 45 | margin-top: 2rem; 46 | } 47 | 48 | .main { 49 | display: flex; 50 | flex-direction: column; 51 | 52 | @include mq($tablet) { 53 | max-width: 50rem; 54 | margin: 0 auto; 55 | } 56 | } 57 | 58 | .heading-1, 59 | .heading-2, 60 | .heading-3 { 61 | color: var(--color-grey-dark-3); 62 | } 63 | 64 | .heading-1 { 65 | font-size: 2.2rem; 66 | } 67 | 68 | .heading-2 { 69 | font-size: 1.8rem; 70 | } 71 | 72 | .heading-3 { 73 | font-size: 1.4rem; 74 | } 75 | 76 | .btn { 77 | font-size: 1.4rem; 78 | font-weight: 600; 79 | color: currentColor; 80 | background-color: transparent; 81 | outline: none; 82 | border: none; 83 | padding: 0.4rem; 84 | cursor: pointer; 85 | } 86 | 87 | .btn-light { 88 | color: var(--color-dark); 89 | background-color: #fff; 90 | } 91 | 92 | .btn-ouline-light { 93 | border: 1px solid #fff; 94 | } 95 | 96 | .footer { 97 | padding: 2rem 0; 98 | font-size: 1.8rem; 99 | text-align: right; 100 | } 101 | -------------------------------------------------------------------------------- /src/routes.js: -------------------------------------------------------------------------------- 1 | import { lazy } from 'react'; 2 | import { PublicLayout } from './layouts'; 3 | import { 4 | INDEX_PATH, 5 | GAMES_PATH, 6 | GAME_PATH, 7 | USER_PATH 8 | } from './constants/urlApi'; 9 | 10 | const GamesContainer = lazy(() => import('./containers/GamesContainer')); 11 | const GameContainer = lazy(() => import('./containers/GameContainer')); 12 | const LoginContainer = lazy(() => import('./containers/LoginContainer')); 13 | const SignupContainer = lazy(() => import('./containers/SignupContainer')); 14 | const UserContainer = lazy(() => import('./containers/UserContainer')); 15 | const UserSettingsContainer = lazy(() => 16 | import('./containers/UserSettingsContainer') 17 | ); 18 | const NotFound = lazy(() => import('./containers/NotFound')); 19 | export const routes = [ 20 | { 21 | path: INDEX_PATH, 22 | exact: true, 23 | layout: PublicLayout, 24 | component: GamesContainer 25 | }, 26 | { 27 | path: GAMES_PATH, 28 | exact: true, 29 | layout: PublicLayout, 30 | component: GamesContainer 31 | }, 32 | { 33 | path: GAME_PATH, 34 | exact: true, 35 | layout: PublicLayout, 36 | component: GameContainer 37 | }, 38 | { 39 | path: USER_PATH, 40 | exact: true, 41 | layout: PublicLayout, 42 | component: UserContainer 43 | }, 44 | { 45 | path: '/settings', 46 | exact: true, 47 | layout: PublicLayout, 48 | component: UserSettingsContainer 49 | }, 50 | { 51 | path: '/login', 52 | exact: true, 53 | layout: PublicLayout, 54 | component: LoginContainer 55 | }, 56 | { 57 | path: '/signup', 58 | exact: true, 59 | layout: PublicLayout, 60 | component: SignupContainer 61 | }, 62 | { 63 | path: '*', 64 | layout: PublicLayout, 65 | component: NotFound 66 | } 67 | ]; 68 | -------------------------------------------------------------------------------- /src/utils/RouterUtils.js: -------------------------------------------------------------------------------- 1 | import { pathToRegexp, compile } from 'path-to-regexp'; 2 | 3 | const compileOptions = options => { 4 | return Object.keys(options) 5 | .map(key => `${key}=${options[key]}`) 6 | .join('&'); 7 | }; 8 | 9 | const getPathMatch = (paths, pathname) => { 10 | return paths 11 | .map(path => { 12 | const keys = []; 13 | const regexp = pathToRegexp(path, keys); 14 | return { path, regexp, keys }; 15 | }) 16 | .find(path => path.regexp.test(pathname)); 17 | }; 18 | 19 | const parseRouteKeys = (pathname, result) => { 20 | const { keys, regexp } = result; 21 | const regexpResult = regexp.exec(pathname); 22 | 23 | return keys.reduce( 24 | (obj, key, i) => ({ 25 | ...obj, 26 | [key.name]: i + 1 < regexpResult.length ? regexpResult[i + 1] : '' 27 | }), 28 | {} 29 | ); 30 | }; 31 | 32 | const parseRouteOptions = search => { 33 | return search 34 | .split('&') 35 | .map(pair => pair.split('=')) 36 | .filter(keyValuePair => keyValuePair.length === 2 && keyValuePair[0] !== '') 37 | .reduce( 38 | (obj, keyValuePair) => ({ ...obj, [keyValuePair[0]]: keyValuePair[1] }), 39 | {} 40 | ); 41 | }; 42 | 43 | export const parseRoute = (paths, pathname, search) => { 44 | const pathMatch = getPathMatch(paths, pathname); 45 | const path = pathMatch ? pathMatch.path : pathname; 46 | const keys = pathMatch ? parseRouteKeys(pathname, pathMatch) : []; 47 | const options = search ? parseRouteOptions(search) : {}; 48 | 49 | return { path, keys, options }; 50 | }; 51 | 52 | export const compileRoute = ({ path, keys, options }) => { 53 | const toPath = compile(path, { encode: encodeURIComponent }); 54 | const query = compileOptions(options); 55 | return `${toPath(keys)}${query.trim() !== '' ? `?${query}` : ''}`; 56 | }; 57 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /src/scss/components/_loading.scss: -------------------------------------------------------------------------------- 1 | .lds-spinner { 2 | color: currentColor; 3 | display: inline-block; 4 | position: relative; 5 | width: 80px; 6 | height: 80px; 7 | 8 | & div { 9 | transform-origin: 40px 40px; 10 | animation: lds-spinner 1.2s linear infinite; 11 | } 12 | & div:after { 13 | content: ' '; 14 | display: block; 15 | position: absolute; 16 | top: 3px; 17 | left: 37px; 18 | width: 6px; 19 | height: 18px; 20 | border-radius: 20%; 21 | background: #fff; 22 | } 23 | & div:nth-child(1) { 24 | transform: rotate(0deg); 25 | animation-delay: -1.1s; 26 | } 27 | & div:nth-child(2) { 28 | transform: rotate(30deg); 29 | animation-delay: -1s; 30 | } 31 | & div:nth-child(3) { 32 | transform: rotate(60deg); 33 | animation-delay: -0.9s; 34 | } 35 | & div:nth-child(4) { 36 | transform: rotate(90deg); 37 | animation-delay: -0.8s; 38 | } 39 | & div:nth-child(5) { 40 | transform: rotate(120deg); 41 | animation-delay: -0.7s; 42 | } 43 | & div:nth-child(6) { 44 | transform: rotate(150deg); 45 | animation-delay: -0.6s; 46 | } 47 | & div:nth-child(7) { 48 | transform: rotate(180deg); 49 | animation-delay: -0.5s; 50 | } 51 | & div:nth-child(8) { 52 | transform: rotate(210deg); 53 | animation-delay: -0.4s; 54 | } 55 | & div:nth-child(9) { 56 | transform: rotate(240deg); 57 | animation-delay: -0.3s; 58 | } 59 | & div:nth-child(10) { 60 | transform: rotate(270deg); 61 | animation-delay: -0.2s; 62 | } 63 | & div:nth-child(11) { 64 | transform: rotate(300deg); 65 | animation-delay: -0.1s; 66 | } 67 | & div:nth-child(12) { 68 | transform: rotate(330deg); 69 | animation-delay: 0s; 70 | } 71 | @keyframes lds-spinner { 72 | 0% { 73 | opacity: 1; 74 | } 75 | 100% { 76 | opacity: 0; 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/components/UserSettings.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useMemo } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import withAuthenticated from './HOCs/withAuthenticated'; 4 | import SettingsProfileTab from './SettingsProfileTab'; 5 | import SettingsPasswordTab from './SettingsPasswordTab'; 6 | import { SETTINGS_TABS } from '../constants/GlobalConstants'; 7 | 8 | const propTypes = { 9 | currentUser: PropTypes.object 10 | }; 11 | 12 | const defaultProps = {}; 13 | 14 | const UserSettings = ({ currentUser, updateUser, changePassword }) => { 15 | const [currentTab, setCurrentTab] = useState(''); 16 | 17 | const renderTab = useMemo(() => { 18 | switch (currentTab) { 19 | case '': 20 | return ( 21 | 25 | ); 26 | case 'change-password': 27 | return ; 28 | default: 29 | return null; 30 | } 31 | }, [currentTab, currentUser, updateUser, changePassword]); 32 | 33 | const handleChangeTab = tab => () => { 34 | setCurrentTab(tab); 35 | }; 36 | 37 | return ( 38 |
39 |
40 | {SETTINGS_TABS.map(({ key, label }) => ( 41 | 48 | {label} 49 | 50 | ))} 51 |
52 |
{renderTab}
53 |
54 | ); 55 | }; 56 | 57 | UserSettings.propTypes = propTypes; 58 | UserSettings.defaultProps = defaultProps; 59 | 60 | export default withAuthenticated(UserSettings); 61 | -------------------------------------------------------------------------------- /src/components/CustomImageInput.js: -------------------------------------------------------------------------------- 1 | import React, { useRef, useState } from 'react'; 2 | 3 | const CustomImageInput = ({ name, errorMessage, setFieldValue, field }) => { 4 | const [file, setFile] = useState(); 5 | const [imageReviewUrl, setImageReviewUrl] = useState(); 6 | const fileUpload = useRef(null); 7 | 8 | const handleImageChange = e => { 9 | e.preventDefault(); 10 | const reader = new FileReader(); 11 | const file = e.target.files[0]; 12 | 13 | if (file) { 14 | reader.onloadend = () => { 15 | setFile(file); 16 | setImageReviewUrl(reader.result); 17 | }; 18 | reader.readAsDataURL(file); 19 | setFieldValue(field.name, file); 20 | } 21 | }; 22 | 23 | const showPreviewImage = () => { 24 | if (file && !errorMessage) { 25 | return avatar; 26 | } else { 27 | return ; 28 | } 29 | }; 30 | 31 | const showFileUpload = () => { 32 | if (fileUpload) { 33 | fileUpload.current.click(); 34 | } 35 | }; 36 | 37 | return ( 38 |
41 |
{showPreviewImage()}
42 | 50 | 53 | {errorMessage ? ( 54 | 62 | {errorMessage} 63 | 64 | ) : null} 65 |
66 | ); 67 | }; 68 | 69 | export default CustomImageInput; 70 | -------------------------------------------------------------------------------- /src/actions/GamesActions.js: -------------------------------------------------------------------------------- 1 | import { 2 | FETCH_GAMES_REQUEST, 3 | FETCH_GAMES_SUCCESS 4 | } from '../constants/ActionTypes'; 5 | import { fetchApi } from '../utils/apiCaller'; 6 | import { getGames } from '../selectors/CommonSelectors'; 7 | 8 | export const gamesFetchRequest = collectionKey => ({ 9 | type: FETCH_GAMES_REQUEST, 10 | payload: { collectionKey } 11 | }); 12 | 13 | export const gamesFetchSuccess = (collectionKey, fetchedData, nextUrl) => ({ 14 | type: FETCH_GAMES_SUCCESS, 15 | payload: { collectionKey, fetchedData, nextUrl } 16 | }); 17 | 18 | export const fetchGames = (collectionKey, url, method) => async dispatch => { 19 | try { 20 | dispatch(gamesFetchRequest(collectionKey)); 21 | const data = await fetchApi(url, method); 22 | 23 | const { next, results } = data; 24 | 25 | dispatch(gamesFetchSuccess(collectionKey, results, next)); 26 | } catch (err) { 27 | console.log('fetchGames error', err); 28 | } 29 | }; 30 | 31 | export const fetchGamesNext = (collectionKey, gamesNextUrl) => async ( 32 | dispatch, 33 | getState 34 | ) => { 35 | const state = getState(); 36 | const games = getGames(state); 37 | const collection = games[collectionKey]; 38 | const isExist = !!collection; 39 | const isFetching = isExist ? collection.loading : false; 40 | const shouldFetch = isExist && !isFetching && gamesNextUrl; 41 | if (shouldFetch) { 42 | dispatch(fetchGames(collectionKey, gamesNextUrl)); 43 | } 44 | }; 45 | 46 | export const fetchGamesIfNeeded = (collectionKey, url) => async ( 47 | dispatch, 48 | getState 49 | ) => { 50 | const state = getState(); 51 | const games = getGames(state); 52 | const collection = games[collectionKey]; 53 | const isExists = !!collection; 54 | const isFetching = isExists ? collection.loading : false; 55 | const hasItems = isExists ? collection.games.length > 0 : false; 56 | if (!isExists || (!hasItems && !isFetching)) { 57 | dispatch(fetchGames(collectionKey, url)); 58 | } 59 | }; 60 | -------------------------------------------------------------------------------- /src/components/HeaderGenres.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import CustomLink from './CustomLink'; 4 | import { GAMES_PATH } from '../constants/urlApi'; 5 | import { IoMdArrowDropdown } from 'react-icons/io'; 6 | 7 | const propTypes = { 8 | genres: PropTypes.array, 9 | genre: PropTypes.string, 10 | changeRoute: PropTypes.func.isRequired 11 | }; 12 | 13 | const defaultProps = {}; 14 | 15 | const HeaderGenres = ({ genres, genre, changeRoute }) => { 16 | const [expanded, setExpanded] = useState(false); 17 | 18 | const handleClick = () => { 19 | setExpanded(prev => !prev); 20 | }; 21 | 22 | return ( 23 | 57 | ); 58 | }; 59 | 60 | HeaderGenres.propTypes = propTypes; 61 | HeaderGenres.defaultProps = defaultProps; 62 | 63 | export default React.memo(HeaderGenres); 64 | -------------------------------------------------------------------------------- /src/components/UserLikesTab.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import GamesRendered from './GamesRendered'; 4 | import Loading from './Loading'; 5 | 6 | const propTypes = { 7 | uid: PropTypes.string.isRequired, 8 | fetchUserLikes: PropTypes.func.isRequired, 9 | changeRoute: PropTypes.func.isRequired, 10 | playFullVideo: PropTypes.func.isRequired, 11 | windowSize: PropTypes.number.isRequired, 12 | toggleLike: PropTypes.func.isRequired, 13 | isAuthenticated: PropTypes.bool.isRequired, 14 | likes: PropTypes.object 15 | }; 16 | 17 | const defaultProps = {}; 18 | 19 | const UserLikesTab = ({ 20 | uid, 21 | fetchUserLikes, 22 | changeRoute, 23 | playFullVideo, 24 | windowSize, 25 | toggleLike, 26 | isAuthenticated, 27 | likes 28 | }) => { 29 | const [games, setGames] = useState([]); 30 | const [loading, setLoading] = useState(false); 31 | 32 | useEffect(() => { 33 | const fetchGamesUserLike = async () => { 34 | setLoading(true); 35 | const res = await fetchUserLikes(uid); 36 | const data = res.data(); 37 | if (data) { 38 | const gameArr = Object.keys(data) 39 | .filter(key => !!data[key]) 40 | .reduce((arr, key) => [...arr, data[key]], []); 41 | setGames(gameArr); 42 | } 43 | setLoading(false); 44 | }; 45 | 46 | fetchGamesUserLike(); 47 | }, [uid, fetchUserLikes]); 48 | 49 | if (loading) { 50 | return ; 51 | } 52 | 53 | return ( 54 |
55 | 64 |
65 | ); 66 | }; 67 | 68 | UserLikesTab.propTypes = propTypes; 69 | UserLikesTab.defaultProps = defaultProps; 70 | 71 | export default UserLikesTab; 72 | -------------------------------------------------------------------------------- /src/components/Games.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import GamesRendered from './GamesRendered'; 4 | import Loading from './Loading'; 5 | import withInfiniteScroll from './HOCs/withInfiniteScroll'; 6 | import HeaderGenres from './HeaderGenres'; 7 | 8 | const propTypes = { 9 | loading: PropTypes.bool.isRequired, 10 | games: PropTypes.array, 11 | genres: PropTypes.array, 12 | genre: PropTypes.string, 13 | changeRoute: PropTypes.func.isRequired, 14 | fetchGamesIfNeeded: PropTypes.func.isRequired, 15 | collectionKey: PropTypes.string, 16 | gamesUrl: PropTypes.string, 17 | videoId: PropTypes.string, 18 | playFullVideo: PropTypes.func.isRequired, 19 | windowSize: PropTypes.number.isRequired, 20 | toggleLike: PropTypes.func.isRequired, 21 | likes: PropTypes.object, 22 | isAuthenticated: PropTypes.bool.isRequired 23 | }; 24 | 25 | const defaultProps = {}; 26 | 27 | const Games = ({ 28 | loading, 29 | games, 30 | genres, 31 | genre, 32 | changeRoute, 33 | fetchGamesIfNeeded, 34 | collectionKey, 35 | gamesUrl, 36 | playFullVideo, 37 | windowSize, 38 | toggleLike, 39 | likes, 40 | isAuthenticated 41 | }) => { 42 | useEffect(() => { 43 | fetchGamesIfNeeded(collectionKey, gamesUrl); 44 | }, [fetchGamesIfNeeded, collectionKey, gamesUrl]); 45 | 46 | return ( 47 |
48 | 49 | 58 | 63 |
64 | ); 65 | }; 66 | 67 | Games.propTypes = propTypes; 68 | Games.defaultProps = defaultProps; 69 | 70 | export default withInfiniteScroll(Games); 71 | -------------------------------------------------------------------------------- /src/scss/components/_user.scss: -------------------------------------------------------------------------------- 1 | .user { 2 | margin: 3rem 6rem; 3 | display: flex; 4 | flex-direction: column; 5 | 6 | @include mq($tablet) { 7 | margin: 3rem 0; 8 | align-items: center; 9 | } 10 | 11 | &__top { 12 | display: flex; 13 | justify-content: space-between; 14 | align-items: center; 15 | 16 | @include mq($tablet) { 17 | flex-direction: column; 18 | } 19 | } 20 | 21 | &__info { 22 | display: flex; 23 | align-items: center; 24 | font-weight: 600; 25 | 26 | @include mq($tablet) { 27 | flex-direction: column-reverse; 28 | justify-content: center; 29 | margin-bottom: 1rem; 30 | } 31 | } 32 | 33 | &__photo { 34 | background-size: cover; 35 | background-position: center center; 36 | background-color: #fff; 37 | width: 12rem; 38 | height: 12rem; 39 | color: #000; 40 | font-size: 4rem; 41 | border-radius: 50%; 42 | display: flex; 43 | justify-content: center; 44 | align-items: center; 45 | } 46 | 47 | &__username { 48 | font-size: 5rem; 49 | margin-right: 2rem; 50 | 51 | @include mq($tablet) { 52 | margin-right: 0; 53 | } 54 | } 55 | 56 | &__settings { 57 | font-size: 1.6rem; 58 | background-color: #fff; 59 | color: #000; 60 | border-radius: 0.4rem; 61 | padding: 1rem 2rem; 62 | animation: settings_opacity 0.2s; 63 | 64 | @keyframes settings_opacity { 65 | from { 66 | opacity: 0; 67 | } 68 | to { 69 | opacity: 1; 70 | } 71 | } 72 | } 73 | 74 | &__tabs { 75 | display: flex; 76 | align-items: center; 77 | margin: 2rem 0; 78 | } 79 | 80 | &__tab { 81 | padding: 0.6rem 0; 82 | font-size: 2rem; 83 | color: var(--color-grey-dark-3); 84 | border-bottom: 2px solid transparent; 85 | 86 | & + & { 87 | margin-left: 1.4rem; 88 | } 89 | 90 | &:hover { 91 | border-bottom: 2px solid #fff; 92 | cursor: pointer; 93 | } 94 | 95 | &--active { 96 | color: #fff; 97 | border-bottom: 2px solid #fff; 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/utils/GamesUtils.js: -------------------------------------------------------------------------------- 1 | import { GAMES_PATH } from '../constants/urlApi'; 2 | import { 3 | GENRE_COLLECTION_TYPE, 4 | SEARCH_COLLECTION_TYPE, 5 | TAG_COLLECTION_TYPE 6 | } from '../constants/KeyConstants'; 7 | 8 | const isFetching = (collections, collectionKey) => 9 | !!collections[collectionKey] ? collections[collectionKey].loading : false; 10 | 11 | const gamesUrlByGenre = genre => { 12 | const genreUriSegment = `genres=${genre}`; 13 | return `${GAMES_PATH}?${genreUriSegment}`; 14 | }; 15 | 16 | const gamesUrlBySearch = search => { 17 | const searchUriSegment = `search=${search}`; 18 | return `${GAMES_PATH}?${searchUriSegment}`; 19 | }; 20 | 21 | const gamesUrlByTag = tag => { 22 | const searchUriSegment = `tags=${tag}`; 23 | return `${GAMES_PATH}?${searchUriSegment}`; 24 | }; 25 | 26 | const gamesNextUrl = (collections, collectionKey) => 27 | !!collections[collectionKey] ? collections[collectionKey].nextUrl : null; 28 | 29 | const gamesByCollectionKey = (collections, collectionKey) => 30 | !!collections[collectionKey] ? collections[collectionKey].games : []; 31 | 32 | export const gameCollectionData = (games, genre, search, tag) => { 33 | if (search) { 34 | const collectionKey = [SEARCH_COLLECTION_TYPE, search].join('|'); 35 | return { 36 | loading: isFetching(games, collectionKey), 37 | gamesUrl: gamesUrlBySearch(search), 38 | gamesNextUrl: gamesNextUrl(games, collectionKey), 39 | games: gamesByCollectionKey(games, collectionKey), 40 | collectionKey 41 | }; 42 | } else if (tag) { 43 | const collectionKey = [TAG_COLLECTION_TYPE, tag].join('|'); 44 | return { 45 | loading: isFetching(games, collectionKey), 46 | gamesUrl: gamesUrlByTag(tag), 47 | gamesNextUrl: gamesNextUrl(games, collectionKey), 48 | games: gamesByCollectionKey(games, collectionKey), 49 | collectionKey 50 | }; 51 | } 52 | 53 | const collectionKey = [GENRE_COLLECTION_TYPE, genre].join('|'); 54 | return { 55 | loading: isFetching(games, collectionKey), 56 | gamesUrl: gamesUrlByGenre(genre), 57 | gamesNextUrl: gamesNextUrl(games, collectionKey), 58 | games: gamesByCollectionKey(games, collectionKey), 59 | collectionKey 60 | }; 61 | }; 62 | -------------------------------------------------------------------------------- /src/scss/components/_header-genres.scss: -------------------------------------------------------------------------------- 1 | .header-genres { 2 | position: relative; 3 | color: #fff; 4 | margin-bottom: 4rem; 5 | 6 | @include mq($tablet) { 7 | margin-top: -3rem; 8 | margin-bottom: 0; 9 | } 10 | 11 | &__expanded { 12 | display: none; 13 | margin-bottom: 1rem; 14 | 15 | @include mq($tablet) { 16 | display: flex; 17 | justify-content: flex-end; 18 | align-items: center; 19 | } 20 | } 21 | 22 | &__current-genre { 23 | font-size: 1.6em; 24 | text-transform: uppercase; 25 | } 26 | 27 | &__expanded-icon { 28 | display: inline-block; 29 | text-align: center; 30 | font-size: 3rem; 31 | width: 3rem; 32 | height: 3rem; 33 | line-height: 3rem; 34 | margin-left: 1rem; 35 | border-radius: 0.4rem; 36 | background-color: var(--color-grey-dark-1); 37 | cursor: pointer; 38 | } 39 | 40 | &__menu { 41 | display: flex; 42 | flex-wrap: wrap; 43 | overflow: hidden; 44 | 45 | @include mq($tablet) { 46 | background-color: var(--color-dark); 47 | position: absolute; 48 | top: 3.8rem; 49 | left: 0; 50 | width: 100%; 51 | padding: 0; 52 | flex-direction: column; 53 | text-align: center; 54 | height: 0; 55 | z-index: 1; 56 | } 57 | 58 | &--expanded { 59 | height: auto; 60 | } 61 | } 62 | } 63 | 64 | .link { 65 | font-size: 1.6rem; 66 | padding: 0.6rem 1.6rem; 67 | background-color: var(--color-grey-dark-1); 68 | color: var(--color-grey-dark-3); 69 | margin: 1rem 1rem 0 0; 70 | border-radius: 10rem; 71 | cursor: pointer; 72 | transition: 0.2s; 73 | 74 | &:hover { 75 | background-color: #fff; 76 | color: var(--color-dark); 77 | } 78 | 79 | @include mq($tablet) { 80 | font-size: 1.6rem; 81 | padding: 1rem 1.6rem; 82 | color: var(--color-grey-dark-3); 83 | background-color: transparent; 84 | margin: 0; 85 | border-radius: 0; 86 | border-bottom: 1px solid var(--color-grey-dark-1); 87 | border-left: 1px solid var(--color-grey-dark-1); 88 | border-right: 1px solid var(--color-grey-dark-1); 89 | } 90 | 91 | &:first-child { 92 | @include mq($tablet) { 93 | border-top: 1px solid var(--color-grey-dark-1); 94 | } 95 | } 96 | 97 | &--active { 98 | background-color: #fff; 99 | color: var(--color-dark); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/constants/GlobalConstants.js: -------------------------------------------------------------------------------- 1 | export const GENRES = [ 2 | { 3 | key: 'action', 4 | label: 'Action', 5 | to: 'action' 6 | }, 7 | { 8 | key: 'indie', 9 | label: 'Indie', 10 | to: 'indie' 11 | }, 12 | { 13 | key: 'adventure', 14 | label: 'Adventure', 15 | to: 'adventure' 16 | }, 17 | { 18 | key: 'role-playing-games-rpg', 19 | label: 'RPG', 20 | to: 'role-playing-games-rpg' 21 | }, 22 | { 23 | key: 'shooter', 24 | label: 'Shooter', 25 | to: 'shooter' 26 | }, 27 | { 28 | key: 'strategy', 29 | label: 'Strategy', 30 | to: 'strategy' 31 | }, 32 | { 33 | key: 'casual', 34 | label: 'Casual', 35 | to: 'casual' 36 | }, 37 | { 38 | key: 'simulation', 39 | label: 'Simulation', 40 | to: 'simulation' 41 | }, 42 | { 43 | key: 'arcade', 44 | label: 'Arcade', 45 | to: 'arcade' 46 | }, 47 | { 48 | key: 'puzzle', 49 | label: 'Puzzle', 50 | to: 'puzzle' 51 | }, 52 | { 53 | key: 'platformer', 54 | label: 'Platformer', 55 | to: 'platformer' 56 | }, 57 | { 58 | key: 'racing', 59 | label: 'Racing', 60 | to: 'racing' 61 | }, 62 | { 63 | key: 'sports', 64 | label: 'Sports', 65 | to: 'sports' 66 | }, 67 | { 68 | key: 'family', 69 | label: 'Family', 70 | to: 'family' 71 | }, 72 | { 73 | key: 'massively-multiplayer', 74 | label: 'Massively Multiplayer', 75 | to: 'massively-multiplayer' 76 | }, 77 | { 78 | key: 'fighting', 79 | label: 'Fighting', 80 | to: 'fighting' 81 | }, 82 | { 83 | key: 'board-games', 84 | label: 'Board Games', 85 | to: 'board-games' 86 | }, 87 | { 88 | key: 'educational', 89 | label: 'Educational', 90 | to: 'educational' 91 | }, 92 | { 93 | key: 'card', 94 | label: 'Card', 95 | to: 'card' 96 | } 97 | ]; 98 | 99 | export const WINDOW_SIZE = { 100 | tablet: 768, 101 | laptop: 992, 102 | desktop: 1200, 103 | all: 0 104 | }; 105 | 106 | export const WINDOW_RESIZE_DEBOUNCE = 400; 107 | 108 | export const USER_TABS = [ 109 | { 110 | key: '', 111 | label: 'Overview' 112 | }, 113 | { 114 | key: 'likes', 115 | label: 'Likes' 116 | } 117 | ]; 118 | 119 | export const SETTINGS_TABS = [ 120 | { 121 | key: '', 122 | label: 'Profile' 123 | }, 124 | { 125 | key: 'change-password', 126 | label: 'Change password' 127 | } 128 | ]; 129 | 130 | export const FILE_SIZE = 160 * 1024; 131 | export const SUPPORTED_FORMATS = ['image/jpg', 'image/jpeg', 'image/png']; 132 | -------------------------------------------------------------------------------- /src/components/GamesRendered.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import GameItem from './GameItem'; 4 | import { WINDOW_SIZE } from '../constants/GlobalConstants'; 5 | 6 | const propTypes = { 7 | games: PropTypes.array, 8 | changeRoute: PropTypes.func.isRequired, 9 | playFullVideo: PropTypes.func.isRequired, 10 | windowSize: PropTypes.number.isRequired, 11 | toggleLike: PropTypes.func.isRequired, 12 | likes: PropTypes.object, 13 | isAuthenticated: PropTypes.bool.isRequired 14 | }; 15 | 16 | const defaultProps = { 17 | games: [] 18 | }; 19 | 20 | function setCol(windowSize) { 21 | switch (windowSize) { 22 | case WINDOW_SIZE.all: 23 | return 4; 24 | case WINDOW_SIZE.desktop: 25 | return 3; 26 | case WINDOW_SIZE.laptop: 27 | return 2; 28 | case WINDOW_SIZE.tablet: 29 | return 1; 30 | default: 31 | return 1; 32 | } 33 | } 34 | 35 | const GameRendered = ({ 36 | games, 37 | changeRoute, 38 | playFullVideo, 39 | windowSize, 40 | toggleLike, 41 | likes, 42 | isAuthenticated 43 | }) => { 44 | const [gamesRendered, setGameRendered] = useState([]); 45 | const [colRendered, setColRendered] = useState(setCol(windowSize)); 46 | 47 | useEffect(() => { 48 | let col = 0; 49 | const items = []; 50 | 51 | for (let i = 0; i < colRendered; i++) { 52 | items.push([]); 53 | } 54 | 55 | for (let i = 0; i < games.length; i++) { 56 | items[col].push(games[i]); 57 | col++; 58 | if (col === colRendered) { 59 | col = 0; 60 | } 61 | } 62 | setGameRendered(items); 63 | }, [games, colRendered]); 64 | 65 | useEffect(() => { 66 | setColRendered(setCol(windowSize)); 67 | }, [windowSize]); 68 | 69 | return ( 70 |
76 | {gamesRendered.map((set, index) => { 77 | return ( 78 |
79 | {set.map(game => { 80 | return ( 81 | 90 | ); 91 | })} 92 |
93 | ); 94 | })} 95 |
96 | ); 97 | }; 98 | 99 | GameRendered.propTypes = propTypes; 100 | GameRendered.defaultProps = defaultProps; 101 | 102 | export default React.memo(GameRendered); 103 | -------------------------------------------------------------------------------- /src/components/Login.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import { Formik, Form } from 'formik'; 4 | import * as yup from 'yup'; 5 | import CustomTextField from './CustomTextField'; 6 | import { AiOutlineLoading } from 'react-icons/ai'; 7 | import withLogged from './HOCs/withLogged'; 8 | 9 | const initialValues = { 10 | email: '', 11 | password: '', 12 | failed: '' 13 | }; 14 | 15 | const validationSchema = yup.object({ 16 | email: yup.string().required('This field is required'), 17 | password: yup.string().required('This field is required') 18 | }); 19 | 20 | const Login = ({ login }) => { 21 | const handleSubmit = async (data, actions) => { 22 | const { setSubmitting, setFieldError } = actions; 23 | const { email, password } = data; 24 | 25 | setSubmitting(true); 26 | try { 27 | await login(email, password); 28 | } catch (error) { 29 | console.log(error); 30 | setFieldError('failed', 'Unable to log in with provided credentials'); 31 | setSubmitting(false); 32 | } 33 | }; 34 | 35 | return ( 36 |
37 |

LOGIN

38 | 43 | {({ isSubmitting, errors }) => ( 44 |
45 | 51 | 57 | {errors && errors.failed && ( 58 |
66 | {errors.failed} 67 |
68 | )} 69 | 78 | 79 | )} 80 |
81 | 82 | Don't have an account? Sign up. 83 | 84 | 85 | Forgot your password? 86 | 87 |
88 | ); 89 | }; 90 | 91 | export default withLogged(Login); 92 | -------------------------------------------------------------------------------- /src/components/Header.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { history } from '../utils/helpers'; 4 | import { FaSearch } from 'react-icons/fa'; 5 | import { IoIosLogOut } from 'react-icons/io'; 6 | import { GAMES_PATH, USER_PATH } from '../constants/urlApi'; 7 | import CustomLink from './CustomLink'; 8 | 9 | const propTypes = { 10 | changeRoute: PropTypes.func.isRequired, 11 | logOut: PropTypes.func.isRequired, 12 | currentUser: PropTypes.object 13 | }; 14 | 15 | const defaultProps = {}; 16 | 17 | const Header = ({ changeRoute, logOut, currentUser }) => { 18 | const { displayName, photoURL } = currentUser || {}; 19 | 20 | const handleLogOut = async () => { 21 | await logOut(); 22 | }; 23 | 24 | const handleKeyPress = e => { 25 | const code = e.which || e.keyCode; 26 | //press enter 27 | if (code === 13) { 28 | const value = e.target.value; 29 | if (value !== '') { 30 | changeRoute({ path: 'games', keys: {}, options: { query: value } }); 31 | history.push(`/games?query=${value}`); 32 | } 33 | } 34 | }; 35 | 36 | return ( 37 |
38 |

39 | 40 | RAWGC 41 | 42 |

43 |
44 | 47 | 53 |
54 | {!currentUser ? ( 55 | 56 | 62 | Login 63 | 64 | 69 | Sign up 70 | 71 | 72 | ) : ( 73 |
74 |
75 | 82 |
86 | {!photoURL && displayName ? displayName[0].toUpperCase() : ''} 87 |
88 | {displayName} 89 |
90 |
91 | 97 |
98 | )} 99 |
100 | ); 101 | }; 102 | 103 | Header.propTypes = propTypes; 104 | Header.defaultProps = defaultProps; 105 | 106 | export default React.memo(Header); 107 | -------------------------------------------------------------------------------- /src/components/Signup.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import { Formik, Form } from 'formik'; 4 | import * as yup from 'yup'; 5 | import CustomTextField from './CustomTextField'; 6 | import { AiOutlineLoading } from 'react-icons/ai'; 7 | import withLogged from './HOCs/withLogged'; 8 | 9 | const initialValues = { 10 | email: '', 11 | username: '', 12 | password: '', 13 | passwordConfirmation: '' 14 | }; 15 | 16 | const validationSchema = yup.object({ 17 | email: yup 18 | .string() 19 | .required('This field is required') 20 | .email('This field must be email'), 21 | username: yup 22 | .string() 23 | .required('This field is required') 24 | .min(6, 'This field min 6 characters') 25 | .max(15, 'This field max 15 characters'), 26 | password: yup 27 | .string() 28 | .required('This field is required') 29 | .min(6, 'This field min 6 characters'), 30 | passwordConfirmation: yup 31 | .string() 32 | .required('This field is required') 33 | .when('password', { 34 | is: val => (val && val.length > 0 ? true : false), 35 | then: yup 36 | .string() 37 | .oneOf([yup.ref('password')], 'Both password need to be the same') 38 | }) 39 | }); 40 | 41 | const Signup = ({ signUp }) => { 42 | const handleSubmit = async (data, actions) => { 43 | const { setSubmitting, setFieldError } = actions; 44 | const { email, password, username } = data; 45 | 46 | setSubmitting(true); 47 | try { 48 | const error = await signUp(email, username, password); 49 | if (error) { 50 | const [name, msg] = error.split('-'); 51 | setFieldError(name, msg); 52 | } 53 | } catch (error) { 54 | console.error(error.code); 55 | console.error(error.message); 56 | setSubmitting(false); 57 | } 58 | }; 59 | 60 | return ( 61 |
62 |

SIGN UP

63 | 68 | {({ isSubmitting }) => ( 69 |
70 | 76 | 82 | 88 | 94 | 103 | 104 | )} 105 |
106 | 107 | Already have an account? Login. 108 | 109 |
110 | ); 111 | }; 112 | 113 | export default withLogged(Signup); 114 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useCallback } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { HashRouter, Switch } from 'react-router-dom'; 4 | import { routes } from './routes'; 5 | import { PublicRoute } from './layouts'; 6 | import { initRouter } from './actions/RouterActions'; 7 | import { closeFullVideo, windowResize } from './actions/AppActions'; 8 | import { authen } from './actions/UserActions'; 9 | import HeaderContainer from './containers/HeaderContainer'; 10 | import { 11 | INDEX_PATH, 12 | GAMES_PATH, 13 | GAME_PATH, 14 | USER_PATH 15 | } from './constants/urlApi'; 16 | import { FullVideo } from './components'; 17 | import { 18 | WINDOW_SIZE, 19 | WINDOW_RESIZE_DEBOUNCE 20 | } from './constants/GlobalConstants'; 21 | import { ToastContainer } from 'react-toastify'; 22 | import 'react-toastify/dist/ReactToastify.min.css'; 23 | 24 | const paths = [INDEX_PATH, GAMES_PATH, GAME_PATH, USER_PATH]; 25 | 26 | function App({ 27 | initRouter, 28 | closeFullVideo, 29 | videoId, 30 | windowResize, 31 | windowSize, 32 | authen 33 | }) { 34 | useEffect(() => { 35 | initRouter(paths); 36 | const subscriber = authen(); 37 | return subscriber; 38 | }, [initRouter, authen]); 39 | 40 | useEffect(() => { 41 | let timeout = null; 42 | 43 | const resize = () => { 44 | const { innerWidth } = window; 45 | if ( 46 | innerWidth < WINDOW_SIZE.tablet && 47 | windowSize !== WINDOW_SIZE.tablet 48 | ) { 49 | windowResize(WINDOW_SIZE.tablet); 50 | } else if ( 51 | innerWidth >= WINDOW_SIZE.tablet && 52 | innerWidth < WINDOW_SIZE.laptop && 53 | windowSize !== WINDOW_SIZE.laptop 54 | ) { 55 | windowResize(WINDOW_SIZE.laptop); 56 | } else if ( 57 | innerWidth >= WINDOW_SIZE.laptop && 58 | innerWidth < WINDOW_SIZE.desktop && 59 | windowSize !== WINDOW_SIZE.desktop 60 | ) { 61 | windowResize(WINDOW_SIZE.desktop); 62 | } else if ( 63 | innerWidth >= WINDOW_SIZE.desktop && 64 | windowSize !== WINDOW_SIZE.all 65 | ) { 66 | windowResize(WINDOW_SIZE.all); 67 | } 68 | }; 69 | 70 | const onWidthResize = () => { 71 | clearTimeout(timeout); 72 | timeout = setTimeout(resize, WINDOW_RESIZE_DEBOUNCE); 73 | }; 74 | 75 | onWidthResize(); 76 | window.addEventListener('resize', onWidthResize); 77 | 78 | return () => { 79 | window.removeEventListener('resize', onWidthResize); 80 | }; 81 | }, [windowSize, windowResize]); 82 | 83 | const renderRoutes = useCallback(routes => { 84 | let result = null; 85 | if (routes.length > 0) { 86 | result = routes.map((route, index) => { 87 | const { path, exact, layout, component } = route; 88 | 89 | return ( 90 | 97 | ); 98 | }); 99 | } 100 | return {result}; 101 | }, []); 102 | 103 | return ( 104 | 105 | 106 |
107 | 108 | {renderRoutes(routes)} 109 | 110 |
111 |
112 | ); 113 | } 114 | 115 | const mapStateToProps = state => { 116 | return { 117 | ...state.app 118 | }; 119 | }; 120 | 121 | export default connect(mapStateToProps, { 122 | initRouter, 123 | closeFullVideo, 124 | windowResize, 125 | authen 126 | })(App); 127 | -------------------------------------------------------------------------------- /src/components/SettingsProfileTab.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Formik, Form, Field } from 'formik'; 4 | import * as yup from 'yup'; 5 | import CustomTextField from './CustomTextField'; 6 | import CustomImageInput from './CustomImageInput'; 7 | import { AiOutlineLoading } from 'react-icons/ai'; 8 | import { FILE_SIZE, SUPPORTED_FORMATS } from '../constants/GlobalConstants'; 9 | import { toast } from 'react-toastify'; 10 | 11 | const propTypes = { 12 | currentUser: PropTypes.object 13 | }; 14 | 15 | const defaultProps = {}; 16 | 17 | const initialValues = { 18 | photo: undefined, 19 | username: '' 20 | }; 21 | 22 | const validationSchema = yup.object({ 23 | photo: yup 24 | .mixed() 25 | .test( 26 | 'fileSize', 27 | 'File too large', 28 | value => !value || value.size <= FILE_SIZE 29 | ) 30 | .test( 31 | 'fileFormat', 32 | 'Unsupported format', 33 | value => !value || SUPPORTED_FORMATS.includes(value.type) 34 | ), 35 | username: yup 36 | .string() 37 | .trim() 38 | .required('This field is required') 39 | .min(6, 'This field min 6 characters') 40 | .max(15, 'This field max 15 characters') 41 | }); 42 | 43 | const SettingsProfileTab = ({ currentUser, updateUser }) => { 44 | const { displayName } = currentUser || {}; 45 | 46 | const handleSubmit = async (data, actions) => { 47 | const { setSubmitting, setFieldError } = actions; 48 | const { photo, username } = data; 49 | 50 | if (!photo && displayName === username) { 51 | return; 52 | } 53 | 54 | setSubmitting(true); 55 | try { 56 | const error = await updateUser(data); 57 | if (error) { 58 | const [name, msg] = error.split('-'); 59 | setFieldError(name, msg); 60 | } else { 61 | toast.dark('🦄 Update profile successfully!', { 62 | position: toast.POSITION.TOP_RIGHT 63 | }); 64 | } 65 | } catch (error) { 66 | console.error(error.code); 67 | console.error(error.message); 68 | } finally { 69 | setSubmitting(false); 70 | } 71 | }; 72 | 73 | return ( 74 |
75 |
76 |

MY PROFILE

77 | 82 | {({ isSubmitting, errors, touched, setFieldValue }) => ( 83 |
84 | 91 | 97 | 106 | 107 | )} 108 |
109 |
110 |
111 | ); 112 | }; 113 | 114 | SettingsProfileTab.propTypes = propTypes; 115 | SettingsProfileTab.defaultProps = defaultProps; 116 | 117 | export default SettingsProfileTab; 118 | -------------------------------------------------------------------------------- /src/components/SettingsPasswordTab.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Formik, Form } from 'formik'; 4 | import * as yup from 'yup'; 5 | import CustomTextField from './CustomTextField'; 6 | import { AiOutlineLoading } from 'react-icons/ai'; 7 | import { toast } from 'react-toastify'; 8 | 9 | const propTypes = { 10 | currentUser: PropTypes.object 11 | }; 12 | 13 | const defaultProps = {}; 14 | 15 | const initialValues = { 16 | oldPassword: '', 17 | newPassword: '', 18 | confirmNewPassword: '', 19 | error: '' 20 | }; 21 | 22 | const validationSchema = yup.object({ 23 | oldPassword: yup.string().required('This field is required'), 24 | newPassword: yup 25 | .string() 26 | .required('This field is required') 27 | .min(6, 'This field min 6 characters'), 28 | confirmNewPassword: yup 29 | .string() 30 | .required('This field is required') 31 | .when('newPassword', { 32 | is: val => (val && val.length > 0 ? true : false), 33 | then: yup 34 | .string() 35 | .oneOf([yup.ref('newPassword')], 'Both password need to be the same') 36 | }) 37 | }); 38 | 39 | const SettingsPasswordTab = ({ changePassword }) => { 40 | const handleSubmit = async (data, actions) => { 41 | const { setSubmitting, setFieldError, resetForm } = actions; 42 | const { oldPassword, newPassword } = data; 43 | 44 | setSubmitting(true); 45 | try { 46 | const error = await changePassword(oldPassword, newPassword); 47 | 48 | if (error) { 49 | const [name, msg] = error.split('-'); 50 | setFieldError(name, msg); 51 | } else { 52 | resetForm(); 53 | toast.dark('🦄 Change password successfully!', { 54 | position: toast.POSITION.TOP_RIGHT 55 | }); 56 | } 57 | } catch (error) { 58 | console.error(error.code); 59 | console.error(error.message); 60 | } finally { 61 | setSubmitting(false); 62 | } 63 | }; 64 | 65 | return ( 66 |
67 |
68 |

CHANGE MY PASSWORD

69 | 74 | {({ isSubmitting, errors }) => ( 75 |
76 | 82 | 88 | 94 | {errors['error'] && ( 95 | 102 | {errors['error']} 103 | 104 | )} 105 | 114 | 115 | )} 116 |
117 |
118 |
119 | ); 120 | }; 121 | 122 | SettingsPasswordTab.propTypes = propTypes; 123 | SettingsPasswordTab.defaultProps = defaultProps; 124 | 125 | export default SettingsPasswordTab; 126 | -------------------------------------------------------------------------------- /src/components/GameItem.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import Background from './Background'; 4 | import Video from './Video'; 5 | import CustomLink from './CustomLink'; 6 | import { FaPlay } from 'react-icons/fa'; 7 | import { AiTwotoneLike, AiOutlineLoading } from 'react-icons/ai'; 8 | import { setMetacriticColor, platformIcon } from '../utils/helpers'; 9 | import { GAME_PATH } from '../constants/urlApi'; 10 | import { history } from '../utils/helpers'; 11 | 12 | const propTypes = { 13 | game: PropTypes.object, 14 | changeRoute: PropTypes.func.isRequired, 15 | playFullVideo: PropTypes.func.isRequired, 16 | toggleLike: PropTypes.func.isRequired, 17 | liked: PropTypes.bool.isRequired, 18 | isAuthenticated: PropTypes.bool.isRequired 19 | }; 20 | 21 | const defaultProps = { 22 | game: { 23 | clip: { 24 | clip: '', 25 | video: '' 26 | }, 27 | parentPlatforms: [] 28 | } 29 | }; 30 | 31 | const GameItem = ({ 32 | game, 33 | changeRoute, 34 | playFullVideo, 35 | toggleLike, 36 | liked, 37 | isAuthenticated 38 | }) => { 39 | const [loadingToggleLike, setLoadingToggleLike] = useState(false); 40 | const [hover, setHover] = useState(false); 41 | 42 | const { 43 | id, 44 | name, 45 | slug, 46 | backgroundImage, 47 | metacritic, 48 | clip, 49 | parentPlatforms 50 | } = game; 51 | const hasVideo = !!clip && !!clip.clip; 52 | const showVideo = hasVideo && hover; 53 | 54 | const handleMouseEnter = e => { 55 | setHover(true); 56 | }; 57 | 58 | const handleMouseLeave = e => { 59 | setHover(false); 60 | }; 61 | 62 | const handleToggleLike = e => { 63 | if (isAuthenticated) { 64 | setLoadingToggleLike(true); 65 | toggleLike(id, !liked ? game : null, setLoadingToggleLike); 66 | } else { 67 | changeRoute({ path: '/login', keys: {}, options: {} }); 68 | history.push('/login'); 69 | } 70 | }; 71 | 72 | return ( 73 |
78 | 79 | {hasVideo && } 80 | 81 | {showVideo && ( 82 |
128 | ); 129 | }; 130 | 131 | GameItem.propTypes = propTypes; 132 | GameItem.defaultProps = defaultProps; 133 | 134 | export default React.memo(GameItem); 135 | -------------------------------------------------------------------------------- /src/components/User.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useMemo } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { USER_TABS } from '../constants/GlobalConstants'; 4 | import Loading from './Loading'; 5 | import CustomLink from './CustomLink'; 6 | import UserLikesTab from './UserLikesTab'; 7 | 8 | const propTypes = { 9 | loadingUserProfile: PropTypes.bool.isRequired, 10 | visitedUserProfile: PropTypes.object, 11 | username: PropTypes.string, 12 | isCurrentUser: PropTypes.bool.isRequired, 13 | fetchUser: PropTypes.func.isRequired, 14 | changeRoute: PropTypes.func.isRequired, 15 | playFullVideo: PropTypes.func.isRequired, 16 | windowSize: PropTypes.number.isRequired, 17 | toggleLike: PropTypes.func.isRequired, 18 | isAuthenticated: PropTypes.bool.isRequired, 19 | fetchUserLikes: PropTypes.func.isRequired, 20 | likes: PropTypes.object 21 | }; 22 | 23 | const defaultProps = {}; 24 | 25 | const User = ({ 26 | loadingUserProfile, 27 | visitedUserProfile, 28 | username, 29 | fetchUser, 30 | isCurrentUser, 31 | changeRoute, 32 | playFullVideo, 33 | windowSize, 34 | toggleLike, 35 | isAuthenticated, 36 | fetchUserLikes, 37 | likes 38 | }) => { 39 | const [currentTab, setCurrentTab] = useState(''); 40 | const { uid, displayName, photoURL } = visitedUserProfile || {}; 41 | 42 | useEffect(() => { 43 | setCurrentTab(''); 44 | if (username) { 45 | fetchUser(username); 46 | } 47 | }, [fetchUser, username]); 48 | 49 | const renderTab = useMemo(() => { 50 | switch (currentTab) { 51 | case 'likes': 52 | return ( 53 | 63 | ); 64 | default: 65 | return null; 66 | } 67 | }, [ 68 | currentTab, 69 | uid, 70 | fetchUserLikes, 71 | changeRoute, 72 | playFullVideo, 73 | windowSize, 74 | toggleLike, 75 | isAuthenticated, 76 | likes 77 | ]); 78 | 79 | const handleChangeTab = tab => () => { 80 | setCurrentTab(tab); 81 | }; 82 | 83 | if (loadingUserProfile) { 84 | return ; 85 | } else if (!loadingUserProfile && !visitedUserProfile) { 86 | return ( 87 |
88 | User is not found 89 |
90 | ); 91 | } 92 | 93 | return ( 94 |
95 |
96 |
97 | {displayName} 98 |
102 | {!photoURL && displayName[0].toUpperCase()} 103 |
104 |
105 | {isCurrentUser && ( 106 | 111 | Settings 112 | 113 | )} 114 |
115 |
116 |
117 | {USER_TABS.map(({ key, label }) => ( 118 | 125 | {label} 126 | 127 | ))} 128 |
129 |
130 |
{renderTab}
131 |
132 | ); 133 | }; 134 | 135 | User.propTypes = propTypes; 136 | User.defaultProps = defaultProps; 137 | 138 | export default User; 139 | -------------------------------------------------------------------------------- /src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.0/8 are considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | export function register(config) { 24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 25 | // The URL constructor is available in all browsers that support SW. 26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 27 | if (publicUrl.origin !== window.location.origin) { 28 | // Our service worker won't work if PUBLIC_URL is on a different origin 29 | // from what our page is served on. This might happen if a CDN is used to 30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 31 | return; 32 | } 33 | 34 | window.addEventListener('load', () => { 35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 36 | 37 | if (isLocalhost) { 38 | // This is running on localhost. Let's check if a service worker still exists or not. 39 | checkValidServiceWorker(swUrl, config); 40 | 41 | // Add some additional logging to localhost, pointing developers to the 42 | // service worker/PWA documentation. 43 | navigator.serviceWorker.ready.then(() => { 44 | console.log( 45 | 'This web app is being served cache-first by a service ' + 46 | 'worker. To learn more, visit https://bit.ly/CRA-PWA' 47 | ); 48 | }); 49 | } else { 50 | // Is not localhost. Just register service worker 51 | registerValidSW(swUrl, config); 52 | } 53 | }); 54 | } 55 | } 56 | 57 | function registerValidSW(swUrl, config) { 58 | navigator.serviceWorker 59 | .register(swUrl) 60 | .then(registration => { 61 | registration.onupdatefound = () => { 62 | const installingWorker = registration.installing; 63 | if (installingWorker == null) { 64 | return; 65 | } 66 | installingWorker.onstatechange = () => { 67 | if (installingWorker.state === 'installed') { 68 | if (navigator.serviceWorker.controller) { 69 | // At this point, the updated precached content has been fetched, 70 | // but the previous service worker will still serve the older 71 | // content until all client tabs are closed. 72 | console.log( 73 | 'New content is available and will be used when all ' + 74 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' 75 | ); 76 | 77 | // Execute callback 78 | if (config && config.onUpdate) { 79 | config.onUpdate(registration); 80 | } 81 | } else { 82 | // At this point, everything has been precached. 83 | // It's the perfect time to display a 84 | // "Content is cached for offline use." message. 85 | console.log('Content is cached for offline use.'); 86 | 87 | // Execute callback 88 | if (config && config.onSuccess) { 89 | config.onSuccess(registration); 90 | } 91 | } 92 | } 93 | }; 94 | }; 95 | }) 96 | .catch(error => { 97 | console.error('Error during service worker registration:', error); 98 | }); 99 | } 100 | 101 | function checkValidServiceWorker(swUrl, config) { 102 | // Check if the service worker can be found. If it can't reload the page. 103 | fetch(swUrl, { 104 | headers: { 'Service-Worker': 'script' }, 105 | }) 106 | .then(response => { 107 | // Ensure service worker exists, and that we really are getting a JS file. 108 | const contentType = response.headers.get('content-type'); 109 | if ( 110 | response.status === 404 || 111 | (contentType != null && contentType.indexOf('javascript') === -1) 112 | ) { 113 | // No service worker found. Probably a different app. Reload the page. 114 | navigator.serviceWorker.ready.then(registration => { 115 | registration.unregister().then(() => { 116 | window.location.reload(); 117 | }); 118 | }); 119 | } else { 120 | // Service worker found. Proceed as normal. 121 | registerValidSW(swUrl, config); 122 | } 123 | }) 124 | .catch(() => { 125 | console.log( 126 | 'No internet connection found. App is running in offline mode.' 127 | ); 128 | }); 129 | } 130 | 131 | export function unregister() { 132 | if ('serviceWorker' in navigator) { 133 | navigator.serviceWorker.ready 134 | .then(registration => { 135 | registration.unregister(); 136 | }) 137 | .catch(error => { 138 | console.error(error.message); 139 | }); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/scss/components/_game.scss: -------------------------------------------------------------------------------- 1 | .game { 2 | width: 80%; 3 | margin: 0 auto; 4 | display: flex; 5 | flex-direction: column; 6 | justify-content: center; 7 | position: relative; 8 | 9 | @include mq($laptop) { 10 | width: 100%; 11 | } 12 | 13 | &__back-art { 14 | width: 100%; 15 | z-index: -2; 16 | background-size: cover; 17 | background-position: center center; 18 | overflow: hidden; 19 | height: 0; 20 | padding-top: 56.25%; 21 | } 22 | 23 | &__main { 24 | margin-bottom: 6rem; 25 | display: flex; 26 | position: relative; 27 | z-index: 1; 28 | 29 | @include mq(tablet) { 30 | flex-direction: column; 31 | justify-content: center; 32 | align-items: center; 33 | text-align: center; 34 | } 35 | } 36 | 37 | &__art { 38 | position: relative; 39 | z-index: 1; 40 | width: 40rem; 41 | height: 30rem; 42 | background-color: var(--color-grey-dark-1); 43 | background-size: cover; 44 | background-position: center center; 45 | 46 | @include mq($tablet) { 47 | overflow: hidden; 48 | height: 0; 49 | padding-top: 56.25%; 50 | } 51 | } 52 | 53 | &__content { 54 | flex: 1; 55 | margin: -1.4rem 0; 56 | margin-left: 3.5rem; 57 | 58 | @include mq(tablet) { 59 | order: 0; 60 | margin: 0; 61 | margin-top: 1rem; 62 | } 63 | } 64 | 65 | &__name { 66 | font-size: 3.4rem; 67 | margin-bottom: -1rem; 68 | 69 | @include mq(tablet) { 70 | font-size: 2.6rem; 71 | } 72 | } 73 | 74 | &__alternative-names { 75 | display: block; 76 | font-size: 1.6rem; 77 | font-style: italic; 78 | color: var(--color-grey-dark-3); 79 | margin-bottom: 1.4rem; 80 | } 81 | 82 | &__released, 83 | &__genres, 84 | &__homepage, 85 | &__description { 86 | color: #fff; 87 | font-size: 1.6rem; 88 | font-weight: 100; 89 | margin-top: -0.5rem; 90 | margin-bottom: 1.5rem; 91 | } 92 | 93 | &__genres { 94 | & span { 95 | font-weight: 100; 96 | display: inline-block; 97 | border-bottom: 1px solid #fff; 98 | cursor: pointer; 99 | } 100 | } 101 | 102 | &__genre, 103 | &__tag { 104 | color: #fff; 105 | } 106 | 107 | &__genre { 108 | border-bottom: 1px solid #fff; 109 | } 110 | 111 | &__homepage > a { 112 | color: #fff; 113 | text-decoration: none; 114 | 115 | &:hover { 116 | text-decoration: underline; 117 | } 118 | } 119 | 120 | &__meta { 121 | font-size: 2rem; 122 | display: inline-block; 123 | border: 1px solid currentColor; 124 | padding: 0.2rem 1.5rem; 125 | border-radius: 0.4rem; 126 | margin: 0 0 1rem 0; 127 | } 128 | 129 | &__trailer { 130 | font-size: 1.6rem; 131 | display: flex; 132 | justify-content: center; 133 | align-items: center; 134 | border: 1px solid #fff; 135 | padding: 0.4rem 0.8rem; 136 | margin-top: 1rem; 137 | border-radius: 0.4rem; 138 | transition: 0.2s; 139 | 140 | span { 141 | margin-left: 0.5rem; 142 | } 143 | 144 | &:hover { 145 | background-color: #fff; 146 | color: var(--color-dark); 147 | cursor: pointer; 148 | } 149 | } 150 | 151 | &__actions { 152 | list-style-type: none; 153 | display: flex; 154 | align-items: center; 155 | font-size: 1.6rem; 156 | margin-top: 1rem; 157 | 158 | @include mq(tablet) { 159 | justify-content: center; 160 | } 161 | } 162 | 163 | &__action { 164 | flex: 1; 165 | border: 1px solid #fff; 166 | border-radius: 0.4rem; 167 | display: flex; 168 | justify-content: center; 169 | align-items: center; 170 | transition: 0.2s; 171 | height: 3rem; 172 | 173 | span { 174 | margin-left: 0.6rem; 175 | } 176 | 177 | & + & { 178 | margin-left: 1rem; 179 | } 180 | 181 | &:hover { 182 | background-color: #fff; 183 | color: var(--color-dark); 184 | cursor: pointer; 185 | } 186 | 187 | &--liked { 188 | border: 1px solid var(--color-pink); 189 | background-color: var(--color-pink); 190 | color: #fff; 191 | } 192 | 193 | &--like { 194 | &:hover { 195 | border: 1px solid var(--color-pink); 196 | background-color: var(--color-pink); 197 | color: #fff; 198 | } 199 | } 200 | } 201 | 202 | &__images { 203 | display: grid; 204 | grid-template-columns: repeat(2, minmax(50px, 1fr)); 205 | column-gap: 1rem; 206 | row-gap: 1rem; 207 | 208 | @include mq($tablet) { 209 | grid-template-columns: repeat(1, minmax(50px, 1fr)); 210 | } 211 | } 212 | 213 | &__image { 214 | position: relative; 215 | z-index: 1; 216 | width: 100%; 217 | height: 25rem; 218 | 219 | @include mq($tablet) { 220 | height: 25rem; 221 | } 222 | 223 | @include mq($phablet) { 224 | height: 20rem; 225 | } 226 | } 227 | 228 | &__show-more { 229 | font-style: italic; 230 | color: var(--color-grey-dark-3); 231 | cursor: pointer; 232 | 233 | &:hover { 234 | text-decoration: underline; 235 | } 236 | } 237 | 238 | &__sub { 239 | display: flex; 240 | 241 | @include mq($tablet) { 242 | flex-direction: column; 243 | } 244 | } 245 | 246 | &__tags { 247 | display: flex; 248 | flex-direction: column; 249 | 250 | @include mq($tablet) { 251 | flex-direction: row; 252 | flex-wrap: wrap; 253 | } 254 | } 255 | 256 | &__tag { 257 | display: inline-block; 258 | font-weight: 100; 259 | color: #fff; 260 | cursor: pointer; 261 | 262 | @include mq($tablet) { 263 | border-bottom: 1px solid #fff; 264 | margin-right: 1rem; 265 | } 266 | } 267 | } 268 | 269 | .deep { 270 | position: fixed; 271 | top: 0; 272 | left: 0; 273 | width: 100%; 274 | height: 100vh; 275 | z-index: -2; 276 | } 277 | 278 | .left { 279 | flex: 0 0 80%; 280 | margin-right: 2rem; 281 | 282 | @include mq($tablet) { 283 | margin: 0; 284 | } 285 | } 286 | 287 | .right { 288 | margin-top: -0.5rem; 289 | 290 | @include mq($tablet) { 291 | margin: 2rem 0 3rem 0; 292 | } 293 | } 294 | -------------------------------------------------------------------------------- /src/actions/UserActions.js: -------------------------------------------------------------------------------- 1 | import firebase from '../firebase'; 2 | import { 3 | LOGIN_SUCCESS, 4 | CLEAR_USER, 5 | UPDATE_USER_USERNAME, 6 | FETCH_USER_LIKES_SUCCESS, 7 | TOGGLE_LIKE, 8 | FETCH_VISITED_USER_SUCCESS, 9 | FETCH_VISITED_USER_REQUEST, 10 | UPDATE_USER_PROFILE_SUCCESS 11 | } from '../constants/ActionTypes'; 12 | 13 | const loginSuccess = user => ({ type: LOGIN_SUCCESS, payload: { user } }); 14 | 15 | const clearUser = () => ({ type: CLEAR_USER }); 16 | 17 | const updateUsername = username => ({ 18 | type: UPDATE_USER_USERNAME, 19 | payload: { username } 20 | }); 21 | 22 | const fetchUserLikesSuccess = likes => ({ 23 | type: FETCH_USER_LIKES_SUCCESS, 24 | payload: { likes } 25 | }); 26 | 27 | const userToggleLike = (id, liked) => ({ 28 | type: TOGGLE_LIKE, 29 | payload: { id, liked } 30 | }); 31 | 32 | const fetchVisitedUserRequest = () => ({ type: FETCH_VISITED_USER_REQUEST }); 33 | 34 | const fetchVisitedUserSuccess = user => ({ 35 | type: FETCH_VISITED_USER_SUCCESS, 36 | payload: { user } 37 | }); 38 | 39 | const updateUserProfileSuccess = props => ({ 40 | type: UPDATE_USER_PROFILE_SUCCESS, 41 | payload: { props } 42 | }); 43 | 44 | export const login = async (email, password) => { 45 | return firebase.auth.signInWithEmailAndPassword(email, password); 46 | }; 47 | 48 | export const signUp = (email, username, password) => async dispatch => { 49 | //email existed 50 | const emailExisted = await firebase.auth.fetchSignInMethodsForEmail(email); 51 | if (emailExisted && emailExisted.length > 0) { 52 | return 'email-The email address is already in use by another account.'; 53 | } 54 | 55 | //username existed 56 | const usernameExisted = await firebase.db 57 | .collection('users') 58 | .doc(username) 59 | .get(); 60 | if (usernameExisted.data()) { 61 | return 'username-The username is already in use by another account.'; 62 | } 63 | 64 | //create user 65 | const res = await firebase.auth.createUserWithEmailAndPassword( 66 | email, 67 | password 68 | ); 69 | await res.user 70 | .updateProfile({ 71 | displayName: username 72 | }) 73 | .then(() => { 74 | dispatch(updateUsername(res.user.displayName)); 75 | firebase.db 76 | .collection('users') 77 | .doc(res.user.displayName) 78 | .set({ 79 | uid: res.user.uid, 80 | displayName: res.user.displayName, 81 | photoURL: null 82 | }) 83 | .catch(console.error); 84 | }); 85 | }; 86 | 87 | export const logOut = async () => { 88 | return firebase.auth.signOut(); 89 | }; 90 | 91 | //observe current user state change 92 | export const authen = () => dispatch => { 93 | return firebase.auth.onAuthStateChanged(user => { 94 | if (user) { 95 | const fetchedUser = { 96 | uid: user.uid, 97 | photoURL: user.photoURL, 98 | displayName: user.displayName 99 | }; 100 | dispatch(loginSuccess(fetchedUser)); 101 | fetchUserLikes(fetchedUser.uid) 102 | .then(res => dispatch(fetchUserLikesSuccess(res.data()))) 103 | .catch(console.error); 104 | } else { 105 | dispatch(clearUser()); 106 | } 107 | }); 108 | }; 109 | 110 | export const fetchUserLikes = uid => { 111 | return firebase.db.collection('likes').doc(uid).get(); 112 | }; 113 | 114 | export const toggleLike = (id, game, callback) => async dispatch => { 115 | const { currentUser } = firebase.auth; 116 | 117 | if (currentUser) { 118 | const { uid } = currentUser; 119 | const docRef = firebase.db.collection('likes').doc(uid); 120 | 121 | docRef 122 | .set({ [id]: game }, { merge: true }) 123 | .then(() => { 124 | dispatch(userToggleLike(id, game)); 125 | }) 126 | .catch(console.error) 127 | .finally(() => { 128 | callback(false); 129 | }); 130 | } 131 | }; 132 | 133 | export const fetchUser = username => async dispatch => { 134 | try { 135 | dispatch(fetchVisitedUserRequest()); 136 | const fetchedUser = await firebase.db 137 | .collection('users') 138 | .doc(username) 139 | .get(); 140 | dispatch(fetchVisitedUserSuccess(fetchedUser.data())); 141 | } catch (err) { 142 | console.error(err); 143 | } 144 | }; 145 | 146 | export const updateUser = user => async dispatch => { 147 | const { currentUser } = firebase.auth; 148 | 149 | if (!currentUser) { 150 | return; 151 | } 152 | 153 | const storageRef = firebase.storage.ref(); 154 | const { photo, username } = user; 155 | const { uid, displayName } = currentUser; 156 | let propsShouldUpdate = { displayName: username }; 157 | 158 | try { 159 | //username existed 160 | if (displayName !== username) { 161 | const usernameExisted = await firebase.db 162 | .collection('users') 163 | .doc(username) 164 | .get(); 165 | if (usernameExisted.data()) { 166 | return 'username-The username is already in use by another account.'; 167 | } 168 | } 169 | 170 | if (photo) { 171 | //upload image to storage 172 | await storageRef.child(uid).put(photo); 173 | //get image url 174 | const url = await storageRef.child(uid).getDownloadURL(); 175 | 176 | propsShouldUpdate = { ...propsShouldUpdate, photoURL: url }; 177 | } 178 | 179 | //update auth profile 180 | currentUser.updateProfile({ 181 | ...propsShouldUpdate 182 | }); 183 | 184 | const collectRef = firebase.db.collection('users').doc(displayName); 185 | if (displayName === username) { 186 | //update only 187 | collectRef.update({ 188 | ...propsShouldUpdate 189 | }); 190 | } else { 191 | //delete current user and add new user with new key 192 | const previousUser = await collectRef.get(); 193 | 194 | collectRef.delete(); 195 | 196 | firebase.db 197 | .collection('users') 198 | .doc(username) 199 | .set({ 200 | ...previousUser.data(), 201 | ...propsShouldUpdate 202 | }); 203 | } 204 | 205 | //update reducer 206 | dispatch(updateUserProfileSuccess(propsShouldUpdate)); 207 | } catch (err) { 208 | throw err; 209 | } 210 | }; 211 | 212 | export const changePassword = (oldPassword, newPassword) => async () => { 213 | const { currentUser } = firebase.auth; 214 | if (!currentUser) { 215 | return; 216 | } 217 | 218 | try { 219 | const { email } = currentUser; 220 | const credential = firebase.EmailAuthProvider.credential( 221 | email, 222 | oldPassword 223 | ); 224 | const res = await currentUser.reauthenticateWithCredential(credential); 225 | 226 | if (res) { 227 | await currentUser.updatePassword(newPassword); 228 | } 229 | } catch (err) { 230 | if (err.code === 'auth/wrong-password') { 231 | return 'oldPassword-Wrong password'; 232 | } 233 | return 'error-Something went wrong'; 234 | } 235 | }; 236 | -------------------------------------------------------------------------------- /src/components/Game.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import dayjs from 'dayjs'; 4 | import relativeTime from 'dayjs/plugin/relativeTime'; 5 | import localizedFormat from 'dayjs/plugin/localizedFormat'; 6 | import { setMetacriticColor } from '../utils/helpers'; 7 | import { 8 | IMAGE_URL, 9 | LARGE_IMAGE_URL, 10 | MEDIUM_IMAGE_URL, 11 | GAMES_PATH 12 | } from '../constants/urlApi'; 13 | import Loading from './Loading'; 14 | import { FaPlay } from 'react-icons/fa'; 15 | import CustomLink from './CustomLink'; 16 | import { 17 | AiTwotoneLike, 18 | AiOutlinePlusCircle, 19 | AiOutlineLoading 20 | } from 'react-icons/ai'; 21 | import { history } from '../utils/helpers'; 22 | import { preventClick } from '../utils/helpers'; 23 | 24 | dayjs.extend(localizedFormat); 25 | dayjs.extend(relativeTime); 26 | 27 | const propTypes = { 28 | fetchGameIfNeeded: PropTypes.func.isRequired, 29 | collectionKey: PropTypes.string, 30 | slug: PropTypes.string, 31 | game: PropTypes.object, 32 | screenshots: PropTypes.array, 33 | playFullVideo: PropTypes.func.isRequired, 34 | changeRoute: PropTypes.func.isRequired, 35 | isAuthenticated: PropTypes.bool.isRequired, 36 | likes: PropTypes.object, 37 | toggleLike: PropTypes.func.isRequired 38 | }; 39 | 40 | const defaultProps = { 41 | screenshots: [] 42 | }; 43 | 44 | const Game = ({ 45 | fetchGameIfNeeded, 46 | collectionKey, 47 | slug, 48 | game, 49 | screenshots, 50 | playFullVideo, 51 | changeRoute, 52 | isAuthenticated, 53 | likes, 54 | toggleLike 55 | }) => { 56 | const [loadingToggleLike, setLoadingToggleLike] = useState(false); 57 | const [collapsed, setCollapsed] = useState(true); 58 | 59 | useEffect(() => { 60 | fetchGameIfNeeded(slug, collectionKey); 61 | }, [fetchGameIfNeeded, slug, collectionKey]); 62 | 63 | if (!game) { 64 | return ; 65 | } 66 | 67 | const { 68 | id, 69 | backgroundImage, 70 | name, 71 | released, 72 | metacritic, 73 | genres, 74 | website, 75 | descriptionRaw, 76 | alternativeNames, 77 | clip, 78 | tags 79 | } = game; 80 | 81 | const largeBackground = 82 | backgroundImage && backgroundImage.replace(IMAGE_URL, LARGE_IMAGE_URL); 83 | const mediumBackground = 84 | backgroundImage && backgroundImage.replace(IMAGE_URL, MEDIUM_IMAGE_URL); 85 | const showCollapsed = descriptionRaw.length > 220 && collapsed; 86 | const collapsedDescription = showCollapsed 87 | ? `${descriptionRaw.substring(0, 220)}...` 88 | : descriptionRaw; 89 | const videoId = clip && clip.video; 90 | const releasedDate = `${dayjs(released).format('ll')} (${dayjs( 91 | released 92 | ).fromNow()})`; 93 | const liked = likes[id]; 94 | 95 | const handleCollapsed = () => { 96 | setCollapsed(prev => !prev); 97 | }; 98 | 99 | const handleToggleLike = () => { 100 | if (isAuthenticated) { 101 | setLoadingToggleLike(true); 102 | toggleLike(id, !liked ? game : null, setLoadingToggleLike); 103 | } else { 104 | changeRoute({ path: '/login', keys: {}, options: {} }); 105 | history.push('/login'); 106 | } 107 | }; 108 | 109 | return ( 110 |
111 |
112 |
121 |
122 |
123 |
124 |
128 | {videoId && ( 129 |
  • 133 | 134 | Play trailer 135 |
  • 136 | )} 137 |
      138 |
    • 144 | {loadingToggleLike ? ( 145 | 149 | ) : ( 150 | 151 | 152 | Like 153 | 154 | )} 155 |
    • 156 |
    • 157 | 158 | alert('Not supported yet!')}> 159 | Collection 160 | 161 |
    • 162 |
    163 |
    164 |
    165 |

    {name}

    166 | 167 | {alternativeNames.join(', ')} 168 | 169 |

    170 | Released Date

    {releasedDate}

    171 |

    172 | 173 | {metacritic || 0} 174 | 175 |

    176 | Genres 177 |
    178 | {genres.map((genre, index) => ( 179 | 180 | {index !== 0 && ', '} 181 | 188 | {genre.name} 189 | 190 | 191 | ))} 192 |
    193 |

    194 |

    195 | Homepage 196 |

    197 | 198 | {website} 199 | 200 |

    201 |

    202 |

    203 | Description{' '} 204 |

    205 | {collapsedDescription} 206 | {showCollapsed && ( 207 | 211 | read more 212 | 213 | )} 214 |

    215 |

    216 |
    217 |
    218 |
    219 |
    220 |
    221 | {screenshots.map(ss => ( 222 | {ss.id} 228 | ))} 229 |
    230 |
    231 |
    232 |

    233 | Tags 234 |
    235 | {tags.map(tag => ( 236 | 244 | {tag.name} 245 | 246 | ))} 247 |
    248 |

    249 |
    250 |
    251 |
    Power by RAWG
    252 |
    253 | ); 254 | }; 255 | 256 | Game.propTypes = propTypes; 257 | Game.defaultProps = defaultProps; 258 | 259 | export default Game; 260 | --------------------------------------------------------------------------------