├── .babelrc ├── .eslintrc ├── .flowconfig ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── index.html ├── package.json ├── src ├── components │ ├── App.js │ ├── Container.js │ ├── Header.js │ ├── Item.js │ ├── List.js │ ├── Message.js │ └── Ribbon.js ├── emitter.js ├── index.js ├── style │ ├── App.css │ ├── Header.css │ ├── Item.css │ ├── List.css │ ├── Message.css │ └── Ribbon.css ├── type.js └── utils.js ├── test ├── .eslintrc └── unit │ ├── __snapshots__ │ └── utils.test.js.snap │ ├── components │ ├── App.test.js │ ├── Container.test.js │ ├── Header.test.js │ ├── Item.test.js │ ├── List.test.js │ ├── Message.test.js │ ├── Ribbon.test.js │ └── __snapshots__ │ │ ├── App.test.js.snap │ │ ├── Container.test.js.snap │ │ ├── Header.test.js.snap │ │ ├── Item.test.js.snap │ │ ├── List.test.js.snap │ │ ├── Message.test.js.snap │ │ └── Ribbon.test.js.snap │ ├── jest-setup.js │ └── utils.test.js ├── webpack ├── base.js ├── webpack.config.babel.js └── webpack.config.dev.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["env", "react", "stage-0"], 3 | "plugins": ["react-hot-loader/babel"] 4 | } 5 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | env: 2 | browser: true 3 | es6: true 4 | 5 | parserOptions: 6 | ecmaFeatures: 7 | jsx: true 8 | 9 | parser: "babel-eslint" 10 | 11 | plugins: 12 | - react 13 | 14 | extends: 15 | - airbnb 16 | 17 | rules: 18 | react/jsx-wrap-multilines: 0 19 | object-curly-newline: 0 20 | react/no-unused-state: 0 21 | react/sort-comp: 0 22 | react/forbid-prop-types: 0 23 | react/jsx-filename-extension: 0 24 | react/require-render-return: 0 25 | import/no-unresolved: 0 26 | no-underscore-dangle: 0 27 | no-unused-vars: 0 28 | comma-dangle: 0 29 | consistent-return: 0 30 | arrow-body-style: 0 31 | arrow-parens: 0 32 | no-unused-expressions: [2, { allowTernary: true, allowShortCircuit: true }] 33 | -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | .*/dist/.* 3 | .*/webpack/.* 4 | .*/node_modules/.* 5 | 6 | [include] 7 | 8 | [libs] 9 | 10 | [options] 11 | esproposal.class_static_fields=enable 12 | esproposal.class_instance_fields=enable 13 | suppress_comment= \\(.\\|\n\\)*\\$FlowFixMe 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | dist 4 | .DS_Store 5 | npm-debug.log 6 | flow-typed 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - "8" 5 | 6 | branches: 7 | only: 8 | - master 9 | 10 | sudo: false 11 | 12 | before_install: 13 | - curl -o- -L https://yarnpkg.com/install.sh | bash -s -- --version 1.2.1 14 | - export PATH=$HOME/.yarn/bin:$PATH 15 | 16 | cache: 17 | yarn: true 18 | 19 | script: 20 | yarn && yarn test 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright © 2017 Leo Hsieh [ http://leoj.js.org ] 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-iTunes-search 2 | 3 | [![Travis](https://img.shields.io/travis/LeoAJ/react-iTunes-search.svg?style=flat-square)](https://travis-ci.org/LeoAJ/react-iTunes-search) 4 | [![npm](https://img.shields.io/npm/l/express.svg?style=flat-square)](https://github.com/LeoAJ/react-iTunes-search/blob/master/LICENSE) 5 | [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-orange.svg?style=flat-square)](http://makeapullrequest.com) 6 | [![node](https://img.shields.io/badge/node-%3E=_8.0-yellowgreen.svg?style=flat-square)](https://nodejs.org) 7 | 8 | :musical_note: :mag: Simple web app for iTunes search with React (v16) 9 | 10 | >If you like this one and you also like Redux, there is a Redux implementation version [here](https://github.com/LeoAJ/redux-iTunes-search). Please check it out. 11 | 12 | ## Preview 13 | 14 | ![Preview Gif](https://user-images.githubusercontent.com/492921/31646805-f68ca096-b2b8-11e7-988d-ecfe934debff.gif) 15 | 16 | ## Live Demo 17 | 18 | http://leoj.js.org/react-iTunes-search/ 19 | 20 | ## Detail 21 | 22 | To know more detail, please read my [post](http://leoj.js.org/personal/React-iTunes-Search/). 23 | 24 | ## Installation 25 | 26 | 1. `git clone git@github.com:LeoAJ/react-iTunes-search.git` 27 | 2. `cd react-iTunes-search` 28 | 3. `yarn` 29 | 30 | ## Dev 31 | 32 | Run webpack dev server with [react-hot-loader 3](https://github.com/gaearon/react-hot-loader) 33 | 34 | ``` 35 | yarn start 36 | ``` 37 | 38 | and go to `localhost:3000` 39 | 40 | ## Deploy 41 | 42 | Use webpack minify plugin 43 | 44 | ``` 45 | yarn deploy 46 | ``` 47 | 48 | ## Lint 49 | 50 | run eslint, using [eslint-config-airbnb](https://github.com/airbnb/javascript/tree/master/packages/eslint-config-airbnb) rules. 51 | 52 | ``` 53 | yarn lint 54 | ``` 55 | 56 | ## Flow 57 | 58 | ``` 59 | flow-typed install 60 | yarn flow 61 | ``` 62 | 63 | ## Unit test 64 | 65 | Use `Jest` 66 | 67 | ``` 68 | yarn jest 69 | ``` 70 | 71 | ### Test 72 | 73 | run both `lint`, `flow` and `jest` 74 | 75 | ``` 76 | yarn test 77 | ``` 78 | 79 | ## Built with 80 | 81 | * React (v16) 82 | * Babel 6 83 | * ES6/ES7 (async/await) 84 | * Webpack 85 | * React-hot-loader 3 86 | * PostCSS 87 | * Eslint 88 | * Materialize 89 | * iTunes search API 90 | * Flow 91 | * Jest 92 | * Enzyme 93 | 94 | ## License 95 | 96 | MIT 97 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | iTunes Search by React 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-iTunes-search", 3 | "version": "2.2.2", 4 | "description": "simple web app for iTunes Search implemented by React", 5 | "keywords": [ 6 | "react", 7 | "iTunes", 8 | "search", 9 | "apple", 10 | "babel", 11 | "ES6" 12 | ], 13 | "repository": { 14 | "type": "git", 15 | "url": "git@github.com:LeoAJ/react-facebook-friends.git" 16 | }, 17 | "author": "Leo Hsieh (http://leoj.js.org)", 18 | "license": "MIT", 19 | "scripts": { 20 | "coverage": "jest --coverage", 21 | "jest:watch": "jest --watch", 22 | "test": "yarn lint && yarn flow && yarn jest", 23 | "jest:update": "jest --u test/unit", 24 | "jest": "jest test/unit", 25 | "flow": "flow", 26 | "clean": "rimraf dist/", 27 | "build": "webpack --config ./webpack/webpack.config.babel.js --progress --colors --define process.env.NODE_ENV='\"production\"'", 28 | "dev": "webpack-dev-server --config ./webpack/webpack.config.dev.js", 29 | "deploy": "yarn clean && yarn build", 30 | "lint": "eslint src/** webpack/** test/**", 31 | "start": "yarn dev" 32 | }, 33 | "dependencies": { 34 | "autoprefixer": "^7.1.6", 35 | "babel-polyfill": "^6.26.0", 36 | "classnames": "^2.2.3", 37 | "fecha": "^2.2.0", 38 | "react": "^16.0.0", 39 | "react-dom": "^16.0.0", 40 | "react-hot-loader": "^3.0.0-beta.6" 41 | }, 42 | "devDependencies": { 43 | "babel-core": "^6.7.2", 44 | "babel-eslint": "^8.0.1", 45 | "babel-jest": "^21.2.0", 46 | "babel-loader": "^7.0.0", 47 | "babel-preset-env": "^1.6.0", 48 | "babel-preset-react": "^6.5.0", 49 | "babel-preset-stage-0": "^6.5.0", 50 | "css-loader": "^0.28.1", 51 | "enzyme": "^3.1.0", 52 | "enzyme-adapter-react-16": "^1.0.2", 53 | "enzyme-to-json": "^3.1.4", 54 | "eslint": "^4.8.0", 55 | "eslint-config-airbnb": "^16.0.0", 56 | "eslint-plugin-import": "^2.7.0", 57 | "eslint-plugin-jsx-a11y": "^6.0.2", 58 | "eslint-plugin-react": "^7.4.0", 59 | "file-loader": "^1.1.4", 60 | "flow-bin": "^0.59.0", 61 | "identity-obj-proxy": "^3.0.0", 62 | "imports-loader": "^0.7.0", 63 | "jest": "^21.2.1", 64 | "postcss-loader": "^2.0.3", 65 | "prettier": "^1.3.1", 66 | "raf": "^3.4.0", 67 | "rimraf": "^2.5.0", 68 | "style-loader": "^0.19.0", 69 | "url-loader": "^0.6.2", 70 | "webpack": "^3.6.0", 71 | "webpack-dev-server": "^2.4.2" 72 | }, 73 | "jest": { 74 | "setupFiles": [ 75 | "raf/polyfill", 76 | "./test/unit/jest-setup.js" 77 | ], 78 | "collectCoverageFrom": [ 79 | "src/**.js", 80 | "!src/index.js", 81 | "!src/type.js" 82 | ], 83 | "snapshotSerializers": [ 84 | "enzyme-to-json/serializer" 85 | ], 86 | "moduleNameMapper": { 87 | "\\.(css)$": "identity-obj-proxy" 88 | }, 89 | "testPathIgnorePatterns": [ 90 | "/node_modules/" 91 | ] 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/components/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Header from './Header'; 3 | import Container from './Container'; 4 | import Ribbon from './Ribbon'; 5 | import '../style/App.css'; 6 | 7 | const App = () => { 8 | return ( 9 |
10 |
11 | 12 | 13 |
14 | ); 15 | }; 16 | 17 | export default App; 18 | -------------------------------------------------------------------------------- /src/components/Container.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import React from 'react'; 3 | import List from './List'; 4 | import Message from './Message'; 5 | import emitter from '../emitter'; 6 | import { getApiUrl } from '../utils'; 7 | import type { HeaderState, ContainerState } from '../type'; 8 | 9 | class Container extends React.PureComponent<{}, ContainerState> { 10 | state: ContainerState = { 11 | status: 'init', 12 | data: {} 13 | }; 14 | 15 | async getSearchResult(headerState: HeaderState) { 16 | try { 17 | this.setState({ status: 'loading' }); 18 | const resp = await fetch(getApiUrl(headerState)); 19 | const json = await resp.json(); 20 | this.setState({ 21 | data: { ...json }, 22 | status: json.resultCount ? '' : 'noContent' 23 | }); 24 | } catch (e) { 25 | this.setState({ status: 'error' }); 26 | } 27 | } 28 | 29 | componentDidMount() { 30 | emitter.on('search', this.getSearchResult.bind(this)); 31 | } 32 | 33 | componentWillUnmount() { 34 | emitter.removeListener('search'); 35 | } 36 | 37 | render() { 38 | const { status, data } = this.state; 39 | return ( 40 |
41 | {status.length ? : } 42 |
43 | ); 44 | } 45 | } 46 | 47 | export default Container; 48 | -------------------------------------------------------------------------------- /src/components/Header.js: -------------------------------------------------------------------------------- 1 | /* global $ */ 2 | // @flow 3 | /* eslint-disable jsx-a11y/anchor-is-valid, jsx-a11y/click-events-have-key-events */ 4 | import React from 'react'; 5 | import emitter from '../emitter'; 6 | import type { HeaderState, SearchOption } from '../type'; 7 | import '../style/Header.css'; 8 | 9 | const options: Array = [ 10 | 'All', 11 | 'Audiobook', 12 | 'eBook', 13 | 'Movie', 14 | 'Music', 15 | 'Music Video', 16 | 'Podcast', 17 | 'TV Show', 18 | 'Software' 19 | ]; 20 | 21 | class Header extends React.PureComponent<{}, HeaderState> { 22 | emitSearch: () => void; 23 | _onKeyUp: (e: Object) => void; 24 | _onClick: (e: Object) => void; 25 | _update: (e: Object) => Function; 26 | ticking: boolean; 27 | rAf: any; 28 | 29 | state: HeaderState = { 30 | media: 'All', 31 | query: '' 32 | }; 33 | 34 | constructor(props: Object) { 35 | super(props); 36 | this.ticking = false; 37 | this.rAf = null; 38 | this.emitSearch = () => emitter.emit('search', this.state); 39 | this._onClick = e => this.setState( 40 | { media: e.target.textContent }, 41 | () => (this.state.query.length ? this.emitSearch() : null) 42 | ); 43 | this._update = ({ keyCode, target: { value: query } }) => _ => { 44 | this.setState({ query }, () => keyCode === 13 && this.emitSearch()); 45 | this.ticking = false; 46 | }; 47 | this._onKeyUp = e => { 48 | if (!this.ticking) { 49 | this.rAf = window.requestAnimationFrame(this._update(e)); 50 | this.ticking = true; 51 | } 52 | }; 53 | } 54 | 55 | renderSearchOption = () => options.map(opt => 56 |
  • 60 | 65 | {opt} 66 | 67 |
  • ); 68 | 69 | componentWillUnmount() { 70 | window.cancelAnimationFrame(this.rAf); 71 | } 72 | 73 | render() { 74 | return ( 75 |
    76 | 101 |
    102 | ); 103 | } 104 | } 105 | 106 | export default Header; 107 | -------------------------------------------------------------------------------- /src/components/Item.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import React, { PropTypes } from 'react'; 3 | import { getKind, kindColorMap } from '../utils'; 4 | import type { SearchResult } from '../type'; 5 | import '../style/Item.css'; 6 | 7 | const Item = (props: SearchResult) => ( 8 |
    9 |
    10 |
    11 | img 16 |
    17 |
    18 | {props.trackName || props.collectionName}more_vert 19 |

    20 | 21 | more 22 | 23 | { 24 | getKind(props.kind).length ? 25 |

    26 | {getKind(props.kind)} 27 |

    : null 28 | } 29 |

    30 |
    31 |
    32 | {props.trackName || props.collectionName}close 33 |

    {props.longDescription || props.description || 'No description.'}

    34 |
    35 |
    36 |
    37 | ); 38 | 39 | export default Item; 40 | -------------------------------------------------------------------------------- /src/components/List.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import React, { PropTypes } from 'react'; 3 | import Item from './Item'; 4 | import type { SearchResult } from '../type'; 5 | import '../style/List.css'; 6 | 7 | const List = ({ 8 | results, 9 | resultCount 10 | }: { 11 | results: Array, 12 | resultCount: number 13 | }) => ( 14 |
    15 | {resultCount > 0 ? results.map((item, i) => ) : null} 16 |
    17 | ); 18 | 19 | export default List; 20 | -------------------------------------------------------------------------------- /src/components/Message.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import React, { PropTypes } from 'react'; 3 | import '../style/Message.css'; 4 | 5 | const msgMap: Object = { 6 | init: { 7 | icon: 'music_note', 8 | msg: 'Welcome back!' 9 | }, 10 | loading: { 11 | msg: 'Loading...' 12 | }, 13 | noContent: { 14 | icon: 'info', 15 | msg: 'No match' 16 | }, 17 | error: { 18 | icon: 'error', 19 | msg: 'Error!' 20 | } 21 | }; 22 | 23 | const spinner = _ => ( 24 |
    25 |
    26 |
    27 |
    28 |
    29 |
    30 |
    31 |
    32 |
    33 |
    34 |
    35 |
    36 |
    37 |
    38 | ); 39 | 40 | const Message = ({ status }: { status: string }) => ( 41 |
    42 | {status === 'loading' ? spinner() : {msgMap[status].icon}} 43 | {msgMap[status].msg} 44 |
    45 | ); 46 | 47 | export default Message; 48 | -------------------------------------------------------------------------------- /src/components/Ribbon.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import '../style/Ribbon.css'; 3 | 4 | const Ribbon = () => ( 5 | 12 | 31 | 32 | ); 33 | 34 | export default Ribbon; 35 | -------------------------------------------------------------------------------- /src/emitter.js: -------------------------------------------------------------------------------- 1 | import EventEmitter from 'events'; 2 | 3 | export default new EventEmitter(); 4 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import 'babel-polyfill'; 2 | import { render } from 'react-dom'; 3 | import { AppContainer } from 'react-hot-loader'; 4 | import React from 'react'; 5 | import App from './components/App'; 6 | 7 | const renderApp = Comp => render( 8 | 9 | 10 | , 11 | document.querySelector('#itunes-search') 12 | ); 13 | 14 | renderApp(App); 15 | 16 | if (module.hot) { 17 | module.hot.accept('./components/App', _ => renderApp(App)); 18 | } 19 | -------------------------------------------------------------------------------- /src/style/App.css: -------------------------------------------------------------------------------- 1 | body { 2 | background: #eeeeee; 3 | } 4 | -------------------------------------------------------------------------------- /src/style/Header.css: -------------------------------------------------------------------------------- 1 | .dropdown-wrapper { 2 | text-align: center; 3 | position: relative; 4 | width: 100px; 5 | left: 60px; 6 | display: block; 7 | padding: 0 10px; 8 | } 9 | 10 | .dropdown-wrapper:hover { 11 | background: #009688; 12 | } 13 | 14 | .nav-wrapper { 15 | display: flex; 16 | justify-content: center; 17 | } 18 | 19 | .header-search-wrapper { 20 | position: relative; 21 | width: 30vw; 22 | } 23 | 24 | .header-search-wrapper i { 25 | position: absolute; 26 | left: 12px; 27 | } 28 | 29 | .header-search-wrapper input.search-input { 30 | background: rgba(255, 255, 255, .3); 31 | border-radius: 3px; 32 | padding-left: 50px; 33 | } 34 | 35 | .btn-wrapper { 36 | margin: auto 10px; 37 | } 38 | 39 | ul li.select { 40 | font-weight: 600; 41 | background: #e0e0e0; 42 | } 43 | -------------------------------------------------------------------------------- /src/style/Item.css: -------------------------------------------------------------------------------- 1 | .card-wrapper { 2 | display: inline-block; 3 | width: 300px; 4 | } 5 | 6 | .kind { 7 | padding: 2px 4px; 8 | font-weight: 300; 9 | font-size: 0.8em; 10 | border-radius: 2px; 11 | } 12 | -------------------------------------------------------------------------------- /src/style/List.css: -------------------------------------------------------------------------------- 1 | .list-wrapper { 2 | padding: 10px 45px; 3 | /* column width */ 4 | column-width: 300px; 5 | /* space between columns */ 6 | column-gap: 2px; 7 | } 8 | -------------------------------------------------------------------------------- /src/style/Message.css: -------------------------------------------------------------------------------- 1 | #itunes-search .toast { 2 | justify-content: baseline; 3 | width: 200px; 4 | margin: 0 auto; 5 | } 6 | 7 | .info { 8 | background: rgb(50, 50, 50); 9 | color: white; 10 | } 11 | 12 | .text { 13 | margin-left: 10px; 14 | } 15 | 16 | /* from: http://tobiasahlin.com/spinkit/ */ 17 | .sk-circle { 18 | width: 30px; 19 | height: 30px; 20 | position: relative; 21 | } 22 | .sk-circle .sk-child { 23 | width: 100%; 24 | height: 100%; 25 | position: absolute; 26 | left: 0; 27 | top: 0; 28 | } 29 | .sk-circle .sk-child:before { 30 | content: ''; 31 | display: block; 32 | margin: 0 auto; 33 | width: 15%; 34 | height: 15%; 35 | background-color: white; 36 | border-radius: 100%; 37 | -webkit-animation: sk-circleBounceDelay 1.2s infinite ease-in-out both; 38 | animation: sk-circleBounceDelay 1.2s infinite ease-in-out both; 39 | } 40 | .sk-circle .sk-circle2 { 41 | -webkit-transform: rotate(30deg); 42 | -ms-transform: rotate(30deg); 43 | transform: rotate(30deg); } 44 | .sk-circle .sk-circle3 { 45 | -webkit-transform: rotate(60deg); 46 | -ms-transform: rotate(60deg); 47 | transform: rotate(60deg); } 48 | .sk-circle .sk-circle4 { 49 | -webkit-transform: rotate(90deg); 50 | -ms-transform: rotate(90deg); 51 | transform: rotate(90deg); } 52 | .sk-circle .sk-circle5 { 53 | -webkit-transform: rotate(120deg); 54 | -ms-transform: rotate(120deg); 55 | transform: rotate(120deg); } 56 | .sk-circle .sk-circle6 { 57 | -webkit-transform: rotate(150deg); 58 | -ms-transform: rotate(150deg); 59 | transform: rotate(150deg); } 60 | .sk-circle .sk-circle7 { 61 | -webkit-transform: rotate(180deg); 62 | -ms-transform: rotate(180deg); 63 | transform: rotate(180deg); } 64 | .sk-circle .sk-circle8 { 65 | -webkit-transform: rotate(210deg); 66 | -ms-transform: rotate(210deg); 67 | transform: rotate(210deg); } 68 | .sk-circle .sk-circle9 { 69 | -webkit-transform: rotate(240deg); 70 | -ms-transform: rotate(240deg); 71 | transform: rotate(240deg); } 72 | .sk-circle .sk-circle10 { 73 | -webkit-transform: rotate(270deg); 74 | -ms-transform: rotate(270deg); 75 | transform: rotate(270deg); } 76 | .sk-circle .sk-circle11 { 77 | -webkit-transform: rotate(300deg); 78 | -ms-transform: rotate(300deg); 79 | transform: rotate(300deg); } 80 | .sk-circle .sk-circle12 { 81 | -webkit-transform: rotate(330deg); 82 | -ms-transform: rotate(330deg); 83 | transform: rotate(330deg); } 84 | .sk-circle .sk-circle2:before { 85 | -webkit-animation-delay: -1.1s; 86 | animation-delay: -1.1s; } 87 | .sk-circle .sk-circle3:before { 88 | -webkit-animation-delay: -1s; 89 | animation-delay: -1s; } 90 | .sk-circle .sk-circle4:before { 91 | -webkit-animation-delay: -0.9s; 92 | animation-delay: -0.9s; } 93 | .sk-circle .sk-circle5:before { 94 | -webkit-animation-delay: -0.8s; 95 | animation-delay: -0.8s; } 96 | .sk-circle .sk-circle6:before { 97 | -webkit-animation-delay: -0.7s; 98 | animation-delay: -0.7s; } 99 | .sk-circle .sk-circle7:before { 100 | -webkit-animation-delay: -0.6s; 101 | animation-delay: -0.6s; } 102 | .sk-circle .sk-circle8:before { 103 | -webkit-animation-delay: -0.5s; 104 | animation-delay: -0.5s; } 105 | .sk-circle .sk-circle9:before { 106 | -webkit-animation-delay: -0.4s; 107 | animation-delay: -0.4s; } 108 | .sk-circle .sk-circle10:before { 109 | -webkit-animation-delay: -0.3s; 110 | animation-delay: -0.3s; } 111 | .sk-circle .sk-circle11:before { 112 | -webkit-animation-delay: -0.2s; 113 | animation-delay: -0.2s; } 114 | .sk-circle .sk-circle12:before { 115 | -webkit-animation-delay: -0.1s; 116 | animation-delay: -0.1s; } 117 | 118 | @-webkit-keyframes sk-circleBounceDelay { 119 | 0%, 80%, 100% { 120 | -webkit-transform: scale(0); 121 | transform: scale(0); 122 | } 40% { 123 | -webkit-transform: scale(1); 124 | transform: scale(1); 125 | } 126 | } 127 | 128 | @keyframes sk-circleBounceDelay { 129 | 0%, 80%, 100% { 130 | -webkit-transform: scale(0); 131 | transform: scale(0); 132 | } 40% { 133 | -webkit-transform: scale(1); 134 | transform: scale(1); 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/style/Ribbon.css: -------------------------------------------------------------------------------- 1 | .github-corner { 2 | position: fixed; 3 | top: 0; 4 | right: 0; 5 | z-index: 1000; 6 | } 7 | 8 | .github-corner:hover .octo-arm { 9 | animation: octocat-wave 560ms ease-in-out; 10 | } 11 | 12 | .svg { 13 | z-index: 1000; 14 | fill: #FD6C6C; 15 | color: #fff; 16 | position: absolute; 17 | top: 0; 18 | border: 0; 19 | right: 0; 20 | } 21 | 22 | .path { 23 | transform-origin: 130px 106px; 24 | } 25 | 26 | @keyframes octocat-wave { 27 | 0%, 28 | 100% { 29 | transform: rotate(0); 30 | } 31 | 20%, 32 | 60% { 33 | transform: rotate(-25deg); 34 | } 35 | 40%, 36 | 80% { 37 | transform: rotate(10deg); 38 | } 39 | } 40 | @media (max-width: 500px) { 41 | .github-corner:hover .octo-arm { 42 | animation: none; 43 | } 44 | .github-corner .octo-arm { 45 | animation: octocat-wave 560ms ease-in-out; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/type.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | export type ContainerState = { 3 | status: 'init' | 'loading' | 'noContent' | 'error' | '', 4 | data?: Object 5 | }; 6 | 7 | export type SearchOption = 'All' | 'Audiobook' | 'eBook' | 'Movie' | 'Music' | 'Music Video' | 'Podcast' | 'TV Show' | 'Short Film' | 'Software'; 8 | 9 | export type HeaderState = { 10 | media: SearchOption, 11 | query: string 12 | }; 13 | 14 | export type SearchResult = { 15 | trackId: number, 16 | trackPrice?: number, 17 | collectionPrice?: number, 18 | price?: number, 19 | trackViewUrl?: string, 20 | collectionViewUrl?: string, 21 | artworkUrl100: string, 22 | trackName?: string, 23 | collectionName?: string, 24 | kind: string, 25 | longDescription?: string, 26 | description?: string 27 | }; 28 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import type { HeaderState } from './type'; 3 | 4 | export const kindColorMap: Object = { 5 | song: 'teal', 6 | 'feature-movie': 'pink', 7 | ebook: 'blue', 8 | 'music-video': 'orange', 9 | podcast: 'green', 10 | 'tv-episode': 'red', 11 | software: 'indigo' 12 | }; 13 | 14 | export const capitalize = (str: string): string => (`${str[0].toUpperCase()}${str.slice(1).toLowerCase()}`); 15 | 16 | export function getMedia(str: string): string { 17 | if (str.indexOf(' ') === -1) { 18 | return str.toLowerCase(); 19 | } 20 | const sg = str.split(' '); 21 | return `${sg[0].toLowerCase()}${capitalize(sg[1])}`; 22 | } 23 | 24 | export const getApiUrl = ({ 25 | media, 26 | query 27 | }: HeaderState) => `https://itunes.apple.com/search?media=${getMedia(media || 'all')}&term=${query.split(' ').join('+')}`; 28 | 29 | export function getKind(str: string): string { 30 | if (typeof str !== 'string') { 31 | return ''; 32 | } 33 | 34 | if (str === 'tv') { 35 | return 'TV'; 36 | } else if (str === 'feature') { 37 | return ''; 38 | } 39 | 40 | if (str.indexOf('-') === -1) { 41 | return capitalize(str); 42 | } 43 | const sg = str.split('-'); 44 | return `${getKind(sg[0])} ${capitalize(sg[1])}`.trim(); 45 | } 46 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends" : "../.eslintrc", 3 | "env" : { 4 | "jest" : true 5 | }, 6 | "rules": { 7 | "max-len": 0 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /test/unit/__snapshots__/utils.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`utils.js capitalize should be able to capitalize words 1`] = `"Abc"`; 4 | 5 | exports[`utils.js capitalize should be able to capitalize words 2`] = `"Abc"`; 6 | 7 | exports[`utils.js capitalize should be able to capitalize words 3`] = `"Abc"`; 8 | 9 | exports[`utils.js getApiUrl should return correct API endpoint 1`] = `"https://itunes.apple.com/search?media=music&term=bsb"`; 10 | 11 | exports[`utils.js getApiUrl should return correct API endpoint without media type 1`] = `"https://itunes.apple.com/search?media=all&term=bsb"`; 12 | 13 | exports[`utils.js getKind should return correct format for UI 1`] = `"Movie"`; 14 | 15 | exports[`utils.js getKind should return correct format for UI 2`] = `"TV"`; 16 | 17 | exports[`utils.js getKind should return correct format for UI 3`] = `"Song"`; 18 | 19 | exports[`utils.js getMedia should return proper term for API usage 1`] = `"tvShow"`; 20 | -------------------------------------------------------------------------------- /test/unit/components/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import App from '../../../src/components/App'; 4 | 5 | describe('', () => { 6 | test('App component should renders correctly', () => { 7 | const component = shallow(); 8 | expect(component).toMatchSnapshot(); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /test/unit/components/Container.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import Container from '../../../src/components/Container'; 4 | 5 | const mockData = { 6 | resultCount: 1, 7 | results: [ 8 | { 9 | trackId: '1', 10 | trackPrice: 0.99, 11 | artworkUrl100: '', 12 | trackName: '', 13 | description: '' 14 | } 15 | ] 16 | }; 17 | 18 | const mockResponse = { 19 | ok: true, 20 | json: _ => mockData 21 | }; 22 | 23 | global.fetch = jest.fn().mockImplementation(_ => new Promise((resolve, reject) => resolve(mockResponse))); 24 | 25 | describe('', () => { 26 | let component; 27 | beforeEach(() => { 28 | component = shallow(); 29 | }); 30 | test('init state', () => { 31 | expect(component).toMatchSnapshot(); 32 | }); 33 | test('loading state', () => { 34 | component.setState({ status: 'loading' }); 35 | expect(component).toMatchSnapshot(); 36 | }); 37 | test('show data', () => { 38 | const data = { 39 | resultCount: 2, 40 | results: [ 41 | { 42 | trackName: 'test1' 43 | }, 44 | { 45 | trackName: 'test2' 46 | } 47 | ] 48 | }; 49 | component.setState({ 50 | status: '', 51 | data 52 | }); 53 | expect(component).toMatchSnapshot(); 54 | }); 55 | test('no search results state', () => { 56 | component.setState({ status: 'noContent' }); 57 | expect(component).toMatchSnapshot(); 58 | }); 59 | test('error state', () => { 60 | component.setState({ status: 'error' }); 61 | expect(component).toMatchSnapshot(); 62 | }); 63 | 64 | test('getSearchResult should fetch data', async () => { 65 | const state = { 66 | media: 'music', 67 | query: 'bsb' 68 | }; 69 | const instance = component.instance(); 70 | await instance.getSearchResult(state); 71 | expect(component.state()).toMatchSnapshot(); 72 | }); 73 | 74 | test('should set error state if there is a fetch error', async () => { 75 | const instance = component.instance(); 76 | await instance.getSearchResult(); 77 | expect(component.state()).toMatchSnapshot(); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /test/unit/components/Header.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import Header from '../../../src/components/Header'; 4 | 5 | describe('
    ', () => { 6 | let component; 7 | let instance; 8 | let mockEvent; 9 | beforeEach(() => { 10 | mockEvent = { 11 | keyCode: 13, 12 | target: { 13 | value: 'test' 14 | } 15 | }; 16 | component = shallow(
    ); 17 | instance = component.instance(); 18 | }); 19 | test('Header component should renders correctly', () => { 20 | expect(component).toMatchSnapshot(); 21 | }); 22 | test('_onKeyUp should start requestAnimationFrame', () => { 23 | instance._onKeyUp(mockEvent); 24 | expect(instance.ticking).toBeTruthy(); 25 | expect(instance.rAf).toBeDefined(); 26 | }); 27 | test('_update should set new state', () => { 28 | instance._update(mockEvent)(); 29 | expect(component.state().query).toBe('test'); 30 | expect(instance.ticking).toBeFalsy(); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /test/unit/components/Item.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import Item from '../../../src/components/Item'; 4 | 5 | describe('', () => { 6 | test('Item component should renders correctly', () => { 7 | const props = { 8 | artworkUrl100: '/100x100/', 9 | trackName: '', 10 | trackViewUrl: '', 11 | longDescription: '' 12 | }; 13 | const component = shallow(); 14 | expect(component).toMatchSnapshot(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /test/unit/components/List.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import List from '../../../src/components/List'; 4 | 5 | describe('', () => { 6 | test('List component should renders correctly', () => { 7 | const props = { 8 | resultCount: 2, 9 | results: [ 10 | { 11 | trackId: '1', 12 | trackPrice: 0.99, 13 | artworkUrl100: '', 14 | trackName: '', 15 | description: '' 16 | }, 17 | { 18 | trackId: '2', 19 | trackPrice: 1.99, 20 | artworkUrl100: '', 21 | trackName: '', 22 | description: '' 23 | } 24 | ] 25 | }; 26 | const component = shallow(); 27 | expect(component).toMatchSnapshot(); 28 | }); 29 | 30 | test('no results', () => { 31 | const props = { 32 | resultCount: 0, 33 | results: [] 34 | }; 35 | const component = shallow(); 36 | expect(component).toMatchSnapshot(); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /test/unit/components/Message.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import Message from '../../../src/components/Message'; 4 | 5 | describe('', () => { 6 | test('Message component should renders correctly', () => { 7 | const props = { 8 | status: 'loading' 9 | }; 10 | const component = shallow(); 11 | expect(component).toMatchSnapshot(); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /test/unit/components/Ribbon.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import Ribbon from '../../../src/components/Ribbon'; 4 | 5 | describe('', () => { 6 | test('Ribbon component should renders correctly', () => { 7 | const component = shallow(); 8 | expect(component).toMatchSnapshot(); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /test/unit/components/__snapshots__/App.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` App component should renders correctly 1`] = ` 4 |
    5 |
    6 | 7 | 8 |
    9 | `; 10 | -------------------------------------------------------------------------------- /test/unit/components/__snapshots__/Container.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` error state 1`] = ` 4 |
    5 | 8 |
    9 | `; 10 | 11 | exports[` getSearchResult should fetch data 1`] = ` 12 | Object { 13 | "data": Object { 14 | "resultCount": 1, 15 | "results": Array [ 16 | Object { 17 | "artworkUrl100": "", 18 | "description": "", 19 | "trackId": "1", 20 | "trackName": "", 21 | "trackPrice": 0.99, 22 | }, 23 | ], 24 | }, 25 | "status": "", 26 | } 27 | `; 28 | 29 | exports[` init state 1`] = ` 30 |
    31 | 34 |
    35 | `; 36 | 37 | exports[` loading state 1`] = ` 38 |
    39 | 42 |
    43 | `; 44 | 45 | exports[` no search results state 1`] = ` 46 |
    47 | 50 |
    51 | `; 52 | 53 | exports[` should set error state if there is a fetch error 1`] = ` 54 | Object { 55 | "data": Object {}, 56 | "status": "error", 57 | } 58 | `; 59 | 60 | exports[` show data 1`] = ` 61 |
    62 | 75 |
    76 | `; 77 | -------------------------------------------------------------------------------- /test/unit/components/__snapshots__/Header.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`
    Header component should renders correctly 1`] = ` 4 |
    7 | 152 |
    153 | `; 154 | -------------------------------------------------------------------------------- /test/unit/components/__snapshots__/Item.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` Item component should renders correctly 1`] = ` 4 |
    7 |
    10 |
    13 | img 18 |
    19 |
    22 | 25 | 28 | more_vert 29 | 30 | 31 |

    32 | 35 | more 36 | 37 |

    38 |
    39 |
    42 | 45 | 48 | close 49 | 50 | 51 |

    52 | No description. 53 |

    54 |
    55 |
    56 |
    57 | `; 58 | -------------------------------------------------------------------------------- /test/unit/components/__snapshots__/List.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` List component should renders correctly 1`] = ` 4 |
    7 | 15 | 23 |
    24 | `; 25 | 26 | exports[` no results 1`] = ` 27 |
    30 | `; 31 | -------------------------------------------------------------------------------- /test/unit/components/__snapshots__/Message.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` Message component should renders correctly 1`] = ` 4 |
    7 |
    10 |
    13 |
    16 |
    19 |
    22 |
    25 |
    28 |
    31 |
    34 |
    37 |
    40 |
    43 |
    46 |
    47 | 50 | Loading... 51 | 52 |
    53 | `; 54 | -------------------------------------------------------------------------------- /test/unit/components/__snapshots__/Ribbon.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` Ribbon component should renders correctly 1`] = ` 4 | 11 | 32 | 33 | `; 34 | -------------------------------------------------------------------------------- /test/unit/jest-setup.js: -------------------------------------------------------------------------------- 1 | import Enzyme, { shallow, render, mount } from 'enzyme'; 2 | import Adapter from 'enzyme-adapter-react-16'; 3 | 4 | Enzyme.configure({ adapter: new Adapter() }); 5 | -------------------------------------------------------------------------------- /test/unit/utils.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | capitalize, 3 | getApiUrl, 4 | getMedia, 5 | getKind 6 | } from '../../src/utils'; 7 | 8 | describe('utils.js', () => { 9 | test('capitalize should be able to capitalize words', () => { 10 | const result = 'Abc'; 11 | expect(capitalize('abc')).toMatchSnapshot(); 12 | expect(capitalize('aBc')).toMatchSnapshot(); 13 | expect(capitalize('ABC')).toMatchSnapshot(); 14 | }); 15 | 16 | test('getMedia should return proper term for API usage', () => { 17 | expect(getMedia('TV Show')).toMatchSnapshot(); 18 | }); 19 | 20 | test('getApiUrl should return correct API endpoint', () => { 21 | expect(getApiUrl({ 22 | media: 'music', 23 | query: 'bsb' 24 | })).toMatchSnapshot(); 25 | }); 26 | 27 | test('getApiUrl should return correct API endpoint without media type', () => { 28 | expect(getApiUrl({ 29 | query: 'bsb' 30 | })).toMatchSnapshot(); 31 | }); 32 | 33 | test('getKind should return correct format for UI', () => { 34 | expect(getKind('feature-movie')).toMatchSnapshot(); 35 | expect(getKind('tv')).toMatchSnapshot(); 36 | expect(getKind('song')).toMatchSnapshot(); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /webpack/base.js: -------------------------------------------------------------------------------- 1 | const autoprefixer = require('autoprefixer'); 2 | 3 | module.exports = { 4 | module: { 5 | rules: [{ 6 | test: /\.css$/, 7 | exclude: /node_modules/, 8 | use: [ 9 | 'style-loader', 10 | 'css-loader', 11 | { 12 | loader: 'postcss-loader', 13 | options: { 14 | plugins: _ => [autoprefixer({ browsers: ['last 2 versions'] })] 15 | } 16 | } 17 | ] 18 | }, { 19 | test: /\.(woff|woff2|ttf|svg)$/, 20 | exclude: /node_modules/, 21 | use: [ 22 | 'url-loader?limit=100000' 23 | ], 24 | }, { 25 | test: /\.(eot|png)$/, 26 | exclude: /node_modules/, 27 | use: [ 28 | 'file-loader' 29 | ] 30 | }] 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /webpack/webpack.config.babel.js: -------------------------------------------------------------------------------- 1 | // import path from 'path'; 2 | // import webpack from 'webpack'; 3 | // import webpackConfig from './base'; 4 | 5 | const path = require('path'); 6 | const webpack = require('webpack'); 7 | const webpackConfig = require('./base'); 8 | 9 | webpackConfig.module.rules.push({ 10 | test: /\.js$/, 11 | exclude: /node_modules/, 12 | use: ['babel-loader'] 13 | }); 14 | 15 | module.exports = Object.assign({}, webpackConfig, { 16 | devtool: 'source-map', 17 | entry: './src', 18 | output: { 19 | path: path.resolve(__dirname, '../', 'dist/'), 20 | filename: 'bundle.js' 21 | }, 22 | plugins: [ 23 | new webpack.optimize.UglifyJsPlugin({ 24 | output: { 25 | comments: false 26 | }, 27 | compress: { 28 | warnings: false 29 | } 30 | }) 31 | ] 32 | }); 33 | -------------------------------------------------------------------------------- /webpack/webpack.config.dev.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | const webpackConfig = require('./base'); 4 | 5 | webpackConfig.module.rules.push({ 6 | test: /\.js$/, 7 | exclude: /node_modules/, 8 | use: [ 9 | 'react-hot-loader/webpack', 10 | 'babel-loader' 11 | ] 12 | }); 13 | 14 | module.exports = Object.assign({}, webpackConfig, { 15 | devtool: 'cheap-module-eval-source-map', 16 | entry: [ 17 | 'babel-polyfill', 18 | 'react-hot-loader/patch', 19 | 'webpack-dev-server/client?http://localhost:3000', 20 | 'webpack/hot/only-dev-server', 21 | './src' 22 | ], 23 | output: { 24 | publicPath: '/dist/', 25 | path: path.resolve(__dirname, '../', 'dist/'), 26 | filename: 'bundle.js' 27 | }, 28 | plugins: [ 29 | new webpack.HotModuleReplacementPlugin(), 30 | new webpack.NamedModulesPlugin() 31 | ], 32 | devServer: { 33 | port: 3000, 34 | hot: true, 35 | compress: false, 36 | historyApiFallback: true, 37 | stats: { 38 | colors: true, 39 | timings: true, 40 | version: true, 41 | warnings: true 42 | } 43 | } 44 | }); 45 | --------------------------------------------------------------------------------