├── .nvmrc ├── robots.txt ├── public ├── robots.txt ├── favicon.ico ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon-48x48.png ├── fonts │ └── font.woff ├── images │ ├── lazy.png │ ├── missing.jpg │ ├── missing-full.jpg │ ├── icons │ │ ├── icon-72x72.png │ │ ├── icon-96x96.png │ │ ├── icon-128x128.png │ │ ├── icon-144x144.png │ │ ├── icon-152x152.png │ │ ├── icon-192x192.png │ │ ├── icon-384x384.png │ │ ├── icon-512x512.png │ │ ├── mstile-150x150.png │ │ ├── apple-touch-icon.png │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-512x512.png │ │ └── safari-pinned-tab.svg │ ├── missing.ff25858a66cc8136c879b91f0dc57d17.jpg │ ├── search.svg │ ├── close.svg │ ├── dots.svg │ ├── marvel.svg │ └── github.svg ├── browserconfig.xml ├── app.1d43c609c894bd6b91dc.js.LICENSE.txt ├── manifest.webmanifest ├── sw.js ├── 577.1d43c609c894bd6b91dc.js ├── 788.1d43c609c894bd6b91dc.js ├── 76.1d43c609c894bd6b91dc.js ├── index.html └── 289.1d43c609c894bd6b91dc.js ├── .eslintignore ├── .env.example ├── src ├── favicon.ico ├── fonts │ └── font.woff ├── images │ ├── lazy.png │ ├── missing.jpg │ ├── missing-full.jpg │ ├── icons │ │ ├── icon-72x72.png │ │ ├── icon-96x96.png │ │ ├── icon-128x128.png │ │ ├── icon-144x144.png │ │ ├── icon-152x152.png │ │ ├── icon-192x192.png │ │ ├── icon-384x384.png │ │ ├── icon-512x512.png │ │ ├── mstile-150x150.png │ │ ├── apple-touch-icon.png │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-512x512.png │ │ └── safari-pinned-tab.svg │ ├── search.svg │ ├── close.svg │ ├── dots.svg │ ├── marvel.svg │ └── github.svg ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon-48x48.png ├── scripts │ ├── actions │ │ ├── search.js │ │ ├── filter.js │ │ ├── started.js │ │ ├── menuOpen.js │ │ ├── fetching.js │ │ ├── fetchingError.js │ │ ├── pagination.js │ │ ├── constants.js │ │ └── fetch.js │ ├── reducers │ │ ├── search.js │ │ ├── filter.js │ │ ├── started.js │ │ ├── menuOpen.js │ │ ├── fetchingError.js │ │ ├── fetching.js │ │ ├── pagination.js │ │ ├── root.js │ │ └── data.js │ ├── model │ │ ├── initialState.js │ │ ├── store.js │ │ ├── api.js │ │ └── paginationHelper.js │ ├── components │ │ ├── scroll-indicator.jsx │ │ ├── loader.jsx │ │ ├── error.jsx │ │ ├── explore.jsx │ │ ├── back-button.jsx │ │ ├── cover.jsx │ │ ├── close-icon.jsx │ │ ├── pagination-link.jsx │ │ ├── infos.jsx │ │ ├── grid-item.jsx │ │ ├── transition.jsx │ │ ├── menu.jsx │ │ ├── header.jsx │ │ ├── grid.jsx │ │ ├── search.jsx │ │ └── pagination.jsx │ ├── app.jsx │ ├── container │ │ ├── detailComicContainer.jsx │ │ ├── detailCharacterContainer.jsx │ │ └── homeContainer.jsx │ ├── pages │ │ ├── app.jsx │ │ ├── detailsCommon.js │ │ ├── about.jsx │ │ ├── home.jsx │ │ ├── detailCharacter.jsx │ │ └── detailComic.jsx │ └── misc │ │ └── tilt.js ├── scss │ ├── media-queries │ │ ├── tablet.scss │ │ ├── wide.scss │ │ └── desktop.scss │ ├── components │ │ ├── grid.scss │ │ ├── error.scss │ │ ├── offline-ready.scss │ │ ├── loading.scss │ │ ├── menu.scss │ │ ├── back-button.scss │ │ ├── filter.scss │ │ ├── search.scss │ │ ├── scroll-indicator.scss │ │ ├── header.scss │ │ ├── slides.scss │ │ ├── pagination.scss │ │ └── thumb.scss │ ├── base │ │ ├── easing.scss │ │ ├── color.scss │ │ └── reset.scss │ ├── app.scss │ └── pages │ │ ├── home.scss │ │ ├── about.scss │ │ └── detail.scss ├── browserconfig.xml ├── manifest.webmanifest └── index.html ├── api.keys.json ├── .gitignore ├── .github ├── dependabot.yml └── workflows │ ├── semgrep.yml │ ├── deploy.yml │ ├── build.yml │ └── codeql-analysis.yml ├── .stylelintrc ├── test ├── model │ ├── initialState.test.js │ ├── api.test.js │ └── paginationHelper.test.js └── misc │ └── tilt.test.js ├── .editorconfig ├── .codeclimate.yml ├── humans.txt ├── webpack.dev.js ├── .babelrc ├── webpack.prod.js ├── crossdomain.xml ├── .eslintrc.json ├── sass-lint.yml ├── workbox-config.js ├── LICENSE ├── 404.html ├── README.md ├── package.json └── webpack.common.js /.nvmrc: -------------------------------------------------------------------------------- 1 | 15.11 -------------------------------------------------------------------------------- /robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / 3 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / 3 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | *.json 2 | /public/ 3 | /coverage/ 4 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | PUBLIC_API_KEY=#### 2 | PUBLIC_HASH=#### 3 | NODE_ENV=production 4 | -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iondrimba/marvel-api-explorer/HEAD/src/favicon.ico -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iondrimba/marvel-api-explorer/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /src/fonts/font.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iondrimba/marvel-api-explorer/HEAD/src/fonts/font.woff -------------------------------------------------------------------------------- /src/images/lazy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iondrimba/marvel-api-explorer/HEAD/src/images/lazy.png -------------------------------------------------------------------------------- /src/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iondrimba/marvel-api-explorer/HEAD/src/favicon-16x16.png -------------------------------------------------------------------------------- /src/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iondrimba/marvel-api-explorer/HEAD/src/favicon-32x32.png -------------------------------------------------------------------------------- /src/favicon-48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iondrimba/marvel-api-explorer/HEAD/src/favicon-48x48.png -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iondrimba/marvel-api-explorer/HEAD/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iondrimba/marvel-api-explorer/HEAD/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicon-48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iondrimba/marvel-api-explorer/HEAD/public/favicon-48x48.png -------------------------------------------------------------------------------- /public/fonts/font.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iondrimba/marvel-api-explorer/HEAD/public/fonts/font.woff -------------------------------------------------------------------------------- /public/images/lazy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iondrimba/marvel-api-explorer/HEAD/public/images/lazy.png -------------------------------------------------------------------------------- /src/images/missing.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iondrimba/marvel-api-explorer/HEAD/src/images/missing.jpg -------------------------------------------------------------------------------- /public/images/missing.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iondrimba/marvel-api-explorer/HEAD/public/images/missing.jpg -------------------------------------------------------------------------------- /api.keys.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseUrl": "https://gateway.marvel.com", 3 | "version": "v1", 4 | "folder": "public" 5 | } 6 | -------------------------------------------------------------------------------- /src/images/missing-full.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iondrimba/marvel-api-explorer/HEAD/src/images/missing-full.jpg -------------------------------------------------------------------------------- /public/images/missing-full.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iondrimba/marvel-api-explorer/HEAD/public/images/missing-full.jpg -------------------------------------------------------------------------------- /src/images/icons/icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iondrimba/marvel-api-explorer/HEAD/src/images/icons/icon-72x72.png -------------------------------------------------------------------------------- /src/images/icons/icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iondrimba/marvel-api-explorer/HEAD/src/images/icons/icon-96x96.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | coverage/ 3 | nodesource_setup.sh 4 | secret.json 5 | out/chrome 6 | src/images/Thumbs.db 7 | .env 8 | -------------------------------------------------------------------------------- /public/images/icons/icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iondrimba/marvel-api-explorer/HEAD/public/images/icons/icon-72x72.png -------------------------------------------------------------------------------- /public/images/icons/icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iondrimba/marvel-api-explorer/HEAD/public/images/icons/icon-96x96.png -------------------------------------------------------------------------------- /src/images/icons/icon-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iondrimba/marvel-api-explorer/HEAD/src/images/icons/icon-128x128.png -------------------------------------------------------------------------------- /src/images/icons/icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iondrimba/marvel-api-explorer/HEAD/src/images/icons/icon-144x144.png -------------------------------------------------------------------------------- /src/images/icons/icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iondrimba/marvel-api-explorer/HEAD/src/images/icons/icon-152x152.png -------------------------------------------------------------------------------- /src/images/icons/icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iondrimba/marvel-api-explorer/HEAD/src/images/icons/icon-192x192.png -------------------------------------------------------------------------------- /src/images/icons/icon-384x384.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iondrimba/marvel-api-explorer/HEAD/src/images/icons/icon-384x384.png -------------------------------------------------------------------------------- /src/images/icons/icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iondrimba/marvel-api-explorer/HEAD/src/images/icons/icon-512x512.png -------------------------------------------------------------------------------- /public/images/icons/icon-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iondrimba/marvel-api-explorer/HEAD/public/images/icons/icon-128x128.png -------------------------------------------------------------------------------- /public/images/icons/icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iondrimba/marvel-api-explorer/HEAD/public/images/icons/icon-144x144.png -------------------------------------------------------------------------------- /public/images/icons/icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iondrimba/marvel-api-explorer/HEAD/public/images/icons/icon-152x152.png -------------------------------------------------------------------------------- /public/images/icons/icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iondrimba/marvel-api-explorer/HEAD/public/images/icons/icon-192x192.png -------------------------------------------------------------------------------- /public/images/icons/icon-384x384.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iondrimba/marvel-api-explorer/HEAD/public/images/icons/icon-384x384.png -------------------------------------------------------------------------------- /public/images/icons/icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iondrimba/marvel-api-explorer/HEAD/public/images/icons/icon-512x512.png -------------------------------------------------------------------------------- /src/images/icons/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iondrimba/marvel-api-explorer/HEAD/src/images/icons/mstile-150x150.png -------------------------------------------------------------------------------- /public/images/icons/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iondrimba/marvel-api-explorer/HEAD/public/images/icons/mstile-150x150.png -------------------------------------------------------------------------------- /src/images/icons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iondrimba/marvel-api-explorer/HEAD/src/images/icons/apple-touch-icon.png -------------------------------------------------------------------------------- /public/images/icons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iondrimba/marvel-api-explorer/HEAD/public/images/icons/apple-touch-icon.png -------------------------------------------------------------------------------- /src/scripts/actions/search.js: -------------------------------------------------------------------------------- 1 | export default function filter(text) { 2 | return { 3 | type: 'SEARCH', 4 | text 5 | }; 6 | } 7 | -------------------------------------------------------------------------------- /src/images/icons/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iondrimba/marvel-api-explorer/HEAD/src/images/icons/android-chrome-192x192.png -------------------------------------------------------------------------------- /src/images/icons/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iondrimba/marvel-api-explorer/HEAD/src/images/icons/android-chrome-512x512.png -------------------------------------------------------------------------------- /src/scripts/actions/filter.js: -------------------------------------------------------------------------------- 1 | export default function filter(filter) { 2 | return { 3 | type: 'FILTER', 4 | filter 5 | }; 6 | } 7 | -------------------------------------------------------------------------------- /src/scripts/actions/started.js: -------------------------------------------------------------------------------- 1 | export default function started(start) { 2 | return { 3 | type: 'STARTED', 4 | start 5 | }; 6 | } 7 | -------------------------------------------------------------------------------- /public/images/icons/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iondrimba/marvel-api-explorer/HEAD/public/images/icons/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/images/icons/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iondrimba/marvel-api-explorer/HEAD/public/images/icons/android-chrome-512x512.png -------------------------------------------------------------------------------- /src/scripts/actions/menuOpen.js: -------------------------------------------------------------------------------- 1 | export default function menuOpen(open) { 2 | return { 3 | type: 'MENU_TOOGLE', 4 | open 5 | }; 6 | } 7 | -------------------------------------------------------------------------------- /src/scripts/actions/fetching.js: -------------------------------------------------------------------------------- 1 | export default function fetching(fetching) { 2 | return { 3 | type: 'FETCHING', 4 | fetching 5 | }; 6 | } 7 | -------------------------------------------------------------------------------- /src/scss/media-queries/tablet.scss: -------------------------------------------------------------------------------- 1 | @media screen and (min-width: 650px ) { 2 | .detail { 3 | img { 4 | max-width: 55%; 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/scss/media-queries/wide.scss: -------------------------------------------------------------------------------- 1 | @media screen and (min-width: 1200px ) { 2 | .detail { 3 | img { 4 | max-width: 60vh; 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/scripts/actions/fetchingError.js: -------------------------------------------------------------------------------- 1 | export default function fetchingError(error) { 2 | return { 3 | type: 'FETCHING_ERROR', 4 | error 5 | }; 6 | } 7 | -------------------------------------------------------------------------------- /src/scripts/actions/pagination.js: -------------------------------------------------------------------------------- 1 | export default function pagination(pagination) { 2 | return { 3 | type: 'PAGINATION', 4 | pagination 5 | }; 6 | } 7 | -------------------------------------------------------------------------------- /public/images/missing.ff25858a66cc8136c879b91f0dc57d17.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iondrimba/marvel-api-explorer/HEAD/public/images/missing.ff25858a66cc8136c879b91f0dc57d17.jpg -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "09:00" 8 | open-pull-requests-limit: 1 9 | -------------------------------------------------------------------------------- /.stylelintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "stylelint-config-standard", 3 | "rules": { 4 | "indentation": 2, 5 | "no-missing-end-of-source-newline": true, 6 | "number-leading-zero":"never" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /test/model/initialState.test.js: -------------------------------------------------------------------------------- 1 | import initialState from '../../src/scripts/model/initialState'; 2 | 3 | describe('initialState', () => { 4 | it('initial state should be an object', () => { 5 | expect(typeof initialState).toBe('object'); 6 | }) 7 | }); 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | root = true 4 | 5 | [*.js] 6 | indent_style = space 7 | indent_size = 2 8 | end_of_line = lf 9 | charset = utf-8 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | -------------------------------------------------------------------------------- /src/scss/components/grid.scss: -------------------------------------------------------------------------------- 1 | @import '../base/color'; 2 | @import '../base/easing'; 3 | 4 | .grid { 5 | display: flex; 6 | flex-direction: row; 7 | flex-wrap: wrap; 8 | margin-bottom: 16px; 9 | margin-top: 50px; 10 | padding: 10px; 11 | } 12 | -------------------------------------------------------------------------------- /src/scripts/reducers/search.js: -------------------------------------------------------------------------------- 1 | import { SEARCH } from '../actions/constants'; 2 | 3 | function search(state = '', action) { 4 | switch (action.type) { 5 | case SEARCH: 6 | return action.text; 7 | } 8 | return state; 9 | } 10 | 11 | export default search; 12 | -------------------------------------------------------------------------------- /src/images/search.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/scripts/reducers/filter.js: -------------------------------------------------------------------------------- 1 | import { FILTER } from '../actions/constants'; 2 | 3 | function filter(state = '', action) { 4 | switch (action.type) { 5 | case FILTER: 6 | return action.filter; 7 | } 8 | return state; 9 | } 10 | 11 | export default filter; 12 | -------------------------------------------------------------------------------- /src/scss/base/easing.scss: -------------------------------------------------------------------------------- 1 | $easings: ( 2 | easeOutExpo: cubic-bezier(.19, 1, .22, 1), 3 | easeOutQuart: cubic-bezier(.165, .84, .44, 1), 4 | easeOutCirc: cubic-bezier(.075, .82, .165, 1) 5 | ); 6 | 7 | @function easing($name) { 8 | @return map-get($easings, $name); 9 | } 10 | -------------------------------------------------------------------------------- /public/images/search.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/scripts/reducers/started.js: -------------------------------------------------------------------------------- 1 | import { STARTED } from '../actions/constants'; 2 | 3 | function started(state = false, action) { 4 | switch (action.type) { 5 | case STARTED: 6 | return action.start; 7 | } 8 | return state; 9 | } 10 | 11 | export default started; 12 | -------------------------------------------------------------------------------- /src/images/close.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #da532c 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /public/images/close.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #da532c 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/scripts/reducers/menuOpen.js: -------------------------------------------------------------------------------- 1 | import { MENU_TOOGLE } from '../actions/constants'; 2 | 3 | function menuOpen(state = false, action) { 4 | switch (action.type) { 5 | case MENU_TOOGLE: 6 | return action.open; 7 | } 8 | return state; 9 | } 10 | 11 | export default menuOpen; 12 | -------------------------------------------------------------------------------- /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | ratings: 2 | paths: 3 | - "src/**/*.js" 4 | - "src/**/*.jsx" 5 | - "src/**/*.scss" 6 | exclude_paths: 7 | - "spec/**/*" 8 | - "public/**/*" 9 | - "coverage/**/*" 10 | - "karma.conf.js" 11 | - "test.webpack.config.js" 12 | - "webpack.config.js" 13 | - "workbox-config.js" 14 | -------------------------------------------------------------------------------- /src/scripts/reducers/fetchingError.js: -------------------------------------------------------------------------------- 1 | import { FETCHING_ERROR } from '../actions/constants'; 2 | 3 | function fetchingError(state = '', action) { 4 | switch (action.type) { 5 | case FETCHING_ERROR: 6 | return action.error; 7 | } 8 | return state; 9 | } 10 | 11 | export default fetchingError; 12 | -------------------------------------------------------------------------------- /src/scss/components/error.scss: -------------------------------------------------------------------------------- 1 | @import '../base/color'; 2 | 3 | .error { 4 | background-color: color(error); 5 | display: block; 6 | left: 50%; 7 | padding: 20px; 8 | position: absolute; 9 | text-align: center; 10 | top: 50%; 11 | transform: translate(-50%, -50%); 12 | width: 100%; 13 | } 14 | -------------------------------------------------------------------------------- /src/scripts/model/initialState.js: -------------------------------------------------------------------------------- 1 | const defaultStore = { 2 | fetching: false, 3 | pagination: { current: 1, total: 0, pages: [], next: false, prev: false }, 4 | filter: 'comics', 5 | search: '', 6 | started: false, 7 | error: { code: '' }, 8 | menuOpen: false, 9 | data: [] 10 | }; 11 | 12 | export default defaultStore; 13 | -------------------------------------------------------------------------------- /humans.txt: -------------------------------------------------------------------------------- 1 | # humanstxt.org/ 2 | # The humans responsible & technology colophon 3 | 4 | # TEAM 5 | 6 | Ion Drimba Filho Developer/Designer iondrimba@gmail.com 7 | 8 | # THANKS 9 | 10 | B2 (UX Tips) 11 | Bulldog (Interface Tips) 12 | 13 | # TECHNOLOGY COLOPHON 14 | 15 | CSS3, HTML5, JS, React, Redux, Service Worker 16 | ES6, Babel, Webpack 17 | -------------------------------------------------------------------------------- /webpack.dev.js: -------------------------------------------------------------------------------- 1 | const { merge } = require('webpack-merge'); 2 | const path = require('path'); 3 | const common = require('./webpack.common.js'); 4 | 5 | module.exports = merge(common, { 6 | mode: 'development', 7 | output: { 8 | publicPath: 'http://localhost:8080/', 9 | }, 10 | devServer: { 11 | contentBase: path.join(__dirname, 'public') 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | "@babel/react", 5 | ], 6 | "plugins": [ 7 | "@babel/plugin-syntax-dynamic-import" 8 | ], 9 | "sourceMap": "inline", 10 | "env": { 11 | "testing": { 12 | "sourceMap": "inline" 13 | }, 14 | "test": { 15 | "plugins": ["@babel/plugin-transform-runtime"] 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/scripts/actions/constants.js: -------------------------------------------------------------------------------- 1 | export const FETCHING = 'FETCHING'; 2 | export const FETCHING_ERROR = 'FETCHING_ERROR'; 3 | export const CHANGED_PAGE = 'CHANGED_PAGE'; 4 | export const FETCHED = 'FETCHED'; 5 | export const PAGINATION = 'PAGINATION'; 6 | export const FILTER = 'FILTER'; 7 | export const SEARCH = 'SEARCH'; 8 | export const STARTED = 'STARTED'; 9 | export const MENU_TOOGLE = 'MENU_TOOGLE'; 10 | -------------------------------------------------------------------------------- /src/scripts/reducers/fetching.js: -------------------------------------------------------------------------------- 1 | import { FETCHING, FETCHED, FETCHING_ERROR } from '../actions/constants'; 2 | 3 | function fetching(state = false, action) { 4 | switch (action.type) { 5 | case FETCHING: 6 | return true; 7 | case FETCHING_ERROR: 8 | return false; 9 | case FETCHED: 10 | return false; 11 | } 12 | return state; 13 | } 14 | 15 | export default fetching; 16 | -------------------------------------------------------------------------------- /src/scripts/components/scroll-indicator.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from 'react'; 2 | 3 | class ScrollIndicator extends PureComponent { 4 | render() { 5 | return ( 6 | 9 | ); 10 | } 11 | } 12 | 13 | export default ScrollIndicator; 14 | -------------------------------------------------------------------------------- /src/images/dots.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /public/images/dots.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/scripts/components/loader.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | class Loader extends PureComponent { 5 | render() { 6 | return ( 7 | this.props.fetching ?
loading data
: 8 | ); 9 | } 10 | } 11 | 12 | Loader.propTypes = { 13 | fetching: PropTypes.bool, 14 | } 15 | 16 | export default Loader; 17 | -------------------------------------------------------------------------------- /src/scripts/app.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Provider } from 'react-redux'; 3 | import { render } from 'react-dom'; 4 | import { Router } from 'react-router-dom'; 5 | import store from './model/store'; 6 | import { history } from './model/store'; 7 | import App from './pages/app'; 8 | 9 | render( 10 | 11 | 12 | 13 | 14 | , 15 | document.querySelector('.marvel-app') 16 | ); 17 | -------------------------------------------------------------------------------- /webpack.prod.js: -------------------------------------------------------------------------------- 1 | const { merge } = require('webpack-merge'); 2 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 3 | const { CleanWebpackPlugin } = require('clean-webpack-plugin'); 4 | const common = require('./webpack.common.js'); 5 | 6 | module.exports = merge(common, { 7 | mode: 'production', 8 | output: { 9 | publicPath: '/', 10 | }, 11 | plugins: [ 12 | new MiniCssExtractPlugin({ 13 | filename: './css/[name].[hash].css', 14 | }), 15 | new CleanWebpackPlugin(), 16 | ] 17 | }); 18 | -------------------------------------------------------------------------------- /src/scripts/components/error.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | class Error extends PureComponent { 5 | onRetry() { 6 | this.props.retry(); 7 | } 8 | render() { 9 | return ( 10 | this.props.error.code ?

Click to retry please :)

:
11 | ); 12 | } 13 | } 14 | Error.propTypes = { 15 | error: PropTypes.object, 16 | retry: PropTypes.func, 17 | } 18 | export default Error; 19 | -------------------------------------------------------------------------------- /src/scripts/components/explore.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | class Explore extends PureComponent { 5 | render() { 6 | return ( 7 | this.props.fetching || this.props.started ?
:
start exploring
8 | ); 9 | } 10 | } 11 | 12 | Explore.propTypes = { 13 | fetching: PropTypes.bool, 14 | started: PropTypes.bool, 15 | onClick: PropTypes.func 16 | } 17 | 18 | export default Explore; 19 | -------------------------------------------------------------------------------- /src/scss/components/offline-ready.scss: -------------------------------------------------------------------------------- 1 | @import '../base/color'; 2 | @import '../base/easing'; 3 | 4 | .alert-offline { 5 | background-color: color(gradient-primary); 6 | color: color(invert); 7 | display: block; 8 | font-weight: bold; 9 | padding: 20px; 10 | position: absolute; 11 | text-align: center; 12 | top: 0; 13 | transform: translateY(0) translateZ(0); 14 | transition: transform .3s easing(easeOutExpo); 15 | width: 100%; 16 | z-index: 1; 17 | 18 | &.show-offlline-ready { 19 | transform: translateY(50px) translateZ(0); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/scripts/components/back-button.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | class BackButton extends PureComponent { 5 | constructor(props) { 6 | super(props); 7 | } 8 | render() { 9 | return ( 10 | 13 | ); 14 | } 15 | } 16 | 17 | BackButton.propTypes = { 18 | onClick: PropTypes.func 19 | } 20 | 21 | export default BackButton; 22 | -------------------------------------------------------------------------------- /src/scripts/components/cover.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | class Cover extends PureComponent { 5 | render() { 6 | return ( 7 |
8 | 9 |
10 |
11 |
12 | ); 13 | } 14 | } 15 | 16 | Cover.propTypes = { 17 | selectedItem: PropTypes.object, 18 | } 19 | 20 | export default Cover; 21 | -------------------------------------------------------------------------------- /src/scss/components/loading.scss: -------------------------------------------------------------------------------- 1 | .loading { 2 | animation: yoyo .8s infinite; 3 | left: 50%; 4 | max-width: 100px; 5 | position: absolute; 6 | top: 50%; 7 | transform: translate(-50%, -50%) translate3d(0, 0, 0); 8 | transform-origin: top center; 9 | width: 20%; 10 | } 11 | 12 | @keyframes yoyo { 13 | from { 14 | transform: translate(-50%, -50%) translate3d(0, 0, 0); 15 | } 16 | 17 | 50% { 18 | transform: translate(-50%, -50%) scale(1.2) translate3d(0, 0, 0); 19 | } 20 | 21 | to { 22 | transform: translate(-50%, -50%) translate3d(0, 0, 0); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/images/marvel.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/scss/components/menu.scss: -------------------------------------------------------------------------------- 1 | 2 | @import '../base/color'; 3 | 4 | .menu { 5 | background-color: color(menu); 6 | color: color(invert); 7 | display: flex; 8 | position: relative; 9 | text-align: center; 10 | text-transform: lowercase; 11 | width: 180px; 12 | 13 | &.show { 14 | box-shadow: 6px -1px 20px 4px color(shadow); 15 | } 16 | 17 | button { 18 | align-self: center; 19 | background-color: inherit; 20 | border: 0; 21 | color: inherit; 22 | cursor: pointer; 23 | display: block; 24 | height: 100%; 25 | padding: 10px; 26 | width: 100%; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /public/images/marvel.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/images/github.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/images/github.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /.github/workflows/semgrep.yml: -------------------------------------------------------------------------------- 1 | on: 2 | workflow_dispatch: {} 3 | pull_request: {} 4 | push: 5 | branches: 6 | - main 7 | - master 8 | paths: 9 | - .github/workflows/semgrep.yml 10 | schedule: 11 | # random HH:MM to avoid a load spike on GitHub Actions at 00:00 12 | - cron: 53 3 * * * 13 | name: Semgrep 14 | jobs: 15 | semgrep: 16 | name: semgrep/ci 17 | runs-on: ubuntu-latest 18 | permissions: 19 | contents: read 20 | env: 21 | SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }} 22 | container: 23 | image: semgrep/semgrep 24 | steps: 25 | - uses: actions/checkout@v4 26 | - run: semgrep ci 27 | -------------------------------------------------------------------------------- /crossdomain.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 15 | 16 | -------------------------------------------------------------------------------- /src/scripts/components/close-icon.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | class CloseIcon extends PureComponent { 5 | render() { 6 | return ( 7 | 8 | 9 | 10 | 11 | 12 | ); 13 | } 14 | } 15 | 16 | CloseIcon.propTypes = { 17 | color: PropTypes.string 18 | } 19 | 20 | export default CloseIcon; 21 | -------------------------------------------------------------------------------- /src/scripts/container/detailComicContainer.jsx: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { withRouter } from 'react-router-dom' 3 | import DetailComic from '../pages/detailComic'; 4 | import { createSelector } from 'reselect'; 5 | 6 | const getItem = (state, props) => { 7 | return state.find((item, i) => item.id === Number(props.match.params.id)); 8 | } 9 | 10 | const getSelectedItem = createSelector( 11 | [getItem], 12 | (item, props) => item 13 | ) 14 | 15 | const mapStateToProps = (state, props) => { 16 | return { 17 | selectedItem: getSelectedItem(state.data, props) 18 | } 19 | } 20 | 21 | export default withRouter(connect(mapStateToProps, null)(DetailComic)); 22 | -------------------------------------------------------------------------------- /src/scripts/reducers/pagination.js: -------------------------------------------------------------------------------- 1 | import { PAGINATION } from '../actions/constants'; 2 | import { LOCATION_CHANGE } from 'react-router-redux'; 3 | 4 | function pagination(state = { current: 1, total: 0, pages: [], next: false, prev: false }, action) { 5 | switch (action.type) { 6 | case LOCATION_CHANGE: 7 | var page = action.payload.location.pathname.split('/')[2]; 8 | 9 | if (isNaN(page)) { 10 | return state; 11 | } 12 | return Object.assign({}, state, { current: Number(page) }); 13 | 14 | case PAGINATION: 15 | return Object.assign({}, state, action.pagination); 16 | } 17 | return state; 18 | } 19 | 20 | export default pagination; 21 | -------------------------------------------------------------------------------- /src/scripts/components/pagination-link.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | class PaginationLink extends PureComponent { 5 | render() { 6 | return ( 7 | 8 | {this.props.label} 9 | 10 | ); 11 | } 12 | } 13 | 14 | PaginationLink.propTypes = { 15 | className: PropTypes.string, 16 | iconClassName: PropTypes.string, 17 | href: PropTypes.string, 18 | label: PropTypes.string, 19 | onClick: PropTypes.func 20 | } 21 | 22 | export default PaginationLink; 23 | -------------------------------------------------------------------------------- /src/scripts/container/detailCharacterContainer.jsx: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { withRouter } from 'react-router-dom' 3 | import DetailCharacter from '../pages/detailCharacter'; 4 | import { createSelector } from 'reselect'; 5 | 6 | const getItem = (state, props) => { 7 | return state.find((item, i) => item.id === Number(props.match.params.id)); 8 | } 9 | 10 | const getSelectedItem = createSelector( 11 | [getItem], 12 | (item, props) => item 13 | ) 14 | 15 | const mapStateToProps = (state, props) => { 16 | return { 17 | selectedItem: getSelectedItem(state.data, props) 18 | } 19 | } 20 | 21 | export default withRouter(connect(mapStateToProps, null)(DetailCharacter)); 22 | -------------------------------------------------------------------------------- /src/scss/app.scss: -------------------------------------------------------------------------------- 1 | @import './base/reset'; 2 | @import './components/slides'; 3 | @import './components/filter'; 4 | @import './components/header'; 5 | @import './components/search'; 6 | @import './components/grid'; 7 | @import './components/back-button'; 8 | @import './components/scroll-indicator'; 9 | @import './components/thumb'; 10 | @import './components/loading'; 11 | @import './components/error'; 12 | @import './components/pagination'; 13 | @import './components/offline-ready'; 14 | @import './components/menu'; 15 | @import './pages/about'; 16 | @import './pages/detail'; 17 | @import './pages/home'; 18 | @import './media-queries/tablet'; 19 | @import './media-queries/desktop'; 20 | @import './media-queries/wide'; 21 | -------------------------------------------------------------------------------- /src/scripts/reducers/root.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import { connectRouter } from 'connected-react-router'; 3 | import data from './data'; 4 | import fetching from './fetching'; 5 | import fetchingError from './fetchingError'; 6 | import filter from './filter'; 7 | import search from './search'; 8 | import pagination from './pagination'; 9 | import menuOpen from './menuOpen'; 10 | import started from './started'; 11 | 12 | const RootReducer = (history) => combineReducers({ 13 | fetching, 14 | error: fetchingError, 15 | filter, 16 | started, 17 | search, 18 | pagination, 19 | router: connectRouter(history), 20 | menuOpen, 21 | data 22 | }); 23 | 24 | export default RootReducer; 25 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "globals": { 3 | "PRODUCTION": true 4 | }, 5 | "plugins": [ 6 | "react", 7 | "jest" 8 | ], 9 | "extends": [ 10 | "eslint:recommended", 11 | "plugin:react/recommended" 12 | ], 13 | "parserOptions": { 14 | "ecmaVersion": 7, 15 | "sourceType": "module", 16 | "ecmaFeatures": { 17 | "jsx": true 18 | } 19 | }, 20 | "parser": "babel-eslint", 21 | "env": { 22 | "es6": true, 23 | "browser": true, 24 | "node": true, 25 | "jest/globals": true 26 | }, 27 | "rules": { 28 | "eqeqeq": 1, 29 | "no-unused-vars": 0, 30 | "no-console": 0, 31 | "react/no-deprecated": 0, 32 | "curly": 1, 33 | "quotes": [ 34 | 1, 35 | "single" 36 | ] 37 | } 38 | } -------------------------------------------------------------------------------- /src/scripts/components/infos.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | class Infos extends PureComponent { 5 | render() { 6 | return ( 7 |
8 |

{this.props.title}

9 | 16 |
17 | ); 18 | } 19 | } 20 | 21 | Infos.propTypes = { 22 | type: PropTypes.string, 23 | title: PropTypes.string, 24 | data: PropTypes.array 25 | } 26 | 27 | export default Infos; 28 | -------------------------------------------------------------------------------- /src/scripts/model/store.js: -------------------------------------------------------------------------------- 1 | import { createBrowserHistory } from 'history'; 2 | import { routerMiddleware } from 'connected-react-router'; 3 | import { createStore, applyMiddleware, compose } from 'redux'; 4 | import thunk from 'redux-thunk'; 5 | import RootReducer from '../reducers/root'; 6 | import defaultStore from './initialState'; 7 | import Api from './api'; 8 | 9 | export const history = createBrowserHistory(); 10 | 11 | const api = new Api('13065ce22cdecaf8358b1b56dc54e2c7', 'bff1bb03adcde1c4dcb3417d64511e0b'); 12 | const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; 13 | 14 | export default createStore(RootReducer(history), defaultStore, 15 | composeEnhancers( 16 | applyMiddleware(routerMiddleware(history), thunk.withExtraArgument(api)) 17 | )); 18 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: deploy 5 | 6 | on: 7 | push: 8 | branches: [ main ] 9 | 10 | jobs: 11 | deploy: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | node-version: [15.11.0] 16 | 17 | steps: 18 | - name: SSH Deploy 19 | uses: garygrossgarten/github-action-ssh@release 20 | with: 21 | command: cd ${{ secrets.FOLDER }} && git pull origin main 22 | host: ${{ secrets.HOST }} 23 | username: ${{ secrets.USER_NAME }} 24 | privateKey: ${{ secrets.PRIVATE_KEY}} 25 | -------------------------------------------------------------------------------- /src/scss/base/color.scss: -------------------------------------------------------------------------------- 1 | $primary: #bd1023; 2 | $scondary: #4e4e4e; 3 | $colors: ( 4 | primary: $primary, 5 | primary-lighter: #ff0000, 6 | error: $primary, 7 | invert: #ffffff, 8 | secondary: #333333, 9 | darkest: #000000, 10 | secondary-lighter: $scondary, 11 | gradient-primary: #1f1f1f, 12 | gradient-secondary: #1b1a1a, 13 | filter: #5a5858, 14 | filter-primary: #444343, 15 | filter-secondary: $scondary, 16 | mask: $primary, 17 | header: $primary, 18 | menu: $scondary, 19 | pagination: $primary, 20 | pagination-selected: #191919, 21 | shadow: rgba(0, 0, 0, .75), 22 | info: $scondary, 23 | series: #edeeef, 24 | series-secondary: #dcdcdc, 25 | stories: #676767, 26 | stories-secondary: #565656, 27 | ); 28 | 29 | @function color($name) { 30 | @return map-get($colors, $name); 31 | } 32 | -------------------------------------------------------------------------------- /src/scss/components/back-button.scss: -------------------------------------------------------------------------------- 1 | @import '../base/color'; 2 | $back-button-size: 44px; 3 | $back-button-icon-size: 10px; 4 | 5 | .back-button { 6 | align-items: center; 7 | background-color: transparent; 8 | border: 0; 9 | cursor: pointer; 10 | display: flex; 11 | height: $back-button-size; 12 | justify-content: center; 13 | opacity: 1; 14 | position: fixed; 15 | width: $back-button-size; 16 | z-index: 2; 17 | 18 | &__icon { 19 | border-bottom: 0; 20 | border-left: 2px solid color(invert); 21 | border-right: 0; 22 | border-top: 2px solid color(invert); 23 | display: block; 24 | height: $back-button-icon-size; 25 | left: 40%; 26 | margin: 0; 27 | padding: 0; 28 | position: absolute; 29 | transform: rotate(-45deg); 30 | width: $back-button-icon-size; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/scripts/reducers/data.js: -------------------------------------------------------------------------------- 1 | import { FETCHED, FETCHING } from '../actions/constants'; 2 | 3 | function data(state = [], action) { 4 | switch (action.type) { 5 | case FETCHING: 6 | return []; 7 | case FETCHED: 8 | var filtered = [...action.data.data.data.results].map((item) => { 9 | var thumb = '/images/missing.jpg', 10 | full = '/images/missing-full.jpg'; 11 | 12 | if (!item.thumbnail.path.includes('image_not_available')) { 13 | thumb = `${item.thumbnail.path}/portrait_incredible.${item.thumbnail.extension}`; 14 | full = `${item.thumbnail.path}.${item.thumbnail.extension}`; 15 | } 16 | 17 | item.thumb = thumb; 18 | item.full = full; 19 | 20 | return item; 21 | }); 22 | 23 | return filtered; 24 | } 25 | return state; 26 | } 27 | 28 | export default data; 29 | -------------------------------------------------------------------------------- /src/scripts/components/grid-item.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from 'react'; 2 | import { Link } from 'react-router-dom' 3 | import PropTypes from 'prop-types'; 4 | 5 | class GridItem extends PureComponent { 6 | render() { 7 | return ( 8 | 9 |
10 | {`${this.props.id}-${this.props.title}`} 11 | {this.props.title} 12 | 13 | ); 14 | } 15 | } 16 | 17 | GridItem.propTypes = { 18 | id: PropTypes.number, 19 | thumb: PropTypes.string, 20 | title: PropTypes.string, 21 | filter: PropTypes.string 22 | } 23 | 24 | export default GridItem; 25 | -------------------------------------------------------------------------------- /src/scss/pages/home.scss: -------------------------------------------------------------------------------- 1 | @import '../base/color'; 2 | 3 | .home { 4 | background: linear-gradient(to bottom, color(gradient-primary), color(gradient-secondary)); 5 | min-height: 100vh; 6 | padding-bottom: 25px; 7 | 8 | form { 9 | button[type='submit'] { 10 | display: none; 11 | } 12 | } 13 | 14 | .btn-explore { 15 | position: absolute; 16 | display: block; 17 | width: auto; 18 | cursor: pointer; 19 | color: color(secondary); 20 | top: 50%; 21 | left: 50%; 22 | transform: translate(-50%, -50%); 23 | font-weight: bolder; 24 | background-color: color(invert); 25 | padding: 10px 20px; 26 | border-radius: 80px; 27 | text-align: center; 28 | text-transform: uppercase; 29 | box-shadow: -1px 1px 20px 13px rgba(0, 0, 0, 0.15); 30 | 31 | &:active { 32 | box-shadow: -1px 1px 20px 13px rgba(0, 0, 0, 0); 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/scripts/components/transition.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Transition = () => ( 4 |
5 | 6 | 7 | 8 | 9 |
10 |
11 |
12 | ); 13 | 14 | export default Transition; 15 | -------------------------------------------------------------------------------- /src/scss/components/filter.scss: -------------------------------------------------------------------------------- 1 | @import '../base/color'; 2 | 3 | .list { 4 | background-color: color(filter); 5 | border-radius: 0 0 4px 4px; 6 | color: color(invert); 7 | float: right; 8 | left: 0; 9 | position: absolute; 10 | text-transform: lowercase; 11 | top: 45px; 12 | width: 100%; 13 | 14 | &__item { 15 | cursor: pointer; 16 | padding: 5px 10px; 17 | 18 | span { 19 | display: block; 20 | width: 100%; 21 | } 22 | 23 | &:nth-child(odd) { 24 | background-color: color(filter-primary); 25 | } 26 | 27 | &:nth-child(even) { 28 | background-color: color(filter-secondary); 29 | } 30 | 31 | &:last-child { 32 | border-radius: 0 0 4px 4px; 33 | } 34 | } 35 | 36 | &.show { 37 | box-shadow: -1px 12px 20px 0 color(shadow); 38 | display: block; 39 | } 40 | 41 | &.hide { 42 | display: none; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/scss/base/reset.scss: -------------------------------------------------------------------------------- 1 | @import '../base/color'; 2 | 3 | @font-face { 4 | font-family: 'font'; 5 | src: url('../fonts/font.woff') format('woff'); 6 | font-style: normal; 7 | font-weight: normal; 8 | font-display: swap; 9 | } 10 | 11 | * { 12 | box-sizing: border-box; 13 | } 14 | 15 | input { 16 | font-family: inherit; 17 | } 18 | 19 | html { 20 | -webkit-tap-highlight-color: transparent; 21 | overflow-y: auto; 22 | touch-action: manipulation; 23 | } 24 | 25 | body, html { 26 | background: linear-gradient(to bottom, color(gradient-primary), color(gradient-secondary)); 27 | font-family: 'font', sans-serif; 28 | font-size: 16px; 29 | height: 100%; 30 | margin: 0; 31 | padding: 0; 32 | } 33 | 34 | h1, h2, h3, ul, button { 35 | font-size: inherit; 36 | margin: 0; 37 | padding: 0; 38 | } 39 | 40 | ul { 41 | list-style-type: none; 42 | margin: 0; 43 | padding: 0; 44 | padding-bottom: 20px; 45 | } 46 | -------------------------------------------------------------------------------- /src/scripts/actions/fetch.js: -------------------------------------------------------------------------------- 1 | import pagination from './pagination'; 2 | import fetchingError from './fetchingError'; 3 | 4 | export function fetched(data) { 5 | return { 6 | type: 'FETCHED', 7 | data 8 | }; 9 | } 10 | 11 | export function fetch(options) { 12 | return function (dispatch, getState, api) { 13 | return api.get(options).then((data) => { 14 | dispatch(fetched(data)); 15 | 16 | const { limit, total } = data.data.data; 17 | const pages = Math.round(total / limit); 18 | 19 | if (getState().pagination.total !== pages && options.page) { 20 | const current = options.page > pages ? 1 : options.page; 21 | const pg = Object.assign({}, getState().pagination, { current, total: pages }); 22 | 23 | dispatch(pagination(pg)); 24 | } 25 | 26 | }, (reject) => { 27 | dispatch(fetchingError(reject)); 28 | 29 | }).catch(function (reason) { 30 | dispatch(fetchingError(reason)); 31 | }) 32 | }; 33 | } 34 | -------------------------------------------------------------------------------- /sass-lint.yml: -------------------------------------------------------------------------------- 1 | ######################### 2 | ## Sample Sass Lint File 3 | ######################### 4 | # Linter Options 5 | options: 6 | # Don't merge default rules 7 | merge-default-rules: false 8 | # Set the formatter to 'html' 9 | 10 | max-warnings: 50 11 | # File Options 12 | files: 13 | include: '**/*.s+(a|c)ss' 14 | ignore: 15 | - 'vendor/**/*.*' 16 | # Rule Configuration 17 | rules: 18 | extends-before-mixins: 2 19 | extends-before-declarations: 2 20 | placeholder-in-extend: 2 21 | mixins-before-declarations: 22 | - 2 23 | - 24 | exclude: 25 | - breakpoint 26 | - mq 27 | 28 | no-warn: 1 29 | no-debug: 1 30 | no-ids: 2 31 | no-important: 2 32 | hex-notation: 33 | - 2 34 | - 35 | style: uppercase 36 | indentation: 37 | - 2 38 | - 39 | size: 2 40 | property-sort-order: 41 | - 1 42 | - 43 | order: 'alphabetical' 44 | ignore-custom-properties: true 45 | variable-for-property: 46 | - 2 47 | - 48 | properties: 49 | - margin 50 | - content -------------------------------------------------------------------------------- /src/scripts/pages/app.jsx: -------------------------------------------------------------------------------- 1 | import React, { lazy, Suspense } from 'react'; 2 | import { Route } from 'react-router-dom'; 3 | import HomeContainer from '../container/homeContainer'; 4 | import '../../scss/app.scss'; 5 | 6 | const DetailCharacterLazy = lazy(() => import('../container/detailCharacterContainer')); 7 | const DetailComicLazy = lazy(() => import('../container/detailComicContainer')); 8 | const AboutLazy = lazy(() => import('./about')); 9 | 10 | class App extends React.Component { 11 | render() { 12 | return ( 13 |
14 | loading...
}> 15 | 16 | } /> 17 | } /> 18 | } /> 19 | 20 | 21 | ); 22 | } 23 | } 24 | 25 | export default App; 26 | -------------------------------------------------------------------------------- /workbox-config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | swDest: 'public/sw.js', 3 | runtimeCaching: [{ 4 | // Match any same-origin request that contains 'api'. 5 | urlPattern: new RegExp(/https\:\/\/gateway\.marvel\.com\/v1\/public\/.+/g), 6 | handler: 'cacheFirst', 7 | options: { 8 | // Use a custom cache name for this route. 9 | cacheName: 'api-cache', 10 | // Configure custom cache expiration. 11 | expiration: { 12 | maxEntries: 20, 13 | maxAgeSeconds: 36000, 14 | }, 15 | // Configure which responses are considered cacheable. 16 | cacheableResponse: { 17 | statuses: [0, 200] 18 | }, 19 | }, 20 | }, 21 | { 22 | urlPattern: new RegExp(/https\:\/\/gateway\.marvel\.com\/v1\/public\/.+/g), 23 | handler: 'staleWhileRevalidate', 24 | options: { 25 | cacheableResponse: { 26 | statuses: [0, 200] 27 | } 28 | } 29 | }], 30 | clientsClaim: true, 31 | skipWaiting: true, 32 | globPatterns: ['**/*.{js,html,css,woff2,woff,svg}'], 33 | globFollow: false, 34 | globDirectory: './public/' 35 | } 36 | -------------------------------------------------------------------------------- /src/scss/pages/about.scss: -------------------------------------------------------------------------------- 1 | @import '../base/color'; 2 | @import '../base/easing'; 3 | 4 | .about { 5 | background-color: color(invert); 6 | color: color(darkest); 7 | height: 100vh; 8 | padding: 0 20px; 9 | position: fixed; 10 | top: 0; 11 | transform: translate3d(0, 0, 0) translateY(100%) translate3d(0, 0, 0); 12 | transition: transform .25s easing(easeOutCirc); 13 | width: 100vw; 14 | z-index: 12; 15 | 16 | .close { 17 | cursor: pointer; 18 | display: inline-block; 19 | margin: 20px 0; 20 | position: relative; 21 | } 22 | 23 | .content { 24 | display: block; 25 | left: 50%; 26 | position: absolute; 27 | top: 50%; 28 | transform: translate(-50%, -50%); 29 | width: 80%; 30 | } 31 | 32 | &.animate { 33 | transform: translateY(0) translate3d(0, 0, 0); 34 | } 35 | 36 | .link { 37 | color: color(darkest); 38 | font-weight: bold; 39 | } 40 | 41 | .github { 42 | display: block; 43 | text-align: center; 44 | 45 | svg { 46 | height: 30px; 47 | width: 30px; 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Ion Drimba F. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Page Not Found 6 | 7 | 52 | 53 | 54 |

Page Not Found

55 |

Sorry, but the page you were trying to view does not exist.

56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # :mortar_board: Marvel API Explorer 2 | 3 | ![build](https://github.com/iondrimba/marvel-api-explorer/workflows/build/badge.svg?branch=main) 4 | ![deploy](https://github.com/iondrimba/marvel-api-explorer/workflows/deploy/badge.svg?branch=main) 5 | [![Coverage Status](https://coveralls.io/repos/github/iondrimba/marvel-api-explorer/badge.svg)](https://coveralls.io/github/iondrimba/marvel-api-explorer) 6 | 7 | ![Demo](https://github.com/iondrimba/images/blob/master/marvel-demo.gif?raw=true) 8 | 9 | ## About 10 | 11 | This is a personal project built in my spare time for learning purposes. 12 | It uses the official [Marvel API](https://developer.marvel.com/docs) 13 | 14 | ## Features 15 | 16 | * PWA 17 | * Responsive 18 | * Offline ready 19 | * Installable (add to Homescreen ) 20 | * Swipe gestures for pagination 21 | * Pull to refresh keeps current pagination page 22 | 23 | ## Running 24 | 25 | ```bash 26 | npm start 27 | ``` 28 | 29 | ## Testing 30 | 31 | ```bash 32 | npm test 33 | ``` 34 | 35 | or 36 | 37 | ```bash 38 | npm run start:watch ## watch/tdd mode 39 | ``` 40 | 41 | ## Built with 42 | 43 | * ES6 44 | * Sass 45 | * React + Redux 46 | * Jest 47 | * VSCode 48 | * WebPack 49 | 50 | ## Todo 51 | 52 | * Write more tests(especially jsx components) 53 | * Refactor bits of code 54 | * Add more mobile friendly gestures 55 | -------------------------------------------------------------------------------- /src/scss/components/search.scss: -------------------------------------------------------------------------------- 1 | @import '../base/color'; 2 | @import '../base/easing'; 3 | 4 | .search { 5 | box-shadow: 0 2px 20px 0 color(shadow); 6 | position: fixed; 7 | top: 0; 8 | transform: translateY(0) translate3d(0, 0, 0); 9 | transition: transform .3s easing(easeOutExpo); 10 | width: 100%; 11 | z-index: 7; 12 | 13 | .search-wrapper { 14 | position: relative; 15 | width: 100%; 16 | } 17 | 18 | &.display { 19 | transform: translateY(100%) translate3d(0, 0, 0); 20 | transition: transform .3s easing(easeOutExpo); 21 | } 22 | 23 | form { 24 | display: flex; 25 | width: 100%; 26 | 27 | label { 28 | background-color: #757575; 29 | visibility: collapse; 30 | height: 0; 31 | width: 0; 32 | display: block; 33 | } 34 | } 35 | 36 | .close-icon { 37 | background-color: color(secondary); 38 | cursor: pointer; 39 | padding: 13px; 40 | position: absolute; 41 | right: 0; 42 | top: 0; 43 | transform: translateX(48px) translate3d(0, 0, 0); 44 | transition: transform .3s easing(easeOutExpo); 45 | width: 48px; 46 | 47 | &.show { 48 | transform: translateX(0) translate3d(0, 0, 0); 49 | } 50 | } 51 | 52 | input { 53 | border: 0; 54 | padding: 15px; 55 | width: 100%; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/scss/components/scroll-indicator.scss: -------------------------------------------------------------------------------- 1 | @import '../base/color'; 2 | @import '../base/easing'; 3 | $scroll-indicator-size: 24px; 4 | $scroll-indicator-icon-size: 10px; 5 | 6 | .scroll-indicator { 7 | align-items: center; 8 | background-color: transparent; 9 | border: 0; 10 | cursor: pointer; 11 | display: flex; 12 | height: $scroll-indicator-size; 13 | justify-content: center; 14 | left: 50%; 15 | opacity: 1; 16 | position: fixed; 17 | top: -30px; 18 | transform: translateX(-50%); 19 | width: $scroll-indicator-size; 20 | z-index: 2; 21 | 22 | &__icon { 23 | animation: yoyo-scroll .8s infinite; 24 | border-bottom: 0; 25 | border-left: 2px solid color(invert); 26 | border-right: 0; 27 | border-top: 2px solid color(invert); 28 | display: block; 29 | height: $scroll-indicator-icon-size; 30 | margin: 0; 31 | padding: 0; 32 | position: absolute; 33 | transform: translateY(0) translate3d(0, 0, 0) rotate(45deg); 34 | width: $scroll-indicator-icon-size; 35 | } 36 | } 37 | 38 | @keyframes yoyo-scroll { 39 | from { 40 | transform: translateY(0) translate3d(0, 0, 0) rotate(45deg); 41 | } 42 | 43 | 50% { 44 | transform: translateY(-50%) translate3d(0, 0, 0) rotate(45deg); 45 | } 46 | 47 | to { 48 | transform: translateY(0) translate3d(0, 0, 0) rotate(45deg); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/scripts/model/api.js: -------------------------------------------------------------------------------- 1 | import apiKeys from '../../../api.keys'; 2 | import axios from 'axios'; 3 | 4 | class Api { 5 | constructor(API_KEY, HASH) { 6 | this.publicKey = API_KEY; 7 | this.hash = HASH; 8 | this.timeout = 10000; 9 | this.options = { 10 | limit: 15, 11 | offset: 0 12 | }; 13 | 14 | this.instance = axios.create({ 15 | baseURL: `${apiKeys.baseUrl}/${apiKeys.version}/${apiKeys.folder}/`, 16 | timeout: this.timeout 17 | }); 18 | } 19 | 20 | appendParameters(url, options) { 21 | let { page, orderBy, titleStartsWith, nameStartsWith } = options; 22 | let fetchUrl = `${url}?ts=1&apikey=${this.publicKey}&hash=${this.hash}`; 23 | console.log(`Appending parameters to URL: ${fetchUrl}`); 24 | 25 | this.options.offset = 0; 26 | 27 | if (page > 0) { 28 | page--; 29 | this.options.offset = page * this.options.limit; 30 | } 31 | 32 | let mergedOptions = Object.assign({}, { orderBy }, this.options); 33 | 34 | mergedOptions = titleStartsWith ? Object.assign({}, { titleStartsWith }, mergedOptions) : mergedOptions; 35 | mergedOptions = nameStartsWith ? Object.assign({}, { nameStartsWith }, mergedOptions) : mergedOptions; 36 | 37 | for (let option in mergedOptions) { 38 | fetchUrl += `&${option}=${mergedOptions[option]}`; 39 | } 40 | 41 | return fetchUrl; 42 | } 43 | 44 | get(options) { 45 | return this.instance.get(this.appendParameters(options.url, options)); 46 | } 47 | } 48 | 49 | export default Api; 50 | -------------------------------------------------------------------------------- /src/scripts/pages/detailsCommon.js: -------------------------------------------------------------------------------- 1 | import Infos from '../components/infos' 2 | import React from 'react'; 3 | 4 | export const animateIn = (slides) => { 5 | slides.map((el, index) => { 6 | requestAnimationFrame(() => { 7 | requestAnimationFrame(() => { 8 | el.classList.add('active'); 9 | }); 10 | }); 11 | }); 12 | 13 | const loader = document.querySelector('.slides .loader'); 14 | 15 | setTimeout(() => { 16 | loader.classList.add('show'); 17 | }, 200); 18 | } 19 | 20 | export const enableScroll = () => { 21 | document.querySelector('html').classList.remove('disable-scroll'); 22 | } 23 | 24 | export const disableScroll = () => { 25 | document.querySelector('html').classList.add('disable-scroll'); 26 | } 27 | 28 | export const coverOnLoad = (content, img, slides, positionInfos, tilt) => { 29 | setTimeout(() => { 30 | const loader = document.querySelector('.slides .loader'); 31 | 32 | setTimeout(() => { 33 | positionInfos(); 34 | 35 | content.classList.add('active'); 36 | img.classList.add('show'); 37 | 38 | slides.reverse().map((el, index) => { 39 | loader.classList.remove('show'); 40 | el.classList.remove('active'); 41 | el.classList.add('out'); 42 | }); 43 | 44 | tilt.init(img); 45 | }, 800); 46 | }, 300); 47 | 48 | disableScroll(); 49 | } 50 | 51 | export const infoData = (data, hasItens, title)=> { 52 | return hasItens(data) ? : '' 53 | } 54 | -------------------------------------------------------------------------------- /src/scss/components/header.scss: -------------------------------------------------------------------------------- 1 | @import '../base/color'; 2 | 3 | .header { 4 | background-color: color(header); 5 | box-shadow: 0 2px 20px 0 rgba(0, 0, 0, .75); 6 | display: flex; 7 | flex-direction: row; 8 | height: 50px; 9 | justify-content: space-between; 10 | position: fixed; 11 | top: 0; 12 | width: 100%; 13 | z-index: 9; 14 | 15 | .logo { 16 | vertical-align: middle; 17 | width: 50px; 18 | } 19 | 20 | &__dots { 21 | background-color: transparent; 22 | border: 0; 23 | display: flex; 24 | justify-content: center; 25 | margin: 0; 26 | padding: 0; 27 | width: 50px; 28 | 29 | span { 30 | display: none; 31 | } 32 | } 33 | 34 | &__search { 35 | background-color: transparent; 36 | border: 0; 37 | margin: 0; 38 | padding: 0; 39 | position: relative; 40 | width: 50px; 41 | 42 | .close-icon { 43 | left: 20px; 44 | } 45 | 46 | .search-icon, .close-icon { 47 | cursor: pointer; 48 | height: 40px; 49 | left: 50%; 50 | opacity: 1; 51 | position: absolute; 52 | top: 50%; 53 | transform: translate(-50%, -50%) translate3d(0, 0, 0); 54 | transition: opacity .2s ; 55 | width: 25px; 56 | 57 | &.hide { 58 | opacity: 0; 59 | } 60 | } 61 | } 62 | 63 | h1 { 64 | align-self: center; 65 | position: relative; 66 | 67 | span { 68 | margin-left: 4px; 69 | position: relative; 70 | top: 3px; 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/scss/components/slides.scss: -------------------------------------------------------------------------------- 1 | @import '../base/color'; 2 | @import '../base/easing'; 3 | 4 | 5 | .slides { 6 | display: block; 7 | height: 100vh; 8 | pointer-events: none; 9 | position: fixed; 10 | width: 100vw; 11 | z-index: 9; 12 | 13 | .loader { 14 | animation: yoyo .8s infinite; 15 | opacity: 0; 16 | transition: opacity .3s; 17 | width: 35%; 18 | z-index: 1; 19 | 20 | &.show { 21 | opacity: 1; 22 | } 23 | } 24 | 25 | @keyframes yoyo { 26 | from { 27 | transform: translate(-50%, -50%) translate3d(0, 0, 0); 28 | } 29 | 30 | 50% { 31 | transform: translate(-50%, -50%) scale(1.2) translate3d(0, 0, 0); 32 | } 33 | 34 | to { 35 | transform: translate(-50%, -50%) translate3d(0, 0, 0); 36 | } 37 | } 38 | 39 | .first, .second { 40 | display: block; 41 | height: 200vh; 42 | position: absolute; 43 | transform: scaleY(0) translate3d(0, 0, 0); 44 | transform-origin: top; 45 | width: 200vh; 46 | 47 | &.active { 48 | transform: scaleY(1) translate3d(0, 0, 0); 49 | } 50 | 51 | &.out { 52 | transform: scaleY(0) translate3d(0, 0, 0); 53 | transform-origin: bottom; 54 | transition: transform 2s .5s easing(easeOutExpo); 55 | } 56 | } 57 | 58 | .first { 59 | background-color: color(primary); 60 | transition: transform 2s easing(easeOutExpo); 61 | } 62 | 63 | .second { 64 | background-color: color(primary); 65 | transition: transform 2s .5s easing(easeOutExpo); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/scss/components/pagination.scss: -------------------------------------------------------------------------------- 1 | @import '../base/color'; 2 | 3 | .pagination { 4 | background-color: color(pagination); 5 | bottom: 0; 6 | box-shadow: 0 -4px 20px 0 color(shadow); 7 | display: flex; 8 | left: 0; 9 | position: fixed; 10 | width: 100%; 11 | z-index: 2; 12 | 13 | &__content { 14 | display: flex; 15 | height: 50px; 16 | justify-content: center; 17 | width: 100%; 18 | } 19 | 20 | &__link { 21 | align-items: center; 22 | color: color(invert); 23 | cursor: pointer; 24 | display: flex; 25 | justify-content: center; 26 | padding: 10px 15px; 27 | position: relative; 28 | text-decoration: none; 29 | transition: background-color .2s; 30 | width: auto; 31 | z-index: 1; 32 | 33 | &--active { 34 | background-color: color(pagination-selected); 35 | box-shadow: 0 1px 15px 0 color(shadow); 36 | z-index: 2; 37 | } 38 | } 39 | 40 | &__prev, &__next { 41 | display: block; 42 | height: 10px; 43 | margin: 0; 44 | padding: 0; 45 | position: absolute; 46 | transform: rotate(-45deg); 47 | width: 10px; 48 | text-indent: -9999px; 49 | overflow: hidden; 50 | } 51 | 52 | &__prev { 53 | border-bottom: 0; 54 | border-left: 2px solid color(invert); 55 | border-right: 0; 56 | border-top: 2px solid color(invert); 57 | 58 | } 59 | 60 | &__next { 61 | border-bottom: 2px solid color(invert); 62 | border-left: 0; 63 | border-right: 2px solid color(invert); 64 | border-top: 0; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/scripts/components/menu.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | class Menu extends PureComponent { 5 | constructor(props) { 6 | super(props); 7 | this.state = { 8 | filter: this.props.filter 9 | }; 10 | } 11 | 12 | onClick(evt) { 13 | const filter = evt.currentTarget.innerText.toLowerCase(); 14 | this.setState({ filer: filter }); 15 | this.props.filterAction(filter, this.props); 16 | this.props.onClick(); 17 | this.props.toogleMenuAction(false); 18 | } 19 | onSelect(evt) { 20 | this.props.toogleMenuAction(!this.props.menuOpen); 21 | } 22 | 23 | toogleVisibility() { 24 | return this.props.menuOpen ? 'show' : 'hide'; 25 | } 26 | 27 | render() { 28 | return ( 29 | 36 | ); 37 | } 38 | } 39 | 40 | Menu.propTypes = { 41 | filter: PropTypes.string, 42 | toogleMenuAction: PropTypes.func, 43 | onClick: PropTypes.func, 44 | menuOpen: PropTypes.bool, 45 | filterAction: PropTypes.func 46 | } 47 | 48 | export default Menu; 49 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: build 5 | 6 | on: 7 | push: 8 | branches: [ main ] 9 | pull_request: 10 | types: [opened, synchronize, reopened, ready_for_review] 11 | branches: [ main ] 12 | 13 | jobs: 14 | build: 15 | name: build 16 | runs-on: ubuntu-latest 17 | strategy: 18 | matrix: 19 | node-version: [15.11.0] 20 | 21 | steps: 22 | - uses: actions/checkout@v4 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v4 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | env: 28 | CI: true 29 | PUBLIC_API_KEY: ${{ secrets.PUBLIC_API_KEY }} 30 | - run: npm ci 31 | - run: npm set apikey=$PUBLIC_API_KEY && npm run prod && npm run workbox 32 | 33 | test: 34 | name: test 35 | runs-on: ubuntu-latest 36 | strategy: 37 | matrix: 38 | node-version: [15.11.0] 39 | 40 | steps: 41 | - uses: actions/checkout@main 42 | - name: Use Node.js ${{ matrix.node-version }} 43 | uses: actions/setup-node@main 44 | with: 45 | node-version: ${{ matrix.node-version }} 46 | - run: npm ci 47 | - uses: paambaati/codeclimate-action@v2.6.0 48 | env: 49 | CI: true 50 | CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }} 51 | with: 52 | coverageCommand: npm test 53 | -------------------------------------------------------------------------------- /src/scripts/pages/about.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from 'react'; 2 | import CloseIcon from '../components/close-icon'; 3 | import PropTypes from 'prop-types'; 4 | 5 | class About extends PureComponent { 6 | componentDidMount() { 7 | setTimeout(() => { 8 | requestAnimationFrame(() => { 9 | this.about.classList.add('animate'); 10 | }) 11 | }, 50); 12 | } 13 | 14 | render() { 15 | return ( 16 |
this.about = c} className="about" > 17 | 18 | 19 | 20 |
21 |

About

22 |

23 | MARVEL API Explorer is a personal project made by Ion Drimba Filho using ReactJS + Redux and the oficial Marvel API.
24 | It also works as a PWA. 25 |

26 | 27 | Github icon 28 | 29 |
30 |
31 | ); 32 | } 33 | } 34 | 35 | About.propTypes = { 36 | 'history': PropTypes.object 37 | }; 38 | 39 | export default About; 40 | -------------------------------------------------------------------------------- /public/app.1d43c609c894bd6b91dc.js.LICENSE.txt: -------------------------------------------------------------------------------- 1 | /* 2 | object-assign 3 | (c) Sindre Sorhus 4 | @license MIT 5 | */ 6 | 7 | /*! Hammer.JS - v2.0.7 - 2016-04-22 8 | * http://hammerjs.github.io/ 9 | * 10 | * Copyright (c) 2016 Jorik Tangelder; 11 | * Licensed under the MIT license */ 12 | 13 | /** @license React v0.20.2 14 | * scheduler.production.min.js 15 | * 16 | * Copyright (c) Facebook, Inc. and its affiliates. 17 | * 18 | * This source code is licensed under the MIT license found in the 19 | * LICENSE file in the root directory of this source tree. 20 | */ 21 | 22 | /** @license React v16.13.1 23 | * react-is.production.min.js 24 | * 25 | * Copyright (c) Facebook, Inc. and its affiliates. 26 | * 27 | * This source code is licensed under the MIT license found in the 28 | * LICENSE file in the root directory of this source tree. 29 | */ 30 | 31 | /** @license React v17.0.2 32 | * react-dom.production.min.js 33 | * 34 | * Copyright (c) Facebook, Inc. and its affiliates. 35 | * 36 | * This source code is licensed under the MIT license found in the 37 | * LICENSE file in the root directory of this source tree. 38 | */ 39 | 40 | /** @license React v17.0.2 41 | * react-is.production.min.js 42 | * 43 | * Copyright (c) Facebook, Inc. and its affiliates. 44 | * 45 | * This source code is licensed under the MIT license found in the 46 | * LICENSE file in the root directory of this source tree. 47 | */ 48 | 49 | /** @license React v17.0.2 50 | * react.production.min.js 51 | * 52 | * Copyright (c) Facebook, Inc. and its affiliates. 53 | * 54 | * This source code is licensed under the MIT license found in the 55 | * LICENSE file in the root directory of this source tree. 56 | */ 57 | -------------------------------------------------------------------------------- /src/scripts/components/header.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from 'react'; 2 | import Search from './search'; 3 | import { Link } from 'react-router-dom'; 4 | import PropTypes from 'prop-types'; 5 | 6 | class Header extends PureComponent { 7 | constructor(props) { 8 | super(props); 9 | this.state = { 10 | displaySearch: false 11 | } 12 | } 13 | onSearchClick() { 14 | this.setState({ displaySearch: !this.state.displaySearch }); 15 | this.props.toogleMenuAction(false); 16 | } 17 | onSearch() { 18 | this.setState({ displaySearch: false }); 19 | } 20 | render() { 21 | return ( 22 |
23 |
24 | 25 | about 26 | About icon 27 | 28 |

Marvel logoAPI Explorer

29 | 33 |
34 | 35 |
36 | ); 37 | } 38 | } 39 | 40 | Header.propTypes = { 41 | toogleMenuAction: PropTypes.func 42 | } 43 | 44 | export default Header; 45 | -------------------------------------------------------------------------------- /public/manifest.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Marvel API", 3 | "short_name": "Marvel API", 4 | "theme_color": "#bd1023", 5 | "background_color": "#bd1023", 6 | "display": "standalone", 7 | "orientation": "portrait", 8 | "Scope": "/", 9 | "start_url": "/", 10 | "icons": [ 11 | { 12 | "src": "/images/icons/icon-72x72.png", 13 | "sizes": "72x72", 14 | "type": "image/png" 15 | }, 16 | { 17 | "src": "/images/icons/icon-96x96.png", 18 | "sizes": "96x96", 19 | "type": "image/png" 20 | }, 21 | { 22 | "src": "/images/icons/icon-128x128.png", 23 | "sizes": "128x128", 24 | "type": "image/png" 25 | }, 26 | { 27 | "src": "/images/icons/icon-144x144.png", 28 | "sizes": "144x144", 29 | "type": "image/png" 30 | }, 31 | { 32 | "src": "/images/icons/icon-152x152.png", 33 | "sizes": "152x152", 34 | "type": "image/png" 35 | }, 36 | { 37 | "src": "/images/icons/icon-192x192.png", 38 | "sizes": "192x192", 39 | "type": "image/png" 40 | }, 41 | { 42 | "src": "/images/icons/icon-384x384.png", 43 | "sizes": "384x384", 44 | "type": "image/png" 45 | }, 46 | { 47 | "src": "/images/icons/icon-512x512.png", 48 | "sizes": "512x512", 49 | "type": "image/png" 50 | }, 51 | { 52 | "src": "/images/android-chrome-192x192.png?v=PY47E7xJdP", 53 | "sizes": "192x192", 54 | "type": "image/png" 55 | }, 56 | { 57 | "src": "/images/android-chrome-512x512.png?v=PY47E7xJdP", 58 | "sizes": "512x512", 59 | "type": "image/png" 60 | } 61 | ], 62 | "splash_pages": null 63 | } 64 | -------------------------------------------------------------------------------- /src/manifest.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Marvel API", 3 | "short_name": "Marvel API", 4 | "theme_color": "#bd1023", 5 | "background_color": "#bd1023", 6 | "display": "standalone", 7 | "orientation": "portrait", 8 | "Scope": "/", 9 | "start_url": "/", 10 | "icons": [ 11 | { 12 | "src": "/images/icons/icon-72x72.png", 13 | "sizes": "72x72", 14 | "type": "image/png" 15 | }, 16 | { 17 | "src": "/images/icons/icon-96x96.png", 18 | "sizes": "96x96", 19 | "type": "image/png" 20 | }, 21 | { 22 | "src": "/images/icons/icon-128x128.png", 23 | "sizes": "128x128", 24 | "type": "image/png" 25 | }, 26 | { 27 | "src": "/images/icons/icon-144x144.png", 28 | "sizes": "144x144", 29 | "type": "image/png" 30 | }, 31 | { 32 | "src": "/images/icons/icon-152x152.png", 33 | "sizes": "152x152", 34 | "type": "image/png" 35 | }, 36 | { 37 | "src": "/images/icons/icon-192x192.png", 38 | "sizes": "192x192", 39 | "type": "image/png" 40 | }, 41 | { 42 | "src": "/images/icons/icon-384x384.png", 43 | "sizes": "384x384", 44 | "type": "image/png" 45 | }, 46 | { 47 | "src": "/images/icons/icon-512x512.png", 48 | "sizes": "512x512", 49 | "type": "image/png" 50 | }, 51 | { 52 | "src": "/images/android-chrome-192x192.png?v=PY47E7xJdP", 53 | "sizes": "192x192", 54 | "type": "image/png" 55 | }, 56 | { 57 | "src": "/images/android-chrome-512x512.png?v=PY47E7xJdP", 58 | "sizes": "512x512", 59 | "type": "image/png" 60 | } 61 | ], 62 | "splash_pages": null 63 | } 64 | -------------------------------------------------------------------------------- /src/scripts/misc/tilt.js: -------------------------------------------------------------------------------- 1 | class Tilt { 2 | constructor() { 3 | this.offset = 0.1; 4 | this.animation = 300; 5 | } 6 | 7 | init(el) { 8 | this.el = el; 9 | this.rect = this.el.getBoundingClientRect(); 10 | this.over = false; 11 | this.timer; 12 | this._addListeners(); 13 | } 14 | 15 | _addListeners() { 16 | this.el.addEventListener('mouseover', this._onMouseOver.bind(this)); 17 | this.el.addEventListener('mousemove', this._onMouseMove.bind(this)); 18 | this.el.addEventListener('mouseout', this._onMouseOut.bind(this)); 19 | } 20 | 21 | _onMouseOver(evt) { 22 | if (this.over) { 23 | this._position(evt, evt.currentTarget); 24 | } 25 | } 26 | 27 | _onMouseMove(evt) { 28 | this._position(evt, evt.currentTarget); 29 | this._transition(evt.currentTarget); 30 | } 31 | 32 | _onMouseOut(evt) { 33 | this._applyStyle(evt.currentTarget, { 34 | transform: 'rotateX(0deg) rotateY(0deg) translate(-50%, -50%)' 35 | }); 36 | this.over = false; 37 | } 38 | 39 | _convertToString(obj) { 40 | let output = ''; 41 | for (var prop in obj) { 42 | output += `${prop}:${obj[prop]};`; 43 | } 44 | return output; 45 | } 46 | 47 | _applyStyle(el, props) { 48 | el.style = this._convertToString(props); 49 | } 50 | 51 | _position(evt, el) { 52 | const rect = this.rect; 53 | const o = this.offset; 54 | 55 | const v = { 56 | x: -(evt.offsetX - (rect.width * .5)) * o, 57 | y: (evt.offsetY - (rect.height * .5)) * .03 58 | }; 59 | 60 | this._applyStyle(el, { transform: 'rotateX(' + v.y + 'deg) rotateY(' + v.x + 'deg) translate(-50%, -50%)' }); 61 | } 62 | 63 | _transition() { 64 | if (this.timer !== undefined) { 65 | clearTimeout(this.timer) 66 | } 67 | 68 | this.timer = setTimeout(() => { 69 | this.over = true; 70 | }, this.animation); 71 | } 72 | } 73 | 74 | export default Tilt; 75 | -------------------------------------------------------------------------------- /src/scripts/model/paginationHelper.js: -------------------------------------------------------------------------------- 1 | class PaginationHelper { 2 | constructor() { 3 | this.maxPages = 5; 4 | } 5 | 6 | mountGroups(totalItens) { 7 | let count = 0; 8 | let groups = []; 9 | let pages = 0; 10 | 11 | const total = this.getTotalPages(totalItens, this.maxPages); 12 | 13 | for (let i = 0; i < total; i++) { 14 | groups.push([]); 15 | 16 | pages = this.maxPages; 17 | 18 | if (totalItens < this.maxPages) { 19 | pages = totalItens; 20 | } else if (i === Math.floor(total)) { 21 | pages = totalItens % count; 22 | } 23 | 24 | for (let j = 0; j < pages; j++) { 25 | groups[i].push(count); 26 | count++; 27 | } 28 | } 29 | return groups; 30 | } 31 | 32 | getCurrentGroup(groups, currentPage) { 33 | return groups[currentPage] || []; 34 | } 35 | 36 | groupPages(currentPage = 1) { 37 | let start = Number(currentPage) / this.maxPages; 38 | return Math.floor(start); 39 | } 40 | 41 | getPages(pagination) { 42 | const pages = this.getCurrentGroup( 43 | this.mountGroups(pagination.total), 44 | this.groupPages(pagination.current - 1) 45 | ); 46 | 47 | return pages <= 1 ? [] : pages; 48 | } 49 | 50 | getTotalPages(totalItens, maxPages) { 51 | return totalItens / maxPages; 52 | } 53 | 54 | hasNext(pagination) { 55 | return ( 56 | pagination.total > 1 && 57 | (pagination.total > 1 && pagination.current < pagination.total) 58 | ); 59 | } 60 | 61 | hasPrev(pagination) { 62 | return pagination.total > 0 && pagination.current > 1; 63 | } 64 | 65 | getPrev(pagination) { 66 | if (this.hasPrev(pagination)) { 67 | return --pagination.current; 68 | } 69 | } 70 | 71 | getNext(pagination) { 72 | if (this.hasNext(pagination)) { 73 | return ++pagination.current; 74 | } 75 | } 76 | } 77 | 78 | export default PaginationHelper; 79 | -------------------------------------------------------------------------------- /src/scripts/components/grid.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from 'react'; 2 | import GridItem from './grid-item'; 3 | import PropTypes from 'prop-types'; 4 | 5 | class Grid extends PureComponent { 6 | componentDidUpdate(prevProps, prevState) { 7 | this.animate([...document.querySelectorAll('.grid .thumb')]); 8 | } 9 | 10 | animate(imgs) { 11 | imgs.map((el, index) => { 12 | setTimeout((elm) => { 13 | requestAnimationFrame(() => { 14 | elm.classList.remove('fetching'); 15 | elm.classList.add('fetched'); 16 | }); 17 | 18 | let image = new Image(); 19 | const file = elm.querySelector('.thumb__file'); 20 | const mask = elm.querySelector('.thumb__mask'); 21 | const title = elm.querySelector('.thumb__title'); 22 | 23 | image.onload = function () { 24 | mask.classList.add('animate'); 25 | file.attributes.src.value = ''; 26 | file.attributes.src.value = image.src; 27 | setTimeout((el) => { 28 | requestAnimationFrame(() => { 29 | file.classList.add('animate'); 30 | mask.classList.add('animate-out'); 31 | 32 | if (image.src.indexOf('missing') > -1) { 33 | title.classList.add('show'); 34 | } 35 | 36 | }); 37 | }, 300, elm); 38 | }; 39 | image.src = file.attributes['data-src'].value; 40 | }, index * 50, el); 41 | }); 42 | } 43 | 44 | render() { 45 | return ( 46 |
47 | { 48 | this.props.data.map((data, index) => { 49 | return 50 | }) 51 | } 52 |
53 | ); 54 | } 55 | } 56 | 57 | Grid.propTypes = { 58 | data: PropTypes.array, 59 | filter: PropTypes.string 60 | } 61 | 62 | export default Grid; 63 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [main] 9 | schedule: 10 | - cron: '0 3 * * 0' 11 | 12 | jobs: 13 | analyze: 14 | name: Analyze 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | # Override automatic language detection by changing the below list 21 | # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] 22 | language: ['javascript'] 23 | # Learn more... 24 | # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection 25 | 26 | steps: 27 | - name: Checkout repository 28 | uses: actions/checkout@v2 29 | with: 30 | # We must fetch at least the immediate parents so that if this is 31 | # a pull request then we can checkout the head. 32 | fetch-depth: 2 33 | 34 | # If this run was triggered by a pull request event, then checkout 35 | # the head of the pull request instead of the merge commit. 36 | - run: git checkout HEAD^2 37 | if: ${{ github.event_name == 'pull_request' }} 38 | 39 | # Initializes the CodeQL tools for scanning. 40 | - name: Initialize CodeQL 41 | uses: github/codeql-action/init@v2 42 | with: 43 | languages: ${{ matrix.language }} 44 | 45 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 46 | # If this step fails, then you should remove it and run the build manually (see below) 47 | - name: Autobuild 48 | uses: github/codeql-action/autobuild@v2 49 | 50 | # ℹ️ Command-line programs to run using the OS shell. 51 | # 📚 https://git.io/JvXDl 52 | 53 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 54 | # and modify them (or add more) to build your code if your project 55 | # uses a compiled language 56 | 57 | #- run: | 58 | # make bootstrap 59 | # make release 60 | 61 | - name: Perform CodeQL Analysis 62 | uses: github/codeql-action/analyze@v2 63 | -------------------------------------------------------------------------------- /src/scss/components/thumb.scss: -------------------------------------------------------------------------------- 1 | .thumb { 2 | background-image: url('../images/missing.jpg'); 3 | background-size: cover; 4 | border-radius: 4px; 5 | margin-bottom: 2.6vw; 6 | margin-right: 2.6vw; 7 | opacity: 0; 8 | overflow: hidden; 9 | position: relative; 10 | transform: translateY(100px) ; 11 | transition: opacity .2s, transform .3s easing(easeOutExpo); 12 | width: 31.45%; 13 | 14 | &__file { 15 | float: left; 16 | opacity: 0; 17 | width: 100%; 18 | 19 | &.animate { 20 | opacity: 1; 21 | } 22 | } 23 | 24 | &__title { 25 | align-items: center; 26 | background-color: #171515; 27 | color: color(invert); 28 | display: inline-flex; 29 | font-size: 14px; 30 | font-weight: bold; 31 | justify-content: center; 32 | left: 0; 33 | min-height: 80px; 34 | opacity: 0; 35 | padding: 10px; 36 | position: absolute; 37 | text-align: center; 38 | text-decoration: none; 39 | top: 50%; 40 | transform: translateY(-50%); 41 | width: 100%; 42 | z-index: 1; 43 | 44 | &.show { 45 | opacity: 1; 46 | transition: opacity .2s .6s; 47 | } 48 | } 49 | 50 | &__mask { 51 | background-color: color(mask); 52 | display: block; 53 | height: 100%; 54 | position: absolute; 55 | transform: scaleY(0) translate3d(0, 0, 0); 56 | transform-origin: top; 57 | transition: transform .3s .1s easing(easeOutExpo); 58 | width: 100%; 59 | z-index: 1; 60 | 61 | &.animate { 62 | transform: scaleY(1) translate3d(0, 0, 0); 63 | } 64 | 65 | &.animate-out { 66 | transform: scaleY(0) translate3d(0, 0, 0); 67 | transform-origin: bottom; 68 | } 69 | } 70 | 71 | &.fetching { 72 | opacity: 0; 73 | transform: translateY(100px); 74 | } 75 | 76 | &.fetched { 77 | opacity: 1; 78 | transform: translateY(0); 79 | } 80 | 81 | &::before { 82 | background-image: linear-gradient(160deg, rgba(255, 255, 255, .15) 50%, transparent 50%); 83 | content: ''; 84 | height: 100%; 85 | left: 0; 86 | position: absolute; 87 | width: 100%; 88 | z-index: 2; 89 | } 90 | 91 | &:nth-child(3n + 0) { 92 | margin-right: 0; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/scripts/components/search.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from 'react'; 2 | import Menu from './menu'; 3 | import PropTypes from 'prop-types'; 4 | 5 | class Search extends PureComponent { 6 | constructor(props) { 7 | super(props); 8 | this.state = { 9 | search: props.search 10 | } 11 | } 12 | 13 | componentDidMount() { 14 | this.form.onsubmit = (evt) => { 15 | evt.preventDefault(); 16 | this.props.searchAction(this.state.search, this.props); 17 | this.search.classList.add('hide'); 18 | this.props.onSearch(); 19 | this.form.blur(); 20 | this.searchInput.blur(); 21 | } 22 | } 23 | 24 | componentDidUpdate() { 25 | if (this.props.display) { 26 | this.searchInput.focus(); 27 | } 28 | } 29 | 30 | onTextChange(evt) { 31 | const search = evt.currentTarget.value; 32 | this.setState({ search: search }); 33 | } 34 | 35 | onClick() { 36 | this.props.onSearch(); 37 | } 38 | 39 | onSearchClear() { 40 | this.props.searchClear(''); 41 | this.setState({ search: this.props.search }); 42 | } 43 | 44 | render() { 45 | return ( 46 |
this.search = c} className={this.props.display ? 'search display' : 'search'}> 47 |
this.form = c} action="/" method="get"> 48 |
49 | this.searchInput = c} id="search" type="text" value={this.state.search} name="search" placeholder="name/title starts with...." onChange={this.onTextChange.bind(this)} /> 50 | 51 | 52 | Clear search icon 53 |
54 | this.menu = c} {...this.props} onClick={this.onClick.bind(this)} /> 55 | 56 |
57 | ); 58 | } 59 | } 60 | 61 | Search.propTypes = { 62 | searchAction: PropTypes.func, 63 | searchClear: PropTypes.func, 64 | onSearch: PropTypes.func, 65 | search: PropTypes.string, 66 | display: PropTypes.bool 67 | } 68 | 69 | export default Search; 70 | -------------------------------------------------------------------------------- /src/scripts/components/pagination.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from 'react'; 2 | import PaginationLink from './pagination-link'; 3 | import PropTypes from 'prop-types'; 4 | 5 | class Pagination extends PureComponent { 6 | getQueryString(search) { 7 | return search ? `?search=${search}` : ''; 8 | } 9 | 10 | onClick(evt) { 11 | evt.preventDefault(); 12 | this.props.paginationAction(evt.currentTarget.attributes.href.value, this.props); 13 | } 14 | 15 | getUrl(page) { 16 | return `/${this.props.filter}/${page}${this.getQueryString(this.props.search)}`; 17 | } 18 | 19 | getStyle(props, page) { 20 | return Number(props.pagination.current) === page ? 'pagination__link pagination__link--active' : 'pagination__link'; 21 | } 22 | 23 | previous() { 24 | this.props.paginationPrevAction(this.props); 25 | } 26 | 27 | next() { 28 | this.props.paginationNextAction(this.props); 29 | } 30 | 31 | _paginationLink(delta, label, key) { 32 | return ; 39 | } 40 | 41 | render() { 42 | return ( 43 |
44 |
45 | {this.props.pagination.prev ? this._paginationLink(-1, 'previous', 'prev') : null} 46 | { 47 | this.props.pagination.pages.map((data, index) => { 48 | return 49 | }) 50 | } 51 | {this.props.pagination.next ? this._paginationLink(+1, 'next', 'next') : null} 52 |
53 |
54 | ); 55 | } 56 | } 57 | 58 | Pagination.propTypes = { 59 | filter: PropTypes.string, 60 | pagination: PropTypes.object, 61 | search: PropTypes.string, 62 | paginationAction: PropTypes.func, 63 | paginationNextAction: PropTypes.func, 64 | paginationPrevAction: PropTypes.func 65 | } 66 | 67 | export default Pagination; 68 | -------------------------------------------------------------------------------- /src/scripts/pages/home.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import Loader from '../components/loader'; 4 | import Grid from '../components/grid'; 5 | import Pagination from '../components/pagination'; 6 | import Error from '../components/error'; 7 | import Header from '../components/header'; 8 | import Hammer from 'hammerjs'; 9 | import Explore from '../components/explore'; 10 | 11 | class Home extends Component { 12 | componentDidMount() { 13 | const stage = document.getElementsByClassName('grid')[0]; 14 | const mc = new Hammer.Manager(stage, { 15 | touchAction: 'pan-y' 16 | }); 17 | 18 | const Swipe = new Hammer.Swipe(); 19 | 20 | mc.add(Swipe); 21 | mc.on('swiperight', (e) => { 22 | this.pagination.previous(); 23 | }); 24 | 25 | mc.on('swipeleft', (e) => { 26 | this.pagination.next(); 27 | }); 28 | } 29 | 30 | onExploreClick() { 31 | this.props.firstFetch(this.props); 32 | } 33 | 34 | shouldComponentUpdate(nextProps, nextState) { 35 | return nextProps.location.pathname.indexOf('detail') === -1; 36 | } 37 | 38 | componentDidUpdate(prevProps, prevState) { 39 | window.scroll(0, 0); 40 | 41 | if ((prevProps.match.params.page !== this.props.match.params.page) && !isNaN(this.props.match.params.page) && !isNaN(prevProps.match.params.page)) { 42 | this.props.fetchAction(Number(this.props.match.params.page)); 43 | } 44 | } 45 | 46 | render() { 47 | const { data, filter } = this.props; 48 | 49 | return ( 50 |
51 | 52 |
53 | 54 | 55 | 56 | this.pagination = c} {...this.props} /> 57 |
58 | ); 59 | } 60 | } 61 | 62 | Home.propTypes = { 63 | match: PropTypes.object, 64 | error: PropTypes.object, 65 | location: PropTypes.object, 66 | data: PropTypes.array, 67 | pagination: PropTypes.object, 68 | fetchAction: PropTypes.func, 69 | searchClear: PropTypes.func, 70 | searchAction: PropTypes.func, 71 | firstFetch: PropTypes.func, 72 | filter: PropTypes.string, 73 | fetching: PropTypes.bool 74 | } 75 | 76 | export default Home; 77 | -------------------------------------------------------------------------------- /src/scripts/pages/detailCharacter.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import Transition from '../components/transition'; 4 | import BackButton from '../components/back-button'; 5 | import Cover from '../components/cover'; 6 | import ScrollIndicator from '../components/scroll-indicator'; 7 | import Tilt from '../misc/tilt'; 8 | import { animateIn, enableScroll, coverOnLoad, infoData } from './detailsCommon'; 9 | 10 | class DetailCharacter extends PureComponent { 11 | constructor(props) { 12 | super(props); 13 | } 14 | 15 | positionInfos() { 16 | let viewH = 0; 17 | if (window.innerWidth < 960) { 18 | viewH = innerHeight - 50; 19 | } 20 | 21 | this.infos.style = `transform:translateY(${viewH}px)`; 22 | } 23 | 24 | componentDidMount() { 25 | this.tilt = new Tilt(); 26 | window.onresize = () => { this.positionInfos(); }; 27 | 28 | const content = document.querySelector('.detail__content'); 29 | const img = content.querySelector('img'); 30 | img.onload = () => { coverOnLoad(content, img, slides, this.positionInfos.bind(this), this.tilt); }; 31 | 32 | let slides = [...document.querySelectorAll('.slides .first')]; 33 | this.animateIn(slides); 34 | } 35 | 36 | componentWillUnmount() { 37 | window.onresize = null; 38 | enableScroll(); 39 | } 40 | 41 | onBackButtonClick() { 42 | this.props.history.goBack(); 43 | } 44 | 45 | animateIn(slides) { 46 | animateIn(slides); 47 | } 48 | 49 | hasItens(data) { 50 | return data.items && data.items.length; 51 | } 52 | 53 | render() { 54 | return ( 55 |
56 | 57 |
this.content = c} className="detail__content"> 58 | 59 | 60 |
this.infos = c} className="detail__infos"> 61 | 62 |
this.infoName = c} className="info__name"> 63 |

{this.props.selectedItem.name}

64 |
65 | {infoData(this.props.selectedItem.stories, this.hasItens, 'Stories')} 66 | {infoData(this.props.selectedItem.series, this.hasItens, 'Series')} 67 |
68 |
69 |
70 | ); 71 | } 72 | } 73 | 74 | DetailCharacter.propTypes = { 75 | selectedItem: PropTypes.object, 76 | history: PropTypes.object 77 | } 78 | 79 | export default DetailCharacter; 80 | -------------------------------------------------------------------------------- /test/model/api.test.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import Api from '../../src/scripts/model/api'; 3 | 4 | jest.mock('axios'); 5 | 6 | axios.create.mockImplementationOnce(() => axios); 7 | 8 | describe('API tests', () => { 9 | 10 | const PUBLIC_API_KEY = process.env.PUBLIC_API_KEY || '######'; 11 | const api = new Api(PUBLIC_API_KEY, '######'); 12 | 13 | it('Api.getCharacters - should retrive character requests', async () => { 14 | const data = { 15 | response: { 16 | data: { 17 | code: 200 18 | } 19 | } 20 | }; 21 | 22 | axios.get.mockImplementationOnce(() => Promise.resolve(data)); 23 | 24 | await expect(api.get({ page: 1, orderBy: 'name', nameStartsWith: 'spi', url: '/characters' })) 25 | .resolves 26 | .toEqual(data); 27 | }); 28 | 29 | it('Api.getCharacters - should retrive character with offset 15', async () => { 30 | const data = { 31 | response: { 32 | data: { 33 | code: 200, 34 | data: { 35 | offset: 15 36 | } 37 | } 38 | } 39 | }; 40 | 41 | axios.get.mockImplementationOnce(() => Promise.resolve(data)); 42 | 43 | await expect(api.get({ page: 2, orderBy: 'name', nameStartsWith: 'spi', url: '/characters' })) 44 | .resolves 45 | .toEqual(data); 46 | }); 47 | 48 | it('Api.getCharacters - should retrive character by name that starts with spi', async () => { 49 | const data = { 50 | response: { 51 | data: { 52 | code: 200, 53 | data: { 54 | results: [ 55 | { name: 'spider-dok' } 56 | ] 57 | } 58 | } 59 | } 60 | }; 61 | 62 | axios.get.mockImplementationOnce(() => Promise.resolve(data)); 63 | 64 | await expect(api.get({ page: 1, orderBy: 'name', nameStartsWith: 'spi', url: '/characters' })) 65 | .resolves 66 | .toEqual(data); 67 | }); 68 | 69 | it('Api.appendParameters - should append options to request url', () => { 70 | let result = api.appendParameters('/characters', { page: 1, orderBy: 'name', nameStartsWith: 'spi' }); 71 | 72 | expect(result).toBe(`/characters?ts=1&apikey=######&hash=######&nameStartsWith=spi&orderBy=name&limit=15&offset=0`); 73 | }); 74 | 75 | it('Api.getCharacters - should retrive error', async () => { 76 | const error = { 77 | stack: '' 78 | }; 79 | 80 | axios.get.mockImplementationOnce(() => Promise.reject(error)); 81 | 82 | api.version = 'v2'; 83 | 84 | await expect(api.get({ page: 1, orderBy: 'name', nameStartsWith: 'spi', url: '/characters' })) 85 | .rejects 86 | .toEqual(error); 87 | }); 88 | }); 89 | -------------------------------------------------------------------------------- /public/sw.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Welcome to your Workbox-powered service worker! 3 | * 4 | * You'll need to register this file in your web app and you should 5 | * disable HTTP caching for this file too. 6 | * See https://goo.gl/nhQhGp 7 | * 8 | * The rest of the code is auto-generated. Please don't update this file 9 | * directly; instead, make changes to your Workbox build configuration 10 | * and re-run your build process. 11 | * See https://goo.gl/2aRDsh 12 | */ 13 | 14 | importScripts("https://storage.googleapis.com/workbox-cdn/releases/3.6.3/workbox-sw.js"); 15 | 16 | workbox.skipWaiting(); 17 | workbox.clientsClaim(); 18 | 19 | /** 20 | * The workboxSW.precacheAndRoute() method efficiently caches and responds to 21 | * requests for URLs in the manifest. 22 | * See https://goo.gl/S9QRab 23 | */ 24 | self.__precacheManifest = [ 25 | { 26 | "url": "289.1d43c609c894bd6b91dc.js", 27 | "revision": "0ea11b46301c591a29d7c58576b41a09" 28 | }, 29 | { 30 | "url": "577.1d43c609c894bd6b91dc.js", 31 | "revision": "4f84a27c44f73fcba43f9e69bda84098" 32 | }, 33 | { 34 | "url": "76.1d43c609c894bd6b91dc.js", 35 | "revision": "3e614050b1177cbf94b12287d18ff703" 36 | }, 37 | { 38 | "url": "788.1d43c609c894bd6b91dc.js", 39 | "revision": "6c23cce13cee9316e8fe4970c24d76ac" 40 | }, 41 | { 42 | "url": "app.1d43c609c894bd6b91dc.js", 43 | "revision": "656097b08d79622bddafa968646339f5" 44 | }, 45 | { 46 | "url": "fonts/font.woff", 47 | "revision": "45b47f3e9c7d74b80f5c6e0a3c513b23" 48 | }, 49 | { 50 | "url": "images/close.svg", 51 | "revision": "e7fb89851b79caec1094b297281ca19b" 52 | }, 53 | { 54 | "url": "images/dots.svg", 55 | "revision": "9524eabbca5125c6c23de2cebe1853d0" 56 | }, 57 | { 58 | "url": "images/github.svg", 59 | "revision": "bd666487a62742a3d09429cbcb14a0cd" 60 | }, 61 | { 62 | "url": "images/icons/safari-pinned-tab.svg", 63 | "revision": "9485bad24d046e48791ee8fe251ca808" 64 | }, 65 | { 66 | "url": "images/marvel.svg", 67 | "revision": "3cb51f0cad379a7e38170621aa653d75" 68 | }, 69 | { 70 | "url": "images/search.svg", 71 | "revision": "de5d7f019aae630499fa25b5a9b37d20" 72 | }, 73 | { 74 | "url": "index.html", 75 | "revision": "bd05c8fe46ccfe09c8c29b118968bbb9" 76 | } 77 | ].concat(self.__precacheManifest || []); 78 | workbox.precaching.suppressWarnings(); 79 | workbox.precaching.precacheAndRoute(self.__precacheManifest, {}); 80 | 81 | workbox.routing.registerRoute(/https\:\/\/gateway\.marvel\.com\/v1\/public\/.+/g, workbox.strategies.cacheFirst({ "cacheName":"api-cache", plugins: [new workbox.expiration.Plugin({"maxEntries":20,"maxAgeSeconds":36000,"purgeOnQuotaError":false}), new workbox.cacheableResponse.Plugin({"statuses":[0,200]})] }), 'GET'); 82 | workbox.routing.registerRoute(/https\:\/\/gateway\.marvel\.com\/v1\/public\/.+/g, workbox.strategies.staleWhileRevalidate({ plugins: [new workbox.cacheableResponse.Plugin({"statuses":[0,200]})] }), 'GET'); 83 | -------------------------------------------------------------------------------- /test/misc/tilt.test.js: -------------------------------------------------------------------------------- 1 | import Tilt from '../../src/scripts/misc/tilt'; 2 | 3 | describe('Tilt Class', () => { 4 | describe('init', () => { 5 | it('inits Tilt instance', () => { 6 | const tilt = new Tilt(); 7 | const $el = document.createElement('div'); 8 | const getBoundingClientRectSpy = jest.spyOn($el, 'getBoundingClientRect'); 9 | const addListenersSpy = jest.spyOn(tilt, '_addListeners'); 10 | 11 | tilt.init($el); 12 | 13 | expect(tilt.el).toEqual($el); 14 | expect(tilt.over).toBeFalsy(); 15 | expect(getBoundingClientRectSpy).toBeCalled(); 16 | expect(addListenersSpy).toBeCalled(); 17 | }); 18 | }); 19 | 20 | describe('_addListeners', () => { 21 | it('adds events to the element', () => { 22 | const tilt = new Tilt(); 23 | const $el = document.createElement('div'); 24 | tilt.init($el); 25 | 26 | const addEventListenerSpy = jest.spyOn($el, 'addEventListener') 27 | 28 | tilt._addListeners(); 29 | 30 | expect(addEventListenerSpy).toHaveBeenCalledTimes(3); 31 | }); 32 | }); 33 | 34 | describe('_mouseOver', () => { 35 | it('calls position when over is truthy', () => { 36 | const tilt = new Tilt(); 37 | const $el = document.createElement('div'); 38 | const positionSpy = jest.spyOn(tilt, '_position'); 39 | const mouseOverEvent = new Event('mouseover'); 40 | 41 | tilt.init($el); 42 | tilt.over = true; 43 | tilt.el.dispatchEvent(mouseOverEvent); 44 | 45 | expect(positionSpy).toBeCalled(); 46 | }); 47 | }); 48 | 49 | describe('_onMouseMove', () => { 50 | it('calls position and transition on mouse move', () => { 51 | const tilt = new Tilt(); 52 | const $el = document.createElement('div'); 53 | const positionSpy = jest.spyOn(tilt, '_position'); 54 | const transitionSpy = jest.spyOn(tilt, '_transition'); 55 | const mouseMoveEvent = new Event('mousemove'); 56 | 57 | tilt.init($el); 58 | tilt.el.dispatchEvent(mouseMoveEvent); 59 | 60 | expect(positionSpy).toBeCalled(); 61 | expect(transitionSpy).toBeCalled(); 62 | }); 63 | }); 64 | 65 | describe('_onMouseOut', () => { 66 | it('applies styles and sets over to false', () => { 67 | const tilt = new Tilt(); 68 | const $el = document.createElement('div'); 69 | const applyStyleSpy = jest.spyOn(tilt, '_applyStyle'); 70 | const mouseOutEvent = new Event('mouseout'); 71 | 72 | tilt.init($el); 73 | tilt.over = true; 74 | tilt.el.dispatchEvent(mouseOutEvent); 75 | 76 | expect(applyStyleSpy).toBeCalled(); 77 | expect(tilt.over).toBeFalsy(); 78 | }); 79 | }); 80 | 81 | describe('_convertToString', () => { 82 | it('converts an object with some style properties to string', () => { 83 | const tilt = new Tilt(); 84 | 85 | const styleObj = { 86 | transform: 'rotate(90deg)', 87 | } 88 | 89 | const expectString = 'transform:rotate(90deg);'; 90 | 91 | expect(tilt._convertToString(styleObj)).toEqual(expectString); 92 | }); 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /src/scss/media-queries/desktop.scss: -------------------------------------------------------------------------------- 1 | @import '../base/easing'; 2 | 3 | @media screen and (min-width: 960px ) { 4 | .grid { 5 | justify-content: center; 6 | } 7 | 8 | .about { 9 | .content { 10 | max-width: 520px; 11 | } 12 | } 13 | 14 | .search, .header { 15 | position: relative; 16 | } 17 | 18 | .header { 19 | box-shadow: -3px -5px 1px 5px rgba(0, 0, 0, .75); 20 | 21 | &__search { 22 | visibility: hidden; 23 | } 24 | } 25 | 26 | .menu { 27 | &.show { 28 | box-shadow: none; 29 | } 30 | } 31 | 32 | .search { 33 | box-shadow: -2px 2px 7px 0 rgba(0, 0, 0, .32); 34 | } 35 | 36 | 37 | .list { 38 | .show { 39 | box-shadow: -3px 9px 8px 0 rgba(0, 0, 0, .3); 40 | } 41 | } 42 | 43 | .slides { 44 | width: 100%; 45 | 46 | .first, .second { 47 | width: 100%; 48 | } 49 | } 50 | 51 | .detail { 52 | overflow-y: hidden; 53 | 54 | img { 55 | border-radius: 4px; 56 | max-width: 50vh; 57 | transform: translate(-50%, -50%) translate3d(0, 0, 0); 58 | width: initial; 59 | } 60 | 61 | &__cover { 62 | position: relative; 63 | width: 50%; 64 | 65 | &--reflex { 66 | filter: grayscale(0) blur(16px); 67 | opacity: .4; 68 | transform: scale(1.1); 69 | width: 100%; 70 | } 71 | } 72 | 73 | &__content { 74 | display: flex; 75 | flex-direction: column; 76 | flex-wrap: wrap; 77 | height: 100%; 78 | position: relative; 79 | } 80 | 81 | &__infos { 82 | background-color: #edeeef; 83 | box-shadow: -2px -3px 20px 0 rgba(0, 0, 0, .3); 84 | min-height: 100vh; 85 | overflow-y: scroll; 86 | position: relative; 87 | transform: translateY(0) translate3d(0, 0, 0); 88 | width: 50%; 89 | } 90 | 91 | .info { 92 | &__name { 93 | box-shadow: none; 94 | line-height: 1.9; 95 | padding: 50px; 96 | 97 | h2 { 98 | font-size: 30pt; 99 | text-transform: uppercase; 100 | } 101 | } 102 | } 103 | } 104 | 105 | .slides { 106 | overflow: hidden; 107 | 108 | .loader { 109 | width: 10%; 110 | } 111 | } 112 | 113 | .thumb { 114 | margin-right: 20px; 115 | width: 180px; 116 | 117 | &__title { 118 | font-size: 16px; 119 | } 120 | 121 | &__file { 122 | transform: scale(1); 123 | transition: filter .3s, transform .6s easing(easeOutQuart); 124 | } 125 | 126 | &:nth-child(3n + 0) { 127 | margin-right: 20px; 128 | } 129 | 130 | &:hover { 131 | .thumb__file { 132 | filter: brightness(15%); 133 | transform: scale(1.2); 134 | } 135 | 136 | .thumb__title { 137 | opacity: 1; 138 | transition: opacity .3s .2s; 139 | } 140 | } 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/scripts/pages/detailComic.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import Transition from '../components/transition'; 4 | import BackButton from '../components/back-button'; 5 | import ScrollIndicator from '../components/scroll-indicator'; 6 | import Tilt from '../misc/tilt'; 7 | import Cover from '../components/cover'; 8 | import { animateIn, enableScroll, coverOnLoad, infoData } from './detailsCommon'; 9 | 10 | class DetailComic extends PureComponent { 11 | constructor(props) { 12 | super(props); 13 | } 14 | 15 | positionInfos() { 16 | let viewH = 0; 17 | if (window.innerWidth < 960) { 18 | viewH = window.innerHeight - 50; 19 | } 20 | this.infos.style = `transform:translateY(${viewH}px)`; 21 | } 22 | 23 | componentDidMount() { 24 | this.tilt = new Tilt(); 25 | this.mobile = true; 26 | 27 | let slides = [...document.querySelectorAll('.slides .first')]; 28 | 29 | window.onresize = () => { this.positionInfos(); }; 30 | 31 | const content = document.querySelector('.detail__content'); 32 | const img = content.querySelector('img'); 33 | 34 | img.onload = () => { coverOnLoad(content, img, slides, this.positionInfos.bind(this), this.tilt); }; 35 | 36 | animateIn(slides); 37 | } 38 | 39 | componentWillUnmount() { 40 | window.onresize = null; 41 | 42 | enableScroll(); 43 | } 44 | 45 | createMarkup(markup) { 46 | return { __html: markup } 47 | } 48 | 49 | onBackButtonClick() { 50 | this.props.history.goBack(); 51 | } 52 | 53 | getDescription(description) { 54 | return description ?

: ''; 55 | } 56 | 57 | hasItens(data) { 58 | let output = false; 59 | if (data.items && data.items.length) { 60 | output = true; 61 | } 62 | 63 | return output; 64 | } 65 | 66 | render() { 67 | return ( 68 |
69 | 70 |
this.content = c} className="detail__content" > 71 | 72 | 73 |
this.infos = c} className="detail__infos"> 74 | 75 |
76 |

this.title = c}>{this.props.selectedItem.title}

77 | {this.getDescription(this.props.selectedItem.description)} 78 |
79 | {infoData(this.props.selectedItem.creators, this.hasItens, 'Creators')} 80 | {infoData(this.props.selectedItem.characters, this.hasItens, 'Characters')} 81 | {infoData(this.props.selectedItem.stories, this.hasItens, 'Stories')} 82 | {infoData(this.props.selectedItem.series, this.hasItens, 'Series')} 83 |
84 |
85 | 86 |
87 | ); 88 | } 89 | } 90 | 91 | DetailComic.propTypes = { 92 | selectedItem: PropTypes.object, 93 | history: PropTypes.object, 94 | } 95 | 96 | export default DetailComic; 97 | -------------------------------------------------------------------------------- /src/scss/pages/detail.scss: -------------------------------------------------------------------------------- 1 | @import '../base/color'; 2 | @import '../base/easing'; 3 | 4 | .detail { 5 | background-color: #000000; 6 | color: color(invert); 7 | height: 100%; 8 | left: 0; 9 | overflow: scroll; 10 | overflow-x: hidden; 11 | position: fixed; 12 | top: 0; 13 | width: 100%; 14 | z-index: 12; 15 | 16 | &__content { 17 | opacity: 0; 18 | position: absolute; 19 | will-change: opacity; 20 | 21 | &.active { 22 | opacity: 1; 23 | } 24 | } 25 | 26 | img { 27 | background-color: color(darkest); 28 | border-radius: 2%; 29 | box-shadow: 0 2px 20px 0 rgba(0, 0, 0, .3); 30 | display: none; 31 | left: 50%; 32 | margin: 0 auto; 33 | position: absolute; 34 | top: 50%; 35 | transform: translate(-50%, -55%) translate3d(0, 0, 0); 36 | transform-origin: left top; 37 | width: 80%; 38 | z-index: 1; 39 | 40 | &.show { 41 | display: block; 42 | } 43 | } 44 | 45 | &__cover { 46 | display: block; 47 | height: 100%; 48 | perspective: 800px; 49 | position: fixed; 50 | width: 100vw; 51 | z-index: 1; 52 | 53 | &--reflex { 54 | background-origin: content-box; 55 | background-position: top center; 56 | background-repeat: no-repeat; 57 | background-size: cover; 58 | filter: grayscale(100%); 59 | height: 100%; 60 | margin: 0 auto; 61 | opacity: .1; 62 | overflow: hidden; 63 | position: absolute; 64 | top: 0; 65 | width: inherit; 66 | 67 | &::before { 68 | background-image: linear-gradient(160deg, rgba(255, 255, 255, .15) 50%, transparent 50%); 69 | content: ''; 70 | height: 100%; 71 | position: absolute; 72 | top: 0; 73 | width: 100%; 74 | z-index: 1; 75 | } 76 | } 77 | } 78 | 79 | &__infos { 80 | background-color: color(info); 81 | font-size: 16px; 82 | line-height: 1.3; 83 | position: absolute; 84 | transform: translateY(100vh) translate3d(0, 0, 0); 85 | width: 100vw; 86 | z-index: 1; 87 | 88 | section{ 89 | background-color: color(series); 90 | color: color(secondary); 91 | 92 | li { 93 | &:nth-child(even) { 94 | background-color: rgba(12, 12, 12, 0.05); 95 | } 96 | 97 | &:nth-child(odd) { 98 | background-color: rgba(0, 0, 0, 0.02); 99 | } 100 | } 101 | } 102 | 103 | } 104 | 105 | .info { 106 | &__name { 107 | background-color: color(secondary); 108 | box-shadow: -2px 18px 20px 20px color(shadow); 109 | padding: 15px; 110 | } 111 | 112 | &__series, &__stories, &__creators, &__characters { 113 | ul { 114 | list-style: inside decimal; 115 | } 116 | 117 | .sub-title { 118 | padding: 15px 10px; 119 | } 120 | 121 | li { 122 | padding: 10px 20px; 123 | } 124 | } 125 | } 126 | } 127 | 128 | .disable-scroll { 129 | overflow: hidden; 130 | } 131 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "marvel-api-explorer", 3 | "version": "0.0.1", 4 | "main": "", 5 | "author": "Ion D. FIlho ", 6 | "engines": { 7 | "node": "15.11.0", 8 | "npm": "7.6.0" 9 | }, 10 | "license": "MIT", 11 | "scripts": { 12 | "preinstall": "npx npm-force-resolutions", 13 | "prod": "cross-env NODE_ENV=production ./node_modules/.bin/webpack --config webpack.prod.js", 14 | "start": "cross-env NODE_ENV=development ./node_modules/.bin/webpack-dev-server --open --config webpack.dev.js", 15 | "test": "jest --coverage --testMatch '**/*.test.js'", 16 | "test:watch": "npm run test -- --watch", 17 | "workbox": "./node_modules/.bin/workbox generateSW workbox-config.js" 18 | }, 19 | "jest": { 20 | "notify": false, 21 | "clearMocks": true, 22 | "restoreMocks": true, 23 | "collectCoverage": true, 24 | "collectCoverageFrom": [ 25 | "src/**/*.{js,jsx,ts}" 26 | ] 27 | }, 28 | "resolutions": { 29 | "yargs-parser": ">=18.1.2", 30 | "braces": ">=3.0.2", 31 | "dot-prop": ">=5.1.1" 32 | }, 33 | "repository": { 34 | "type": "git", 35 | "url": "git@github.com:iondrimba/marvel-api-explorer.git" 36 | }, 37 | "dependencies": { 38 | "@babel/plugin-syntax-dynamic-import": "^7.8.3", 39 | "axios": "^0.21.1", 40 | "babel-preset-env": "^1.7.0", 41 | "connected-react-router": "^6.9.1", 42 | "history": "^4.10.1", 43 | "npm-force-resolutions": "0.0.10", 44 | "prop-types": "^15.7.2", 45 | "react-router": "^5.2.0", 46 | "react-router-redux": "^4.0.8", 47 | "redux-thunk": "^2.1.0", 48 | "reselect": "^4.0.0", 49 | "webpack-merge": "^5.7.3", 50 | "workbox-cli": "^3.6.3" 51 | }, 52 | "devDependencies": { 53 | "@babel/cli": "^7.13.10", 54 | "@babel/core": "^7.13.10", 55 | "@babel/plugin-proposal-object-rest-spread": "^7.13.8", 56 | "@babel/plugin-transform-runtime": "^7.13.10", 57 | "@babel/preset-env": "^7.14.1", 58 | "@babel/preset-react": "^7.12.13", 59 | "@babel/register": "^7.13.16", 60 | "autoprefixer": "10.2.5", 61 | "babel-core": "^7.0.0-bridge.0", 62 | "babel-eslint": "^10.1.0", 63 | "babel-jest": "^26.6.3", 64 | "babel-loader": "^8.2.2", 65 | "babelify": "^10.0.0", 66 | "browserify": "^17.0.0", 67 | "browserify-istanbul": "^3.0.1", 68 | "clean-webpack-plugin": "^3.0.0", 69 | "compression-webpack-plugin": "^6.1.1", 70 | "copy-webpack-plugin": "^8.0.0", 71 | "cross-env": "^7.0.3", 72 | "css-loader": "^5.1.3", 73 | "enzyme": "^3.11.0", 74 | "eslint": "^7.21.0", 75 | "eslint-loader": "^4.0.2", 76 | "eslint-plugin-jest": "^24.3.2", 77 | "eslint-plugin-react": "^7.22.0", 78 | "file-loader": "^6.2.0", 79 | "hammerjs": "^2.0.8", 80 | "html-loader": "^2.1.2", 81 | "html-webpack-plugin": "^4.5.2", 82 | "image-webpack-loader": "^7.0.1", 83 | "jest": "^26.6.3", 84 | "json-loader": "^0.5.4", 85 | "mini-css-extract-plugin": "^1.3.9", 86 | "node-sass": "^5.0.0", 87 | "postcss-cssnext": "^3.1.0", 88 | "postcss-loader": "^4.2.0", 89 | "promise-polyfill": "^8.2.0", 90 | "raw-loader": "^4.0.2", 91 | "react": "^17.0.1", 92 | "react-dom": "^17.0.1", 93 | "react-redux": "^7.2.2", 94 | "react-router-dom": "^5.2.0", 95 | "redux": "^4.0.5", 96 | "reselect": "^4.0.0", 97 | "resolve-url-loader": "^3.1.3", 98 | "sass-loader": "^11.0.1", 99 | "style-loader": "^2.0.0", 100 | "stylefmt": "^6.0.3", 101 | "stylelint-config-standard": "^21.0.0", 102 | "stylelint-webpack-plugin": "^2.1.1", 103 | "sw-precache-webpack-plugin": "^1.0.0", 104 | "system": "^2.0.1", 105 | "url-loader": "^4.1.1", 106 | "webpack": "^5.36.1", 107 | "webpack-cli": "^4.7.0", 108 | "webpack-dev-server": "^3.11.2", 109 | "workbox-sw": "^6.1.2", 110 | "workbox-webpack-plugin": "^6.1.2" 111 | } 112 | } -------------------------------------------------------------------------------- /webpack.common.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | const { CleanWebpackPlugin } = require('clean-webpack-plugin'); 4 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 5 | 6 | const config = { 7 | resolve: { 8 | extensions: ['.js', '.jsx', '.json', '.mp3', '.ico'] 9 | }, 10 | entry: { 11 | app: './src/scripts/app' 12 | }, 13 | output: { 14 | path: __dirname + '/public', 15 | filename: '[name].[hash].js' 16 | }, 17 | module: { 18 | rules: [ 19 | { 20 | test: /.j(s|sx)$/, 21 | exclude: /node_modules/, 22 | use: ['babel-loader', 'eslint-loader'], 23 | }, 24 | { 25 | test: /\.html$/, 26 | use: ['raw-loader'] 27 | }, 28 | { 29 | test: /\.css$/, 30 | use: [ 31 | { loader: 'style-loader' }, 32 | { 33 | loader: 'css-loader', 34 | options: { 35 | modules: { 36 | mode: 'local', 37 | localIdentName: '[local]', 38 | }, 39 | importLoaders: true, 40 | sourceMap: true, 41 | localIdentName: '[local]', 42 | } 43 | } 44 | ] 45 | }, 46 | { 47 | test: /\.scss$/, 48 | use: [ 49 | { loader: 'style-loader' }, 50 | { 51 | loader: 'css-loader', 52 | options: { 53 | modules: { 54 | mode: 'local', 55 | localIdentName: '[local]', 56 | }, 57 | import: true, 58 | importLoaders: true, 59 | } 60 | }, 61 | { loader: 'sass-loader' } 62 | ] 63 | }, 64 | { 65 | test: /\.(eot|ttf|woff|woff2)$/, 66 | loader: 'file-loader', 67 | options: { 68 | name: 'fonts/[name].[ext]', 69 | } 70 | }, 71 | { 72 | test: /\.(mp3)$/, 73 | loader: 'file-loader', 74 | options: { 75 | name: 'sounds/[name].[ext]', 76 | } 77 | }, 78 | { 79 | test: /.*\.(gif|png|jpe?g|svg)$/i, 80 | use: [ 81 | { 82 | loader: 'file-loader', 83 | options: { 84 | hash: 'sha512', 85 | digest: 'hex', 86 | name: 'images/[name].[hash].[ext]', 87 | } 88 | }, 89 | { 90 | loader: 'image-webpack-loader', 91 | options: { 92 | query: { 93 | mozjpeg: { 94 | progressive: true, 95 | }, 96 | gifsicle: { 97 | interlaced: true, 98 | }, 99 | optipng: { 100 | optimizationLevel: 7, 101 | } 102 | } 103 | } 104 | } 105 | ] 106 | } 107 | ] 108 | }, 109 | 110 | plugins: [ 111 | new CleanWebpackPlugin(), 112 | new HtmlWebpackPlugin({ 113 | title: 'Marvel API Demo', 114 | template: './src/index.html', 115 | inject: 'body' 116 | }), 117 | new CopyWebpackPlugin({ 118 | patterns: [ 119 | { 120 | from: 'src/manifest.webmanifest', to: 'manifest.webmanifest' 121 | }, 122 | { 123 | from: 'src/.htaccess' 124 | }, 125 | { 126 | from: 'src/favicon.ico', to: 'favicon.ico' 127 | }, 128 | { 129 | from: './robots.txt', to: 'robots.txt' 130 | }, 131 | { 132 | from: 'src/browserconfig.xml', to: 'browserconfig.xml' 133 | }, 134 | { 135 | from: 'src/favicon-16x16.png', to: 'favicon-16x16.png' 136 | }, 137 | { 138 | from: 'src/favicon-32x32.png', to: 'favicon-32x32.png' 139 | }, 140 | { 141 | from: 'src/favicon-48x48.png', to: 'favicon-48x48.png' 142 | }, 143 | { 144 | from: 'src/images', to: 'images' 145 | }, 146 | ], 147 | }), 148 | new webpack.EnvironmentPlugin([ 149 | 'NODE_ENV' 150 | ]), 151 | new webpack.DefinePlugin({ 152 | 'process.env': { 153 | 'NODE_ENV': JSON.stringify(process.env.NODE_ENV), 154 | 'PUBLIC_API_KEY': JSON.stringify(process.env.PUBLIC_API_KEY), 155 | } 156 | }), 157 | ] 158 | }; 159 | 160 | module.exports = config; 161 | -------------------------------------------------------------------------------- /public/577.1d43c609c894bd6b91dc.js: -------------------------------------------------------------------------------- 1 | "use strict";(self.webpackChunkmarvel_api_explorer=self.webpackChunkmarvel_api_explorer||[]).push([[577],{6577:(t,e,n)=>{n.r(e),n.d(e,{default:()=>j});var r=n(1225),o=n(6347),i=n(6540),c=n(5556),u=n.n(c),a=n(9399),l=n(5022),s=n(8557),f=n(2133),p=n(8277),y=n(884);function m(t){return m="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},m(t)}function d(t,e){(null==e||e>t.length)&&(e=t.length);for(var n=0,r=Array(e);n -------------------------------------------------------------------------------- /src/images/icons/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/788.1d43c609c894bd6b91dc.js: -------------------------------------------------------------------------------- 1 | "use strict";(self.webpackChunkmarvel_api_explorer=self.webpackChunkmarvel_api_explorer||[]).push([[788],{9788:(t,e,n)=>{n.r(e),n.d(e,{default:()=>S});var r=n(1225),o=n(6347),i=n(6540),c=n(5556),u=n.n(c),a=n(9399),s=n(5022),l=n(2133),f=n(8277),p=n(8557),m=n(884);function y(t){return y="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},y(t)}function d(t,e){(null==e||e>t.length)&&(e=t.length);for(var n=0,r=Array(e);n{r.r(t),r.d(t,{default:()=>O});var n=r(6540),o=r(5556),i=r.n(o);function c(e){return c="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},c(e)}function u(e,t){for(var r=0;rMarvel API Demo
This Application is ready to go offline!

almost there, hang on...

-------------------------------------------------------------------------------- /src/scripts/container/homeContainer.jsx: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import Home from '../pages/home'; 3 | import { fetch } from '../actions/fetch'; 4 | import fetching from '../actions/fetching'; 5 | import search from '../actions/search'; 6 | import fetchingError from '../actions/fetchingError'; 7 | import filter from '../actions/filter'; 8 | import started from '../actions/started'; 9 | import menuOpen from '../actions/menuOpen'; 10 | import pagination from '../actions/pagination'; 11 | import appStore from '../model/store'; 12 | import defaultStore from '../model/initialState'; 13 | import PaginationHelper from '../model/paginationHelper'; 14 | 15 | const pg = new PaginationHelper(); 16 | 17 | function mapStateToProps(store) { 18 | return { 19 | menuOpen: store.menuOpen, 20 | error: store.error, 21 | fetching: store.fetching, 22 | started: store.started, 23 | filter: store.filter, 24 | search: store.search, 25 | pagination: Object.assign({}, 26 | store.pagination, { 27 | pages: pg.getPages(store.pagination), 28 | next: pg.hasNext(store.pagination), 29 | prev: pg.hasPrev(store.pagination) 30 | } 31 | ), 32 | data: store.data 33 | }; 34 | } 35 | 36 | function getQueryString(search) { 37 | return search ? `?search=${search}` : ''; 38 | } 39 | 40 | function paginate(url, dispatch, props) { 41 | const page = Number(url.split('/')[2].split('?')[0]); 42 | const store = appStore.getState(); 43 | 44 | dispatch(pagination({ 45 | current: page, 46 | pages: pg.getPages(store.pagination), 47 | next: pg.hasNext(store.pagination), 48 | prev: pg.hasPrev(store.pagination) 49 | })); 50 | 51 | props.history.push(url); 52 | } 53 | 54 | function _errorClear(props, dispatch) { 55 | dispatch(fetchingError({ code: '' })); 56 | } 57 | 58 | function _firstFetch(props, dispatch, fetchData) { 59 | let searchTerm = props.search; 60 | let page = props.pagination.current; 61 | let type = props.filter; 62 | 63 | const paths = props.location.pathname.split('/'); 64 | 65 | if (paths.length && props.location.pathname !== '/') { 66 | type = paths[1]; 67 | 68 | if (props.location.search) { 69 | searchTerm = props.location.search.replace(/\?search=/g, ''); 70 | } 71 | } 72 | 73 | props.history.push(`/${type}/${page}${getQueryString(searchTerm)}`); 74 | 75 | dispatch(search(searchTerm)); 76 | dispatch(started(true)); 77 | 78 | if (!isNaN(paths[2])) { 79 | page = Number(paths[2]); 80 | 81 | dispatch(filter(type)); 82 | dispatch(pagination(Object.assign({}, defaultStore.pagination, { current: page }))); 83 | } 84 | 85 | fetchData(type, Object.assign({}, defaultStore.pagination, { current: page }), searchTerm, dispatch); 86 | } 87 | 88 | function _paginataionAction(delta, props, dispatch) { 89 | const url = `/${props.filter}/${Number(props.pagination.current) + delta}${getQueryString(props.search)}`; 90 | 91 | paginate(url, dispatch, props); 92 | } 93 | 94 | function _fetchData(filter, pagination, search, dispatch) { 95 | let aditionalOptions = { 96 | orderBy: 'name', 97 | nameStartsWith: search 98 | }; 99 | 100 | if (filter === 'comics') { 101 | aditionalOptions = { 102 | orderBy: 'title', 103 | titleStartsWith: search 104 | }; 105 | } 106 | dispatch(fetch(Object.assign({}, { url: `/${filter}`, page: pagination.current, total: pagination.total }, aditionalOptions))); 107 | dispatch(fetchingError({ code: '' })); 108 | dispatch(fetching(true)); 109 | } 110 | 111 | const mapDispatchToProps = (dispatch, store) => { 112 | return { 113 | errorClear: props => _errorClear(props, dispatch), 114 | 115 | firstFetch(props) { 116 | _firstFetch(props, dispatch, _fetchData); 117 | }, 118 | 119 | fetchAction(page = 0) { 120 | if (page) { 121 | Object.assign(appStore.getState().pagination, { current: page }); 122 | } 123 | 124 | _fetchData(appStore.getState().filter, appStore.getState().pagination, appStore.getState().search, dispatch); 125 | }, 126 | 127 | searchAction: (val, props) => { 128 | props.history.push(`/${props.filter}/${defaultStore.pagination.current}${getQueryString(val)}`); 129 | 130 | dispatch(search(val)); 131 | dispatch(menuOpen(false)); 132 | dispatch(started(true)); 133 | dispatch(pagination(defaultStore.pagination)); 134 | 135 | _fetchData(appStore.getState().filter, appStore.getState().pagination, val, dispatch); 136 | }, 137 | 138 | searchClear: (val) => { 139 | dispatch(search(val)); 140 | }, 141 | 142 | filterAction: (val, props) => { 143 | props.history.push(`/${val}/${defaultStore.pagination.current}?search=${appStore.getState().search}`); 144 | 145 | dispatch(filter(val)); 146 | dispatch(pagination(defaultStore.pagination)); 147 | 148 | _fetchData(val, appStore.getState().pagination, appStore.getState().search, dispatch); 149 | }, 150 | 151 | paginationAction: (url, props) => { 152 | paginate(url, dispatch, props); 153 | }, 154 | 155 | paginationPrevAction: (props) => { 156 | pg.hasPrev(props.pagination) ? _paginataionAction(-1, props, dispatch) : null; 157 | }, 158 | 159 | paginationNextAction: (props) => { 160 | pg.hasNext(props.pagination) ? _paginataionAction(+1, props, dispatch) : null; 161 | }, 162 | 163 | toogleMenuAction: (visible) => { 164 | dispatch(menuOpen(visible)); 165 | } 166 | }; 167 | } 168 | 169 | export default connect(mapStateToProps, mapDispatchToProps)(Home); 170 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | Marvel API Demo 61 | 84 | 85 | 117 | 118 | 119 | 130 | 131 | 132 | 133 |
This Application is ready to go offline!
134 |
135 |

almost there, hang on...

136 |
137 | 138 | 139 | 140 | -------------------------------------------------------------------------------- /test/model/paginationHelper.test.js: -------------------------------------------------------------------------------- 1 | import PaginationHelper from '../../src/scripts/model/paginationHelper'; 2 | 3 | describe('paginationHelper', () => { 4 | describe('mountGroups', () => { 5 | const helper = new PaginationHelper(); 6 | 7 | it('always return an array when mountGroups is called when any integer number is passed', () => { 8 | expect(Array.isArray(helper.mountGroups(1))).toBe(true); 9 | expect(Array.isArray(helper.mountGroups(5))).toBe(true); 10 | expect(Array.isArray(helper.mountGroups(10))).toBe(true); 11 | expect(Array.isArray(helper.mountGroups(53))).toBe(true); 12 | }); 13 | 14 | it('every element of the returned array should be an array', () => { 15 | helper.mountGroups(16).forEach(group => { 16 | expect(Array.isArray(group)).toBe(true); 17 | }) 18 | }); 19 | 20 | describe('every element(group) of the returned array should..', () => { 21 | it('be a group with a max size of 5 elements', () => { 22 | const lengthHigherThan5 = group => (group || []).length > 5; 23 | const anyGroupWithMoreThan5Elements = 24 | helper.mountGroups(13).every(lengthHigherThan5); 25 | 26 | expect(anyGroupWithMoreThan5Elements).toBe(false) 27 | }); 28 | 29 | it('every element of the group should be a number', () => { 30 | const isNumber = element => typeof element === 'number'; 31 | 32 | helper.mountGroups(30).forEach(group => { 33 | expect(group.every(isNumber)).toBe(true); 34 | }); 35 | }); 36 | 37 | it('elements is sequencial', () => { 38 | const isSequencial = (cur, i, group) => { 39 | if (group[i + 1]) { 40 | cur < group[i + 1]; 41 | } 42 | 43 | return true; 44 | }; 45 | 46 | expect(helper.mountGroups(4).every(isSequencial)).toBe(true); 47 | }); 48 | }); 49 | }); 50 | 51 | describe('getCurrentGroup', () => { 52 | const helper = new PaginationHelper(); 53 | const groups = helper.mountGroups(11); 54 | 55 | it('returns [0, 1, 2, 3, 4] when current position is 0', () => { 56 | expect(helper.getCurrentGroup(groups, 0)).toEqual([0, 1, 2, 3, 4]); 57 | }); 58 | 59 | it('returns [5, 6, 7, 8, 9] when current position is 1', () => { 60 | expect(helper.getCurrentGroup(groups, 1)).toEqual([5, 6, 7, 8, 9]); 61 | }); 62 | 63 | it('returns [10] when current position is 2', () => { 64 | expect(helper.getCurrentGroup(groups, 2)).toEqual([10]); 65 | }); 66 | }); 67 | 68 | describe('groupPages', () => { 69 | const helper = new PaginationHelper(); 70 | 71 | it('returns 0 when currentPage < 5', () => { 72 | expect(helper.groupPages(1)).toEqual(0); 73 | expect(helper.groupPages(3)).toEqual(0); 74 | expect(helper.groupPages(4)).toEqual(0); 75 | }); 76 | 77 | it('returns 1 when currentPage > 5 and < 10', () => { 78 | expect(helper.groupPages(6)).toEqual(1); 79 | expect(helper.groupPages(8)).toEqual(1); 80 | expect(helper.groupPages(9)).toEqual(1); 81 | }); 82 | }); 83 | 84 | describe('getPages', () => { 85 | const helper = new PaginationHelper(); 86 | 87 | it('returns an empty array when totalElement <= 1', () => { 88 | expect(helper.getPages({ total: 0, current: 1 })).toEqual([]); 89 | expect(helper.getPages({ total: 1, current: 1 })).toEqual([]); 90 | }); 91 | 92 | it('returns [0, 1, 2, 3, 4] when currentPagination <= 5', () => { 93 | expect(helper.getPages({ total: 5, current: 1 })).toEqual([0, 1, 2, 3, 4]); 94 | expect(helper.getPages({ total: 5, current: 5 })).toEqual([0, 1, 2, 3, 4]); 95 | }); 96 | 97 | it('returns [5, 6, 7, 8, 9] when currentPagination < 5 and <= 10', () => { 98 | expect(helper.getPages({ total: 10, current: 6 })).toEqual([5, 6, 7, 8, 9]); 99 | expect(helper.getPages({ total: 10, current: 10 })).toEqual([5, 6, 7, 8, 9]); 100 | }); 101 | }); 102 | 103 | describe('getTotalPages', () => { 104 | const helper = new PaginationHelper(); 105 | 106 | it('returns 2 when totalItens = 10 and maxPages = 5', () => { 107 | expect(helper.getTotalPages(10, 5)).toEqual(2); 108 | }); 109 | 110 | it('returns 20 when totalItens = 100 and maxPages = 5', () => { 111 | expect(helper.getTotalPages(100, 5)).toEqual(20); 112 | }); 113 | 114 | it('returns 200 when totalItens = 1000 and maxPages = 5', () => { 115 | expect(helper.getTotalPages(1000, 5)).toEqual(200); 116 | }); 117 | }); 118 | 119 | describe('hasNext', () => { 120 | const helper = new PaginationHelper(); 121 | 122 | it('returns false when totalPagination <= 1', () => { 123 | expect(helper.hasNext({ total: 0, current: 1 })).toEqual(false); 124 | expect(helper.hasNext({ total: 1, current: 1 })).toEqual(false); 125 | }); 126 | 127 | it('returns false when totalPagination <= current', () => { 128 | expect(helper.hasNext({ total: 3, current: 5 })).toEqual(false); 129 | expect(helper.hasNext({ total: 4, current: 4 })).toEqual(false); 130 | }); 131 | 132 | it('returns true when totalPagination > current', () => { 133 | expect(helper.hasNext({ total: 10, current: 5 })).toEqual(true); 134 | expect(helper.hasNext({ total: 4, current: 3 })).toEqual(true); 135 | }); 136 | }); 137 | 138 | describe('hasPrev', () => { 139 | const helper = new PaginationHelper(); 140 | 141 | it('returns true when totalPagination > 0 and current > 1', () => { 142 | expect(helper.hasPrev({ total: 1, current: 2 })).toEqual(true); 143 | expect(helper.hasPrev({ total: 10, current: 5 })).toEqual(true); 144 | expect(helper.hasPrev({ total: 4, current: 3 })).toEqual(true); 145 | }); 146 | 147 | it('returns false when totalPagination < 0 and current <= 1', () => { 148 | expect(helper.hasPrev({ total: 0, current: 1 })).toEqual(false); 149 | expect(helper.hasPrev({ total: 0, current: 0 })).toEqual(false); 150 | }); 151 | }); 152 | 153 | describe('getPrev', () => { 154 | const helper = new PaginationHelper(); 155 | 156 | 157 | it('returns previousPage(current - 1) when totalPagination > 0 and current > 1', () => { 158 | expect(helper.getPrev({ total: 10, current: 5 })).toEqual(4); 159 | expect(helper.getPrev({ total: 4, current: 3 })).toEqual(2); 160 | }); 161 | 162 | 163 | it('returns false when totalPagination < 0 and current <= 11', () => { 164 | expect(helper.getPrev({ total: 0, current: 1 })).toEqual(undefined); 165 | expect(helper.getPrev({ total: 0, current: 0 })).toEqual(undefined); 166 | }); 167 | }); 168 | 169 | describe('getNext', () => { 170 | const helper = new PaginationHelper(); 171 | 172 | 173 | it('returns nextPage(current + 1) when totalPagination > 0 and current > 1', () => { 174 | expect(helper.getNext({ total: 10, current: 5 })).toEqual(6); 175 | expect(helper.getNext({ total: 4, current: 3 })).toEqual(4); 176 | }); 177 | 178 | 179 | it('returns undefined when totalPagination < 0 and current <= 11', () => { 180 | expect(helper.getNext({ total: 0, current: 1 })).toEqual(undefined); 181 | expect(helper.getNext({ total: 0, current: 0 })).toEqual(undefined); 182 | }); 183 | }); 184 | }); 185 | -------------------------------------------------------------------------------- /public/289.1d43c609c894bd6b91dc.js: -------------------------------------------------------------------------------- 1 | "use strict";(self.webpackChunkmarvel_api_explorer=self.webpackChunkmarvel_api_explorer||[]).push([[289],{884:(t,e,n)=>{n.d(e,{HY:()=>v,l2:()=>m,Vn:()=>b,Dl:()=>h});var r=n(6540),o=n(5556),i=n.n(o);function u(t){return u="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},u(t)}function c(t,e){for(var n=0;n{n.d(e,{A:()=>f});var r=n(6540);function o(t){return o="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},o(t)}function i(t,e){for(var n=0;n{n.d(e,{Mz:()=>c});var r="NOT_FOUND",o=function(t,e){return t===e};function i(t,e){var n,i,u="object"==typeof e?e:{equalityCheck:e},c=u.equalityCheck,a=void 0===c?o:c,l=u.maxSize,f=void 0===l?1:l,s=u.resultEqualityCheck,p=function(t){return function(e,n){if(null===e||null===n||e.length!==n.length)return!1;for(var r=e.length,o=0;o-1){var i=n[o];return o>0&&(n.splice(o,1),n.unshift(i)),i.value}return r}return{get:o,put:function(e,i){o(e)===r&&(n.unshift({key:e,value:i}),n.length>t&&n.pop())},getEntries:function(){return n},clear:function(){n=[]}}}(f,p);function v(){var e=y.get(arguments);if(e===r){if(e=t.apply(null,arguments),s){var n=y.getEntries().find(function(t){return s(t.value,e)});n&&(e=n.value)}y.put(arguments,e)}return e}return v.clearCache=function(){return y.clear()},v}function u(t){for(var e=arguments.length,n=new Array(e>1?e-1:0),r=1;r{n.d(e,{A:()=>y});var r=n(6540),o=n(5556),i=n.n(o);function u(t){return u="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},u(t)}function c(t,e){for(var n=0;n{function r(t){return r="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},r(t)}function o(t,e){for(var n=0;nu});const u=function(){return t=function t(){!function(t,e){if(!(t instanceof e))throw new TypeError("Cannot call a class as a function")}(this,t),this.offset=.1,this.animation=300},(e=[{key:"init",value:function(t){this.el=t,this.rect=this.el.getBoundingClientRect(),this.over=!1,this.timer,this._addListeners()}},{key:"_addListeners",value:function(){this.el.addEventListener("mouseover",this._onMouseOver.bind(this)),this.el.addEventListener("mousemove",this._onMouseMove.bind(this)),this.el.addEventListener("mouseout",this._onMouseOut.bind(this))}},{key:"_onMouseOver",value:function(t){this.over&&this._position(t,t.currentTarget)}},{key:"_onMouseMove",value:function(t){this._position(t,t.currentTarget),this._transition(t.currentTarget)}},{key:"_onMouseOut",value:function(t){this._applyStyle(t.currentTarget,{transform:"rotateX(0deg) rotateY(0deg) translate(-50%, -50%)"}),this.over=!1}},{key:"_convertToString",value:function(t){var e="";for(var n in t)e+="".concat(n,":").concat(t[n],";");return e}},{key:"_applyStyle",value:function(t,e){t.style=this._convertToString(e)}},{key:"_position",value:function(t,e){var n=this.rect,r=this.offset,o=-(t.offsetX-.5*n.width)*r,i=.03*(t.offsetY-.5*n.height);this._applyStyle(e,{transform:"rotateX("+i+"deg) rotateY("+o+"deg) translate(-50%, -50%)"})}},{key:"_transition",value:function(){var t=this;void 0!==this.timer&&clearTimeout(this.timer),this.timer=setTimeout(function(){t.over=!0},this.animation)}}])&&o(t.prototype,e),Object.defineProperty(t,"prototype",{writable:!1}),t;var t,e}()},8557:(t,e,n)=>{n.d(e,{A:()=>y});var r=n(6540),o=n(5556),i=n.n(o);function u(t){return u="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},u(t)}function c(t,e){for(var n=0;n{n.d(e,{A:()=>o});var r=n(6540);const o=function(){return r.createElement("div",{className:"slides"},r.createElement("svg",{className:"loader",xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 654.5 249.8"},r.createElement("path",{fill:"none",d:"M0 0h654.5v249.8H0z"}),r.createElement("path",{fill:"#fff",d:"M560.7 52.5V11H445l-19 138.8L407.4 11h-41.7l4.7 37c-4.8-9.5-22-37-59.4-37h-42v202.2L238.6 11H184l-31.4 209.5V11h-52.3L81.5 128.7 63.2 11H11v227.8h41V129L71 238.8h22L111 129v109.8h79.5l4.8-35h32l4.7 35h78v-74l9.6-1.4 19.8 75.4h40.3l-26-88.4c13.2-9.7 28-34.4 24-58L402.4 239l48-.2L483 32.3v206.5h77.7v-41h-37v-52.2h37V104h-37V52.5zM200 167.5l11.4-97.3 11.7 97.4h-23zm119.7-45a22.5 22.5 0 0 1-9.7 2.4V51.6h.2c3.3 0 27.3 1 27.3 36.2 0 18.3-8.2 30-17.8 34.6zm324 75.2v41h-76V11h41v186.8h35z"})),r.createElement("div",{className:"first"}),r.createElement("div",{className:"second"}))}}}]); --------------------------------------------------------------------------------