├── 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 |
38 |
45 |
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
;
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 |
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 |
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 |
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 |
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 |
87 | )}
88 |
89 |
90 |
91 | {parentPlatforms.map(({ platform }) => {
92 | const Icon = platformIcon(platform.slug);
93 | return Icon ? (
94 |
99 | ) : null;
100 | })}
101 |
102 |
103 | {!!metacritic ? metacritic : 0}
104 |
105 |
106 |
107 |
115 | {name}
116 |
117 | {loadingToggleLike ? (
118 |
119 | ) : (
120 |
124 | )}
125 |
126 |
127 |
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 |
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 |

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 |
--------------------------------------------------------------------------------