Compare commits
24 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 45a6ea25c8 | |||
| da4f93a30b | |||
| bff9b2fdd7 | |||
|
|
ea6a48deef | ||
|
|
e0a39dd4d8 | ||
|
|
ecaa1271bf | ||
| 3839813a0a | |||
|
|
f5f5d61413 | ||
| 9bcf0ecc56 | |||
| 755cf14828 | |||
| 4e28567959 | |||
|
|
6ba958fafa | ||
|
|
cb9f3ec3d1 | ||
|
|
39f0542ef2 | ||
|
|
93358ac607 | ||
| e04e04bee9 | |||
|
|
055511e555 | ||
|
|
d447143312 | ||
|
|
1ceb248df9 | ||
|
|
60e70f4b34 | ||
|
|
c11b1d4653 | ||
|
|
3f0ac404f0 | ||
|
|
4266dbcf6b | ||
|
|
d722027781 |
2
.babelrc
|
|
@ -1,3 +1,3 @@
|
||||||
{
|
{
|
||||||
"presets": ["react", "es2015", "stage-1"]
|
"presets": ["@babel/preset-env", "@babel/react"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
15
.editorconfig
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
# Based on the React EditorConfig file. See:
|
||||||
|
# - http://editorconfig.org
|
||||||
|
# - https://github.com/facebook/react/blob/master/.editorconfig
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.{html,js,jsx,json,scss,css,yml}]
|
||||||
|
indent_size = 4
|
||||||
216
.eslintrc.js
Normal file
|
|
@ -0,0 +1,216 @@
|
||||||
|
var OFF = 0, WARN = 1, ERROR = 2;
|
||||||
|
|
||||||
|
module.exports = exports = {
|
||||||
|
"env": {
|
||||||
|
"es6": true
|
||||||
|
},
|
||||||
|
|
||||||
|
"ecmaFeatures": {
|
||||||
|
// env=es6 doesn't include modules, which we are using
|
||||||
|
"modules": true
|
||||||
|
},
|
||||||
|
|
||||||
|
"extends": "eslint:recommended",
|
||||||
|
|
||||||
|
"rules": {
|
||||||
|
// Possible Errors (overrides from recommended set)
|
||||||
|
"no-extra-parens": ERROR,
|
||||||
|
"no-unexpected-multiline": ERROR,
|
||||||
|
// All JSDoc comments must be valid
|
||||||
|
"valid-jsdoc": [ ERROR, {
|
||||||
|
"requireReturn": false,
|
||||||
|
"requireReturnDescription": false,
|
||||||
|
"requireParamDescription": true,
|
||||||
|
"prefer": {
|
||||||
|
"return": "returns"
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
|
||||||
|
// Best Practices
|
||||||
|
|
||||||
|
// Allowed a getter without setter, but all setters require getters
|
||||||
|
"accessor-pairs": [ ERROR, {
|
||||||
|
"getWithoutSet": false,
|
||||||
|
"setWithoutGet": true
|
||||||
|
}],
|
||||||
|
"block-scoped-var": WARN,
|
||||||
|
"consistent-return": ERROR,
|
||||||
|
"curly": ERROR,
|
||||||
|
"default-case": WARN,
|
||||||
|
// the dot goes with the property when doing multiline
|
||||||
|
"dot-location": [ WARN, "property" ],
|
||||||
|
"dot-notation": WARN,
|
||||||
|
"eqeqeq": [ ERROR, "smart" ],
|
||||||
|
"guard-for-in": WARN,
|
||||||
|
"no-alert": ERROR,
|
||||||
|
"no-caller": ERROR,
|
||||||
|
"no-case-declarations": WARN,
|
||||||
|
"no-div-regex": WARN,
|
||||||
|
"no-else-return": WARN,
|
||||||
|
"no-empty-label": WARN,
|
||||||
|
"no-empty-pattern": WARN,
|
||||||
|
"no-eq-null": WARN,
|
||||||
|
"no-eval": ERROR,
|
||||||
|
"no-extend-native": ERROR,
|
||||||
|
"no-extra-bind": WARN,
|
||||||
|
"no-floating-decimal": WARN,
|
||||||
|
"no-implicit-coercion": [ WARN, {
|
||||||
|
"boolean": true,
|
||||||
|
"number": true,
|
||||||
|
"string": true
|
||||||
|
}],
|
||||||
|
"no-implied-eval": ERROR,
|
||||||
|
"no-invalid-this": ERROR,
|
||||||
|
"no-iterator": ERROR,
|
||||||
|
"no-labels": WARN,
|
||||||
|
"no-lone-blocks": WARN,
|
||||||
|
"no-loop-func": ERROR,
|
||||||
|
"no-magic-numbers": WARN,
|
||||||
|
"no-multi-spaces": ERROR,
|
||||||
|
"no-multi-str": WARN,
|
||||||
|
"no-native-reassign": ERROR,
|
||||||
|
"no-new-func": ERROR,
|
||||||
|
"no-new-wrappers": ERROR,
|
||||||
|
"no-new": ERROR,
|
||||||
|
"no-octal-escape": ERROR,
|
||||||
|
"no-param-reassign": ERROR,
|
||||||
|
"no-process-env": WARN,
|
||||||
|
"no-proto": ERROR,
|
||||||
|
"no-redeclare": ERROR,
|
||||||
|
"no-return-assign": ERROR,
|
||||||
|
"no-script-url": ERROR,
|
||||||
|
"no-self-compare": ERROR,
|
||||||
|
"no-throw-literal": ERROR,
|
||||||
|
"no-unused-expressions": ERROR,
|
||||||
|
"no-useless-call": ERROR,
|
||||||
|
"no-useless-concat": ERROR,
|
||||||
|
"no-void": WARN,
|
||||||
|
// Produce warnings when something is commented as TODO or FIXME
|
||||||
|
"no-warning-comments": [ WARN, {
|
||||||
|
"terms": [ "TODO", "FIXME" ],
|
||||||
|
"location": "start"
|
||||||
|
}],
|
||||||
|
"no-with": WARN,
|
||||||
|
"radix": WARN,
|
||||||
|
"vars-on-top": ERROR,
|
||||||
|
// Enforces the style of wrapped functions
|
||||||
|
"wrap-iife": [ ERROR, "outside" ],
|
||||||
|
"yoda": ERROR,
|
||||||
|
|
||||||
|
// Strict Mode - for ES6, never use strict.
|
||||||
|
"strict": [ ERROR, "never" ],
|
||||||
|
|
||||||
|
// Variables
|
||||||
|
"init-declarations": [ ERROR, "always" ],
|
||||||
|
"no-catch-shadow": WARN,
|
||||||
|
"no-delete-var": ERROR,
|
||||||
|
"no-label-var": ERROR,
|
||||||
|
"no-shadow-restricted-names": ERROR,
|
||||||
|
"no-shadow": WARN,
|
||||||
|
// We require all vars to be initialized (see init-declarations)
|
||||||
|
// If we NEED a var to be initialized to undefined, it needs to be explicit
|
||||||
|
"no-undef-init": OFF,
|
||||||
|
"no-undef": ERROR,
|
||||||
|
"no-undefined": OFF,
|
||||||
|
"no-unused-vars": WARN,
|
||||||
|
// Disallow hoisting - let & const don't allow hoisting anyhow
|
||||||
|
"no-use-before-define": ERROR,
|
||||||
|
|
||||||
|
// Node.js and CommonJS
|
||||||
|
"callback-return": [ WARN, [ "callback", "next" ]],
|
||||||
|
"global-require": ERROR,
|
||||||
|
"handle-callback-err": WARN,
|
||||||
|
"no-mixed-requires": WARN,
|
||||||
|
"no-new-require": ERROR,
|
||||||
|
// Use path.concat instead
|
||||||
|
"no-path-concat": ERROR,
|
||||||
|
"no-process-exit": ERROR,
|
||||||
|
"no-restricted-modules": OFF,
|
||||||
|
"no-sync": WARN,
|
||||||
|
|
||||||
|
// ECMAScript 6 support
|
||||||
|
"arrow-body-style": [ ERROR, "always" ],
|
||||||
|
"arrow-parens": [ ERROR, "always" ],
|
||||||
|
"arrow-spacing": [ ERROR, { "before": true, "after": true }],
|
||||||
|
"constructor-super": ERROR,
|
||||||
|
"generator-star-spacing": [ ERROR, "before" ],
|
||||||
|
"no-arrow-condition": ERROR,
|
||||||
|
"no-class-assign": ERROR,
|
||||||
|
"no-const-assign": ERROR,
|
||||||
|
"no-dupe-class-members": ERROR,
|
||||||
|
"no-this-before-super": ERROR,
|
||||||
|
"no-var": WARN,
|
||||||
|
"object-shorthand": [ WARN, "never" ],
|
||||||
|
"prefer-arrow-callback": WARN,
|
||||||
|
"prefer-spread": WARN,
|
||||||
|
"prefer-template": WARN,
|
||||||
|
"require-yield": ERROR,
|
||||||
|
|
||||||
|
// Stylistic - everything here is a warning because of style.
|
||||||
|
"array-bracket-spacing": [ WARN, "always" ],
|
||||||
|
"block-spacing": [ WARN, "always" ],
|
||||||
|
"brace-style": [ WARN, "1tbs", { "allowSingleLine": false } ],
|
||||||
|
"camelcase": WARN,
|
||||||
|
"comma-spacing": [ WARN, { "before": false, "after": true } ],
|
||||||
|
"comma-style": [ WARN, "last" ],
|
||||||
|
"computed-property-spacing": [ WARN, "never" ],
|
||||||
|
"consistent-this": [ WARN, "self" ],
|
||||||
|
"eol-last": WARN,
|
||||||
|
"func-names": WARN,
|
||||||
|
"func-style": [ WARN, "declaration" ],
|
||||||
|
"id-length": [ WARN, { "min": 2, "max": 32 } ],
|
||||||
|
"indent": [ WARN, 4 ],
|
||||||
|
"jsx-quotes": [ WARN, "prefer-double" ],
|
||||||
|
"linebreak-style": [ WARN, "unix" ],
|
||||||
|
"lines-around-comment": [ WARN, { "beforeBlockComment": true } ],
|
||||||
|
"max-depth": [ WARN, 8 ],
|
||||||
|
"max-len": [ WARN, 132 ],
|
||||||
|
"max-nested-callbacks": [ WARN, 8 ],
|
||||||
|
"max-params": [ WARN, 8 ],
|
||||||
|
"new-cap": WARN,
|
||||||
|
"new-parens": WARN,
|
||||||
|
"no-array-constructor": WARN,
|
||||||
|
"no-bitwise": OFF,
|
||||||
|
"no-continue": OFF,
|
||||||
|
"no-inline-comments": OFF,
|
||||||
|
"no-lonely-if": WARN,
|
||||||
|
"no-mixed-spaces-and-tabs": WARN,
|
||||||
|
"no-multiple-empty-lines": WARN,
|
||||||
|
"no-negated-condition": OFF,
|
||||||
|
"no-nested-ternary": WARN,
|
||||||
|
"no-new-object": WARN,
|
||||||
|
"no-plusplus": OFF,
|
||||||
|
"no-spaced-func": WARN,
|
||||||
|
"no-ternary": OFF,
|
||||||
|
"no-trailing-spaces": WARN,
|
||||||
|
"no-underscore-dangle": WARN,
|
||||||
|
"no-unneeded-ternary": WARN,
|
||||||
|
"object-curly-spacing": [ WARN, "always" ],
|
||||||
|
"one-var": OFF,
|
||||||
|
"operator-assignment": [ WARN, "never" ],
|
||||||
|
"operator-linebreak": [ WARN, "after" ],
|
||||||
|
"padded-blocks": [ WARN, "never" ],
|
||||||
|
"quote-props": [ WARN, "consistent-as-needed" ],
|
||||||
|
"quotes": [ WARN, "single" ],
|
||||||
|
"require-jsdoc": [ WARN, {
|
||||||
|
"require": {
|
||||||
|
"FunctionDeclaration": true,
|
||||||
|
"MethodDefinition": true,
|
||||||
|
"ClassDeclaration": false
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
"semi-spacing": [ WARN, { "before": false, "after": true }],
|
||||||
|
"semi": [ ERROR, "always" ],
|
||||||
|
"sort-vars": OFF,
|
||||||
|
"space-after-keywords": [ WARN, "always" ],
|
||||||
|
"space-before-blocks": [ WARN, "always" ],
|
||||||
|
"space-before-function-paren": [ WARN, "never" ],
|
||||||
|
"space-before-keywords": [ WARN, "always" ],
|
||||||
|
"space-in-parens": [ WARN, "never" ],
|
||||||
|
"space-infix-ops": [ WARN, { "int32Hint": true } ],
|
||||||
|
"space-return-throw-case": ERROR,
|
||||||
|
"space-unary-ops": ERROR,
|
||||||
|
"spaced-comment": [ WARN, "always" ],
|
||||||
|
"wrap-regex": WARN
|
||||||
|
}
|
||||||
|
};
|
||||||
9
.gitignore
vendored
|
|
@ -6,3 +6,12 @@ npm-debug.log
|
||||||
# IntelliJ
|
# IntelliJ
|
||||||
*.iml
|
*.iml
|
||||||
/.idea
|
/.idea
|
||||||
|
|
||||||
|
.cache
|
||||||
|
_build
|
||||||
|
.vscode
|
||||||
|
|
||||||
|
docs
|
||||||
|
|
||||||
|
static/albums.json
|
||||||
|
static/covers
|
||||||
35
.stylelintrc.js
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
rules: {
|
||||||
|
"at-rule-no-unknown": true,
|
||||||
|
"block-no-empty": true,
|
||||||
|
"color-no-invalid-hex": true,
|
||||||
|
"comment-no-empty": true,
|
||||||
|
"declaration-block-no-duplicate-properties": [
|
||||||
|
true,
|
||||||
|
{
|
||||||
|
ignore: ["consecutive-duplicates-with-different-values"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"declaration-block-no-shorthand-property-overrides": true,
|
||||||
|
"font-family-no-duplicate-names": true,
|
||||||
|
"font-family-no-missing-generic-family-keyword": true,
|
||||||
|
"function-calc-no-unspaced-operator": true,
|
||||||
|
"function-linear-gradient-no-nonstandard-direction": true,
|
||||||
|
"keyframe-declaration-no-important": true,
|
||||||
|
"media-feature-name-no-unknown": true,
|
||||||
|
"no-descending-specificity": true,
|
||||||
|
"no-duplicate-at-import-rules": true,
|
||||||
|
"no-duplicate-selectors": true,
|
||||||
|
"no-empty-source": true,
|
||||||
|
"no-extra-semicolons": true,
|
||||||
|
"no-invalid-double-slash-comments": true,
|
||||||
|
"property-no-unknown": true,
|
||||||
|
"selector-pseudo-class-no-unknown": true,
|
||||||
|
"selector-pseudo-element-no-unknown": true,
|
||||||
|
"selector-type-no-unknown": true,
|
||||||
|
"string-no-newline": true,
|
||||||
|
"unit-no-unknown": true
|
||||||
|
}
|
||||||
|
};
|
||||||
1
.tool-versions
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
nodejs 12.10.0
|
||||||
8
LICENSE
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
/*
|
||||||
|
* ----------------------------------------------------------------------------
|
||||||
|
* "THE BEER-WARE LICENSE" (Revision 42):
|
||||||
|
* <yttan@fastmail.se> wrote this code. As long as you retain this notice you
|
||||||
|
* can do whatever you want with this stuff. If we meet some day, and you think
|
||||||
|
* this stuff is worth it, you can buy me a beer in return. Anders Ytterström
|
||||||
|
* ----------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
38
README.md
|
|
@ -1,14 +1,34 @@
|
||||||
# Getting started
|
# BRÜTAL LEGEND
|
||||||
|
|
||||||
Install dependencies and start webpack watcher.
|
https://www.youtube.com/embed/VW88ofmfF0w
|
||||||
|
|
||||||
npm install
|
Progress visualisation of the quest to own a vinyl copy of all songs older than 1990 used in [Brütal Legend](https://en.wikipedia.org/wiki/Br%C3%BCtal_Legend), where possible.
|
||||||
npm run watch
|
|
||||||
|
|
||||||
in another terminal, start a web server using python.
|
Also a project for learning
|
||||||
|
|
||||||
python2 -m 'SimpleHTTPServer' 1337
|
- CSS Grid layout,
|
||||||
# or
|
- React,
|
||||||
python3 -m 'http.server' 1337
|
- Redux,
|
||||||
|
- Redux-sagas and
|
||||||
|
- TypeScript.
|
||||||
|
|
||||||
Visit site on http://localhost:1337
|
## Getting started
|
||||||
|
|
||||||
|
Install dependencies and start development server with live reload.
|
||||||
|
|
||||||
|
npm i
|
||||||
|
npm start
|
||||||
|
|
||||||
|
Visit site on http://localhost:1234
|
||||||
|
|
||||||
|
Use `npm run lint` to lint files.
|
||||||
|
|
||||||
|
## Build release
|
||||||
|
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
## Example data
|
||||||
|
|
||||||
|
The data should consist of a list of images (albums covers), and a JSON file. To have some example data to fiddle around with, a dummy data folder is available.
|
||||||
|
|
||||||
|
cp static/dummy-data/* static
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 165 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 443 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 189 KiB |
106
assets/style.css
|
|
@ -1,106 +0,0 @@
|
||||||
body {
|
|
||||||
background-color: #111;
|
|
||||||
color: #aaa;
|
|
||||||
font-size: large;
|
|
||||||
font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;
|
|
||||||
padding: 5rem 10rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
a:link {
|
|
||||||
color: #a83;
|
|
||||||
}
|
|
||||||
|
|
||||||
a:visited {
|
|
||||||
color: #a83;
|
|
||||||
text-decoration: line-through;
|
|
||||||
}
|
|
||||||
|
|
||||||
a:hover, a:focus {
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
a:active {
|
|
||||||
transform: translate(2px, 2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
border-bottom: 3px solid #a83;
|
|
||||||
padding: 1.25rem 0;
|
|
||||||
margin: 1rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
text-transform: uppercase;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
input {
|
|
||||||
background-color: #333;
|
|
||||||
color: #fff;
|
|
||||||
border-width: 0;
|
|
||||||
border-bottom: 3px solid #555;
|
|
||||||
font-size: large;
|
|
||||||
font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;
|
|
||||||
padding: .5rem 1rem;
|
|
||||||
min-width: 17em;
|
|
||||||
}
|
|
||||||
|
|
||||||
input:hover {
|
|
||||||
background-color: #444;
|
|
||||||
border-color: #777;
|
|
||||||
}
|
|
||||||
|
|
||||||
input:focus {
|
|
||||||
background-color: #fff;
|
|
||||||
color: #000;
|
|
||||||
border-color: #a83;
|
|
||||||
}
|
|
||||||
|
|
||||||
footer {
|
|
||||||
color: #888;
|
|
||||||
font-size: small;
|
|
||||||
position: fixed;
|
|
||||||
transform: rotate(45deg) translate(2em, 3em);
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#albums {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: auto auto auto;
|
|
||||||
grid-gap: 10px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
article {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
figure {
|
|
||||||
margin: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
article:hover,
|
|
||||||
article:focus {
|
|
||||||
background: #333;
|
|
||||||
color: #fff;
|
|
||||||
transform: scale(1.05, 1.05);
|
|
||||||
transition: transform .4s linear;
|
|
||||||
}
|
|
||||||
|
|
||||||
article:hover img {
|
|
||||||
transform: scale(1.5, 1.5) translateX(-1.5rem);
|
|
||||||
transition: transform .2s linear;
|
|
||||||
}
|
|
||||||
|
|
||||||
figure img {
|
|
||||||
width: 10vw;
|
|
||||||
height: 10vw;
|
|
||||||
padding: 5px;
|
|
||||||
display: block;
|
|
||||||
border: 1px solid #a83;
|
|
||||||
background-color: #000;
|
|
||||||
}
|
|
||||||
263
css/brutal.css
Normal file
|
|
@ -0,0 +1,263 @@
|
||||||
|
/* === Base === */
|
||||||
|
body {
|
||||||
|
background-color: #111;
|
||||||
|
color: #aaa;
|
||||||
|
font-size: large;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto,
|
||||||
|
Helvetica Neue, Arial, sans-serif;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: 80em;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:link {
|
||||||
|
color: #a83;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:visited {
|
||||||
|
color: #a83;
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover,
|
||||||
|
a:focus {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:active {
|
||||||
|
transform: translate(2px, 2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
border-bottom: 3px solid #a83;
|
||||||
|
padding: 0.5em 0.5em;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 500px) {
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.5em;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
header {
|
||||||
|
margin: 0.5em 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
p:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === /Base === */
|
||||||
|
/* === Field === */
|
||||||
|
|
||||||
|
.field {
|
||||||
|
background-color: #333;
|
||||||
|
color: #fff;
|
||||||
|
border-width: 0;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: large;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto,
|
||||||
|
Helvetica Neue, Arial, sans-serif;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin-top: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field:hover {
|
||||||
|
background-color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field:focus {
|
||||||
|
background-color: #fff;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 500px) {
|
||||||
|
.field {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
input.field {
|
||||||
|
min-width: 17em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === /Field === */
|
||||||
|
/* === Helpers === */
|
||||||
|
|
||||||
|
.blur {
|
||||||
|
filter: blur(25px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.visuallyhidden {
|
||||||
|
position: absolute;
|
||||||
|
left: -9999em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === /Helpers === */
|
||||||
|
/* === Albums === */
|
||||||
|
|
||||||
|
.album {
|
||||||
|
display: flex;
|
||||||
|
align-items: start;
|
||||||
|
padding: 0.5em;
|
||||||
|
margin: 0.8em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 500px) {
|
||||||
|
.album {
|
||||||
|
flex-direction: column;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
.album {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.album:hover {
|
||||||
|
background: #333;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album__cover {
|
||||||
|
margin: 0 0.5em 0 0;
|
||||||
|
padding-top: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 500px) {
|
||||||
|
.album__cover {
|
||||||
|
margin: 0 0 0.5em;
|
||||||
|
width: 100%;
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
.album__cover {
|
||||||
|
margin: 0 1em 0 0;
|
||||||
|
width: auto;
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.album__cover__media {
|
||||||
|
width: 25vw;
|
||||||
|
height: 25vw;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album__cover__media:after {
|
||||||
|
position: absolute;
|
||||||
|
content: "";
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
background-color: #fff;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 500px) {
|
||||||
|
.album__cover__media {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
.album__cover__media {
|
||||||
|
width: 10vw;
|
||||||
|
height: 10vw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 500px) {
|
||||||
|
.albums {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
|
grid-gap: 0.5em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding-bottom: 2em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === /Albums === */
|
||||||
|
/* === Selected album === */
|
||||||
|
|
||||||
|
.selected-album {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-album__inner {
|
||||||
|
overflow: auto;
|
||||||
|
border: 3px solid #a83;
|
||||||
|
background: black;
|
||||||
|
padding: 2em;
|
||||||
|
max-height: 80%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-album__summary {
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 0.5em;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-album__description {
|
||||||
|
color: #fff;
|
||||||
|
padding: 0 0.5rem 2em;
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: 40em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-album__cover {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
.selected-album__cover {
|
||||||
|
display: block;
|
||||||
|
width: 75vh;
|
||||||
|
max-width: 900px;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-album__media {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === /Selected album === */
|
||||||
|
|
@ -3,12 +3,11 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width">
|
<meta name="viewport" content="width=device-width">
|
||||||
<link rel="stylesheet" href="assets/style.css">
|
<link rel="stylesheet" href="./css/brutal.css">
|
||||||
<title>🤘 Brütal Legend 🤘</title>
|
<title>🤘 Brütal Legend 🤘</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="brutal"></div>
|
<div id="brutal"></div>
|
||||||
<footer>av <a href="https://madr.se" rel="author">madr</a> 2018</footer>
|
|
||||||
</body>
|
</body>
|
||||||
<script src="bundle.js"></script>
|
<script src="./src/index.tsx"></script>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
23728
package-lock.json
generated
49
package.json
|
|
@ -1,27 +1,38 @@
|
||||||
{
|
{
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "webpack -p --define process.env.NODE_ENV='\"production\"' --progress --colors",
|
"start": "cross-env NODE_ENV=development parcel index.html --public-url / --out-dir _build",
|
||||||
"watch": "webpack -p --define process.env.NODE_ENV='\"production\"' --watch --progress --colors",
|
"build": "cross-env NODE_ENV=production parcel build index.html --public-url /bl/ --out-dir docs --no-source-maps --no-content-hash",
|
||||||
"serve": "DEV_SERVER_PORT=10667 DEV_SERVER_ROOT=. dev-server"
|
"lint": "cross-env NODE_ENV=development prettier --check src/**/*"
|
||||||
},
|
},
|
||||||
"author": "",
|
|
||||||
"license": "ISC",
|
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"babel-core": "^6.2.1",
|
"@babel/core": "^7.4.0",
|
||||||
"babel-loader": "^6.2.0",
|
"@babel/plugin-transform-runtime": "^7.4.0",
|
||||||
"babel-preset-es2015": "^6.1.18",
|
"@babel/preset-env": "^7.5.5",
|
||||||
"babel-preset-react": "^6.1.18",
|
"@babel/preset-react": "^7.0.0",
|
||||||
"redux-saga": "^1.0.2",
|
"autoprefixer": "^9.5.0",
|
||||||
"webpack": "^1.12.9",
|
"cross-env": "^5.2.0",
|
||||||
"webpack-cli": "^3.2.3"
|
"jest": "^24.9.0",
|
||||||
|
"parcel-bundler": "^1.12.3",
|
||||||
|
"parcel-plugin-static-files-copy": "^2.3.1",
|
||||||
|
"prettier": "^1.18.2",
|
||||||
|
"typescript": "^3.6.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"babel-polyfill": "^6.26.0",
|
"@babel/runtime-corejs2": "^7.4.2",
|
||||||
"babel-preset-stage-1": "^6.1.18",
|
"react": "^16.8.5",
|
||||||
"dev-server": "^0.1.0",
|
"react-dom": "^16.8.5",
|
||||||
"react": "16.3.2",
|
"react-redux": "^6.0.1",
|
||||||
"react-dom": ">=16.3.3",
|
"redux": "^4.0.1",
|
||||||
"react-redux": "5.0.7",
|
"redux-saga": "^1.0.2",
|
||||||
"redux": "4.0.0"
|
"symbol-observable": "^4.0.0"
|
||||||
|
},
|
||||||
|
"postcss": {
|
||||||
|
"modules": false,
|
||||||
|
"plugins": {
|
||||||
|
"autoprefixer": {}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"browserslist": [
|
||||||
|
"defaults"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
export const SET_VISIBILITY_FILTER = 'SET_VISIBILITY_FILTER';
|
|
||||||
export const SET_SORT_KEY = 'SET_SORT_KEY';
|
|
||||||
export const LOAD_ALBUMS = 'LOAD_ALBUMS';
|
|
||||||
export const LOAD_ALBUMS_OK = 'LOAD_ALBUMS_OK';
|
|
||||||
|
|
||||||
export const setVisibilityFilter = filter => ({
|
|
||||||
type: SET_VISIBILITY_FILTER,
|
|
||||||
payload: {
|
|
||||||
filter
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const setSortKey = key => ({
|
|
||||||
type: SET_SORT_KEY,
|
|
||||||
payload: {
|
|
||||||
key,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const albumsLoadedOk = albums => ({
|
|
||||||
type: LOAD_ALBUMS_OK,
|
|
||||||
payload: {
|
|
||||||
albums,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
40
src/actions/index.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { Album } from "../interfaces";
|
||||||
|
|
||||||
|
export const LOAD_ALBUMS = "LOAD_ALBUMS";
|
||||||
|
export const LOAD_ALBUMS_OK = "LOAD_ALBUMS_OK";
|
||||||
|
export const SELECT_ALBUM = "SELECT_ALBUM";
|
||||||
|
export const SET_VISIBILITY_FILTER = "SET_VISIBILITY_FILTER";
|
||||||
|
export const SET_SORT_KEY = "SET_SORT_KEY";
|
||||||
|
export const UNSELECT_ALBUM = "UNSELECT_ALBUM";
|
||||||
|
|
||||||
|
export const setVisibilityFilter = (filter: string) => ({
|
||||||
|
type: SET_VISIBILITY_FILTER,
|
||||||
|
payload: {
|
||||||
|
filter
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const setSortKey = (key: string) => ({
|
||||||
|
type: SET_SORT_KEY,
|
||||||
|
payload: {
|
||||||
|
key
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const albumsLoadedOk = (albums: Array<Album>) => ({
|
||||||
|
type: LOAD_ALBUMS_OK,
|
||||||
|
payload: {
|
||||||
|
albums
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const selectAlbum = (album: Album) => ({
|
||||||
|
type: SELECT_ALBUM,
|
||||||
|
payload: {
|
||||||
|
album
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const unselectAlbum = () => ({
|
||||||
|
type: UNSELECT_ALBUM
|
||||||
|
});
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
import React, { Component } from 'react';
|
|
||||||
import Album from './album';
|
|
||||||
|
|
||||||
export default class AlbumList extends Component {
|
|
||||||
render() {
|
|
||||||
const {
|
|
||||||
albums,
|
|
||||||
} = this.props;
|
|
||||||
return (
|
|
||||||
<div id="albums">
|
|
||||||
{albums.map(album => (
|
|
||||||
<Album key={album.id} {...album} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
25
src/components/album-list.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import React from "react";
|
||||||
|
import Album from "./album";
|
||||||
|
import * as interfaces from "../interfaces";
|
||||||
|
|
||||||
|
export default (props: Props) => {
|
||||||
|
const { albums, handleOnClick, blurred } = props;
|
||||||
|
const classNames = blurred ? "blur" : "";
|
||||||
|
return (
|
||||||
|
<div className={"albums " + classNames}>
|
||||||
|
{albums.map((album: interfaces.Album) => (
|
||||||
|
<Album
|
||||||
|
key={album.id}
|
||||||
|
album={album}
|
||||||
|
handleOnClick={handleOnClick}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
albums: Array<interfaces.Album>;
|
||||||
|
handleOnClick(album: interfaces.Album): void;
|
||||||
|
blurred: boolean;
|
||||||
|
};
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
import React, { Component } from 'react';
|
|
||||||
|
|
||||||
export default class Album extends Component {
|
|
||||||
render() {
|
|
||||||
const {
|
|
||||||
id,
|
|
||||||
artist,
|
|
||||||
title,
|
|
||||||
songs,
|
|
||||||
year,
|
|
||||||
img,
|
|
||||||
purchased_on,
|
|
||||||
} = this.props;
|
|
||||||
const imagePath = `assets/covers/${img}`;
|
|
||||||
const song = songs.join(', ');
|
|
||||||
return (
|
|
||||||
<article>
|
|
||||||
<figure>
|
|
||||||
<img src={imagePath} alt="cover" />
|
|
||||||
</figure>
|
|
||||||
<span>
|
|
||||||
#{id+1}: {artist} - {song}, från "{title}" ({year})<br />
|
|
||||||
<small>✔️ {purchased_on}</small>
|
|
||||||
</span>
|
|
||||||
</article>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
44
src/components/album.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import React from "react";
|
||||||
|
import { Album } from "../interfaces";
|
||||||
|
|
||||||
|
export default (props: Props) => {
|
||||||
|
const handleKeyPress = (e: KeyboardEvent, callback: Function) => {
|
||||||
|
const SPACE_KEY = 32;
|
||||||
|
const ENTER_KEY = 13;
|
||||||
|
if (e.charCode === SPACE_KEY || e.charCode === ENTER_KEY) {
|
||||||
|
e.preventDefault();
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const { album, handleOnClick } = props;
|
||||||
|
const { id, artist, title, songs, year, img, purchased_on } = album;
|
||||||
|
const imagePath = `./covers/${img}`;
|
||||||
|
const song = songs.join(", ");
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
className="album"
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
onClick={() => handleOnClick(album)}
|
||||||
|
>
|
||||||
|
<figure className="album__cover">
|
||||||
|
<img
|
||||||
|
src={imagePath}
|
||||||
|
alt="cover"
|
||||||
|
className="album__cover__media"
|
||||||
|
/>
|
||||||
|
</figure>
|
||||||
|
<span>
|
||||||
|
#{("00" + id).substr(-2, 2)}: {artist} - {song}, från "{title}" ({year})<br />
|
||||||
|
<small>✔️ {purchased_on}</small>
|
||||||
|
</span>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
key: number;
|
||||||
|
album: Album;
|
||||||
|
handleOnClick(album: Album): void;
|
||||||
|
};
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
import React, { Component } from 'react';
|
|
||||||
import AlbumListContainer from '../containers/album-list';
|
|
||||||
import FilterInputContainer from '../containers/filter-input';
|
|
||||||
//import SortSelectContainer from '../containers/sort-select';
|
|
||||||
|
|
||||||
export default class App extends Component {
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<header>
|
|
||||||
<h1>Brütal Legend</h1>
|
|
||||||
<FilterInputContainer />
|
|
||||||
</header>
|
|
||||||
<AlbumListContainer />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
17
src/components/app.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import React from "react";
|
||||||
|
import AlbumList from "../containers/album-list";
|
||||||
|
import FilterInput from "../containers/filter-input";
|
||||||
|
import SortSelect from "../containers/sort-select";
|
||||||
|
import Modal from "../containers/modal";
|
||||||
|
|
||||||
|
export default () => (
|
||||||
|
<React.Fragment>
|
||||||
|
<header>
|
||||||
|
<h1>Brütal Legend</h1>
|
||||||
|
<SortSelect />
|
||||||
|
<FilterInput />
|
||||||
|
</header>
|
||||||
|
<AlbumList />
|
||||||
|
<Modal />
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
import React, { Component } from 'react';
|
|
||||||
|
|
||||||
export default class FilterInput extends Component {
|
|
||||||
render() {
|
|
||||||
const {
|
|
||||||
value,
|
|
||||||
handleOnChange,
|
|
||||||
} = this.props;
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<input
|
|
||||||
type='text'
|
|
||||||
value={value}
|
|
||||||
onChange={evt => handleOnChange(evt.target.value)}
|
|
||||||
placeholder='Filtrera på år, artist, låt, skivtitel ...'
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
23
src/components/filter-input.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export default (props: Props) => {
|
||||||
|
const { value, handleOnChange } = props;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
className="field"
|
||||||
|
onChange={(evt: { target: HTMLInputElement }) =>
|
||||||
|
handleOnChange(evt.target.value)
|
||||||
|
}
|
||||||
|
placeholder="Filtrera på år, artist, låt, skivtitel ..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
value: string;
|
||||||
|
handleOnChange(filterValue: string): void;
|
||||||
|
};
|
||||||
66
src/components/modal.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import React from "react";
|
||||||
|
import { Album } from "../interfaces";
|
||||||
|
|
||||||
|
export default (props: Props) => {
|
||||||
|
const handleKeyPress = (
|
||||||
|
keyPressed: string,
|
||||||
|
albumId: number,
|
||||||
|
close: Function
|
||||||
|
//goto: Function
|
||||||
|
) => {
|
||||||
|
if (keyPressed === "Escape") {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
// } else if (keyPressed === "ArrowRight") {
|
||||||
|
// goto(albumId, 1);
|
||||||
|
// } else if (keyPressed === "ArrowLeft") {
|
||||||
|
// goto(albumId, -1);
|
||||||
|
// }
|
||||||
|
};
|
||||||
|
|
||||||
|
const { album, close /*goto*/ } = props;
|
||||||
|
const { id, artist, title, songs, year, img, description } = album;
|
||||||
|
|
||||||
|
if (id === undefined) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const imagePath = `./covers/${img}`;
|
||||||
|
const song = songs.join(", ");
|
||||||
|
|
||||||
|
document.onkeyup = (e: KeyboardEvent) =>
|
||||||
|
handleKeyPress(e.key, album.id, close /*goto*/);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="selected-album blurred"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => close()}
|
||||||
|
>
|
||||||
|
<div className="selected-album__inner">
|
||||||
|
<figure className="selected-album__cover">
|
||||||
|
<img
|
||||||
|
src={imagePath}
|
||||||
|
alt="cover"
|
||||||
|
className="selected-album__media"
|
||||||
|
/>
|
||||||
|
</figure>
|
||||||
|
<span className="selected-album__summary">
|
||||||
|
#{("00" + id).substr(-2, 2)}: {artist} - {song}, från "{title}" ({year})
|
||||||
|
<br />
|
||||||
|
</span>
|
||||||
|
<div className="selected-album__description">
|
||||||
|
{description.split("\n\n").map(text => (
|
||||||
|
<p key={text}>{text}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
album: Album;
|
||||||
|
close(): void;
|
||||||
|
// goto(albumId: number, direction: number): void;
|
||||||
|
};
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
import React, { Component } from 'react';
|
|
||||||
|
|
||||||
export default class SortSelect extends Component {
|
|
||||||
render() {
|
|
||||||
const { value, handleOnChange } = this.props;
|
|
||||||
return (
|
|
||||||
<div hidden>
|
|
||||||
<label htmlFor="sortBy">Sortera efter</label>
|
|
||||||
<select
|
|
||||||
id="sortBy"
|
|
||||||
value={value}
|
|
||||||
onChange={evt => handleOnChange(evt.target.value)}>
|
|
||||||
<option value="id">Inköpsdatum</option>
|
|
||||||
<option value="artist">Artist</option>
|
|
||||||
<option value="year">År</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
29
src/components/sort-select.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export default (props: Props) => {
|
||||||
|
const { value, handleOnChange } = props;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label htmlFor="sortBy" className="visuallyhidden">
|
||||||
|
Sortera efter
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="sortBy"
|
||||||
|
value={value}
|
||||||
|
className="field"
|
||||||
|
onChange={(evt: { target: HTMLSelectElement }) =>
|
||||||
|
handleOnChange(evt.target.value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="id">Inköpsdatum</option>
|
||||||
|
<option value="artist">Artist</option>
|
||||||
|
<option value="year">År</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
value: string;
|
||||||
|
handleOnChange(sortKey: string): void;
|
||||||
|
};
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import AlbumList from '../components/album-list';
|
|
||||||
|
|
||||||
const getAlbums = (albums, filter) => {
|
|
||||||
const atos = o => [o.artist, o.title, o.songs.join(' '), o.year].join(' ').toLowerCase();
|
|
||||||
if (filter) {
|
|
||||||
const term = filter.toLowerCase();
|
|
||||||
return albums.filter(album => atos(album).match(term));
|
|
||||||
}
|
|
||||||
return albums;
|
|
||||||
};
|
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
|
||||||
albums: getAlbums(state.albums, state.visibilityFilter),
|
|
||||||
});
|
|
||||||
|
|
||||||
export default connect(mapStateToProps)(AlbumList);
|
|
||||||
29
src/containers/album-list.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { connect } from "react-redux";
|
||||||
|
import AlbumList from "../components/album-list";
|
||||||
|
import { selectAlbum } from "../actions";
|
||||||
|
import { Album, State } from "../interfaces";
|
||||||
|
|
||||||
|
const atos = (o: Album) =>
|
||||||
|
[o.artist, o.title, o.songs.join(" "), o.year].join(" ").toLowerCase();
|
||||||
|
|
||||||
|
const getAlbums = (albums: Array<Object>, filter: string, sortKey: string) => {
|
||||||
|
if (filter) {
|
||||||
|
const term = filter.toLowerCase();
|
||||||
|
albums = albums.filter((album: Album) => atos(album).match(term));
|
||||||
|
}
|
||||||
|
return [...albums].sort((a: Album, b: Album) =>
|
||||||
|
a[sortKey] > b[sortKey] ? 1 : -1
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapStateToProps = (state: State) => ({
|
||||||
|
albums: getAlbums(state.albums, state.visibilityFilter, state.sortKey),
|
||||||
|
blurred: "id" in state.selectedAlbum,
|
||||||
|
sortKey: state.sortKey
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapDispatchToProps = (dispatch: Function) => ({
|
||||||
|
handleOnClick: (album: Album) => dispatch(selectAlbum(album))
|
||||||
|
});
|
||||||
|
|
||||||
|
export default connect(mapStateToProps, mapDispatchToProps)(AlbumList);
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import FilterInput from '../components/filter-input';
|
|
||||||
import { setVisibilityFilter } from '../actions';
|
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
|
||||||
value: state.visibilityFilter,
|
|
||||||
});
|
|
||||||
|
|
||||||
const mapDispatchToProps = dispatch => ({
|
|
||||||
handleOnChange: filter => dispatch(setVisibilityFilter(filter)),
|
|
||||||
});
|
|
||||||
|
|
||||||
export default connect(mapStateToProps, mapDispatchToProps)(FilterInput);
|
|
||||||
14
src/containers/filter-input.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { connect } from "react-redux";
|
||||||
|
import FilterInput from "../components/filter-input";
|
||||||
|
import { setVisibilityFilter } from "../actions";
|
||||||
|
import { State } from "../interfaces";
|
||||||
|
|
||||||
|
const mapStateToProps = (state: State) => ({
|
||||||
|
value: state.visibilityFilter
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapDispatchToProps = (dispatch: Function) => ({
|
||||||
|
handleOnChange: (filter: string) => dispatch(setVisibilityFilter(filter))
|
||||||
|
});
|
||||||
|
|
||||||
|
export default connect(mapStateToProps, mapDispatchToProps)(FilterInput);
|
||||||
14
src/containers/modal.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { connect } from "react-redux";
|
||||||
|
import Modal from "../components/modal";
|
||||||
|
import { unselectAlbum } from "../actions";
|
||||||
|
import { State } from "../interfaces";
|
||||||
|
|
||||||
|
const mapStateToProps = (state: State) => ({
|
||||||
|
album: state.selectedAlbum
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapDispatchToProps = (dispatch: Function) => ({
|
||||||
|
close: () => dispatch(unselectAlbum())
|
||||||
|
});
|
||||||
|
|
||||||
|
export default connect(mapStateToProps, mapDispatchToProps)(Modal);
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import SortSelect from '../components/sort-select';
|
|
||||||
import {setSortKey} from "../actions";
|
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
|
||||||
value: state.sortKey
|
|
||||||
});
|
|
||||||
|
|
||||||
const mapDispatchToProps = (dispatch) => ({
|
|
||||||
handleOnChange: (key) => dispatch(setSortKey(key))
|
|
||||||
});
|
|
||||||
|
|
||||||
export default connect(mapStateToProps, mapDispatchToProps)(SortSelect);
|
|
||||||
14
src/containers/sort-select.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { connect } from "react-redux";
|
||||||
|
import SortSelect from "../components/sort-select";
|
||||||
|
import { setSortKey } from "../actions";
|
||||||
|
import { State } from "../interfaces";
|
||||||
|
|
||||||
|
const mapStateToProps = (state: State) => ({
|
||||||
|
value: state.sortKey
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapDispatchToProps = (dispatch: Function) => ({
|
||||||
|
handleOnChange: (key: string) => dispatch(setSortKey(key))
|
||||||
|
});
|
||||||
|
|
||||||
|
export default connect(mapStateToProps, mapDispatchToProps)(SortSelect);
|
||||||
32
src/index.js
|
|
@ -1,32 +0,0 @@
|
||||||
import "babel-polyfill";
|
|
||||||
import React from 'react';
|
|
||||||
import { render } from 'react-dom';
|
|
||||||
import { createStore, applyMiddleware } from 'redux';
|
|
||||||
import { Provider } from 'react-redux';
|
|
||||||
import createSagaMiddleware from 'redux-saga';
|
|
||||||
import rootReducer from './reducers';
|
|
||||||
import rootSagas from './sagas';
|
|
||||||
import App from './components/app';
|
|
||||||
|
|
||||||
const sagaMiddleware = createSagaMiddleware()
|
|
||||||
|
|
||||||
/* eslint-disable no-underscore-dangle */
|
|
||||||
const composeEnhancers =
|
|
||||||
window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose
|
|
||||||
/* eslint-enable */
|
|
||||||
|
|
||||||
const store = createStore(
|
|
||||||
rootReducer,
|
|
||||||
composeEnhancers(applyMiddleware(sagaMiddleware)),
|
|
||||||
)
|
|
||||||
|
|
||||||
sagaMiddleware.run(rootSagas)
|
|
||||||
|
|
||||||
render(
|
|
||||||
<Provider store={store}>
|
|
||||||
<App />
|
|
||||||
</Provider>,
|
|
||||||
document.getElementById('brutal')
|
|
||||||
);
|
|
||||||
|
|
||||||
store.dispatch({ type: 'LOAD_ALBUMS', payload: { source: '/json/albums.json' }});
|
|
||||||
31
src/index.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import React from "react";
|
||||||
|
import { render } from "react-dom";
|
||||||
|
import { createStore, applyMiddleware, compose } from "redux";
|
||||||
|
import { Provider } from "react-redux";
|
||||||
|
import createSagaMiddleware from "redux-saga";
|
||||||
|
import rootReducer from "./reducers";
|
||||||
|
import rootSagas from "./sagas";
|
||||||
|
import App from "./components/app";
|
||||||
|
import { LOAD_ALBUMS } from "./actions";
|
||||||
|
|
||||||
|
const sagaMiddleware = createSagaMiddleware();
|
||||||
|
|
||||||
|
/* eslint-disable no-underscore-dangle */
|
||||||
|
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
|
||||||
|
/* eslint-enable */
|
||||||
|
|
||||||
|
const store = createStore(
|
||||||
|
rootReducer,
|
||||||
|
composeEnhancers(applyMiddleware(sagaMiddleware))
|
||||||
|
);
|
||||||
|
|
||||||
|
sagaMiddleware.run(rootSagas);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Provider store={store}>
|
||||||
|
<App />
|
||||||
|
</Provider>,
|
||||||
|
document.getElementById("brutal")
|
||||||
|
);
|
||||||
|
|
||||||
|
store.dispatch({ type: LOAD_ALBUMS, payload: { source: "./albums.json" } });
|
||||||
17
src/interfaces/index.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
export interface Album {
|
||||||
|
id: number;
|
||||||
|
artist: string;
|
||||||
|
title: string;
|
||||||
|
songs: Array<string>;
|
||||||
|
year: number;
|
||||||
|
img: string;
|
||||||
|
purchased_on: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface State {
|
||||||
|
albums: Array<Album>;
|
||||||
|
selectedAlbum: Album;
|
||||||
|
visibilityFilter: string;
|
||||||
|
sortKey: string;
|
||||||
|
}
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
import { LOAD_ALBUMS_OK } from '../actions';
|
|
||||||
|
|
||||||
export default (state = [], action) => {
|
|
||||||
switch (action.type) {
|
|
||||||
case LOAD_ALBUMS_OK:
|
|
||||||
const { albums } = action.payload;
|
|
||||||
return albums;
|
|
||||||
default:
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
19
src/reducers/albums.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { LOAD_ALBUMS_OK } from "../actions";
|
||||||
|
import { Album } from "../interfaces";
|
||||||
|
|
||||||
|
type Action = {
|
||||||
|
type: string;
|
||||||
|
payload: {
|
||||||
|
albums: Array<Album>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default (state: Array<Album> = [], action: Action) => {
|
||||||
|
switch (action.type) {
|
||||||
|
case LOAD_ALBUMS_OK:
|
||||||
|
const { albums } = action.payload;
|
||||||
|
return albums;
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import { combineReducers } from 'redux';
|
|
||||||
import albums from './albums';
|
|
||||||
import visibilityFilter from './visibility-filter';
|
|
||||||
import sortKey from "./sort-key";
|
|
||||||
|
|
||||||
export default combineReducers({
|
|
||||||
albums,
|
|
||||||
visibilityFilter,
|
|
||||||
sortKey,
|
|
||||||
});
|
|
||||||
12
src/reducers/index.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { combineReducers } from "redux";
|
||||||
|
import albums from "./albums";
|
||||||
|
import visibilityFilter from "./visibility-filter";
|
||||||
|
import sortKey from "./sort-key";
|
||||||
|
import selectedAlbum from "./selected-album";
|
||||||
|
|
||||||
|
export default combineReducers({
|
||||||
|
albums,
|
||||||
|
visibilityFilter,
|
||||||
|
sortKey,
|
||||||
|
selectedAlbum
|
||||||
|
});
|
||||||
20
src/reducers/selected-album.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { SELECT_ALBUM, UNSELECT_ALBUM } from "../actions";
|
||||||
|
import { Album } from "../interfaces";
|
||||||
|
|
||||||
|
type Action = {
|
||||||
|
type: string;
|
||||||
|
payload: {
|
||||||
|
album: Album;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default (state: Object = {}, action: Action) => {
|
||||||
|
switch (action.type) {
|
||||||
|
case SELECT_ALBUM:
|
||||||
|
return action.payload.album;
|
||||||
|
case UNSELECT_ALBUM:
|
||||||
|
return {};
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import { SET_SORT_KEY } from '../actions';
|
|
||||||
|
|
||||||
export default (state = 'id', action) => {
|
|
||||||
switch (action.type) {
|
|
||||||
case SET_SORT_KEY:
|
|
||||||
return action.payload.key;
|
|
||||||
default:
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
17
src/reducers/sort-key.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { SET_SORT_KEY } from "../actions";
|
||||||
|
|
||||||
|
type Action = {
|
||||||
|
type: string;
|
||||||
|
payload: {
|
||||||
|
key: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default (state: string = "id", action: Action) => {
|
||||||
|
switch (action.type) {
|
||||||
|
case SET_SORT_KEY:
|
||||||
|
return action.payload.key;
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import { SET_VISIBILITY_FILTER } from '../actions';
|
|
||||||
|
|
||||||
export default (state = '', action) => {
|
|
||||||
switch (action.type) {
|
|
||||||
case SET_VISIBILITY_FILTER:
|
|
||||||
return action.payload.filter;
|
|
||||||
default:
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
17
src/reducers/visibility-filter.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { SET_VISIBILITY_FILTER } from "../actions";
|
||||||
|
|
||||||
|
type Action = {
|
||||||
|
type: string;
|
||||||
|
payload: {
|
||||||
|
filter: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default (state: string = "", action: Action) => {
|
||||||
|
switch (action.type) {
|
||||||
|
case SET_VISIBILITY_FILTER:
|
||||||
|
return action.payload.filter;
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
};
|
||||||
32
src/sagas/index.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { put, takeEvery, all, call } from "redux-saga/effects";
|
||||||
|
import { LOAD_ALBUMS, albumsLoadedOk } from "../actions";
|
||||||
|
|
||||||
|
function* watchLoadAlbumsAsync() {
|
||||||
|
yield takeEvery(LOAD_ALBUMS, loadAlbumsAsync);
|
||||||
|
}
|
||||||
|
|
||||||
|
function* loadAlbumsAsync(action: LoadAlbumsAction) {
|
||||||
|
try {
|
||||||
|
const { source } = action.payload;
|
||||||
|
const data = yield call(
|
||||||
|
() =>
|
||||||
|
fetch(source)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => data),
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
yield put(albumsLoadedOk(data));
|
||||||
|
} catch (error) {
|
||||||
|
yield console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function*() {
|
||||||
|
yield all([watchLoadAlbumsAsync()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoadAlbumsAction {
|
||||||
|
payload: {
|
||||||
|
source: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
68
static/dummy-data/albums.json
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"img": "05.jpg",
|
||||||
|
"title": "Death or Glory",
|
||||||
|
"artist": "Running Wild",
|
||||||
|
"album": "Death or Glory",
|
||||||
|
"year": 1989,
|
||||||
|
"purchased_on": "2016-04-09",
|
||||||
|
"description": "",
|
||||||
|
"songs": ["Riding the Storm"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"img": "04.jpg",
|
||||||
|
"title": "Blizzard of Ozz",
|
||||||
|
"artist": "Ozzy Osbourne",
|
||||||
|
"album": "Blizzard of Ozz",
|
||||||
|
"year": 1980,
|
||||||
|
"purchased_on": "2016-04-05",
|
||||||
|
"description": "",
|
||||||
|
"songs": ["Mr Crowley"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"img": "03.jpg",
|
||||||
|
"title": "Destroyer",
|
||||||
|
"artist": "KISS",
|
||||||
|
"album": "Destroyer",
|
||||||
|
"year": 1976,
|
||||||
|
"purchased_on": "2016-03-19",
|
||||||
|
"description": "",
|
||||||
|
"songs": ["God of Thunder"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"img": "02.jpg",
|
||||||
|
"title": "Battle Cry",
|
||||||
|
"artist": "Omen",
|
||||||
|
"album": "Battle Cry",
|
||||||
|
"year": 1984,
|
||||||
|
"purchased_on": "2016-03-12",
|
||||||
|
"description": "",
|
||||||
|
"songs": ["The Axeman"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"img": "01.jpg",
|
||||||
|
"title": "O.F.R.",
|
||||||
|
"artist": "Nitro",
|
||||||
|
"album": "O.F.R.",
|
||||||
|
"year": 1989,
|
||||||
|
"purchased_on": "2016-01-31",
|
||||||
|
"description": "",
|
||||||
|
"songs": ["Machine Gunn Eddie"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"img": "00.jpg",
|
||||||
|
"title": "Refuge Denied",
|
||||||
|
"artist": "Sanctuary",
|
||||||
|
"album": "Refuge Denied",
|
||||||
|
"year": 1987,
|
||||||
|
"purchased_on": "2016-01-24",
|
||||||
|
"description": "",
|
||||||
|
"songs": ["Battle Angels"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
Before Width: | Height: | Size: 271 KiB After Width: | Height: | Size: 271 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 188 KiB After Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 71 KiB |
10
tsconfig.json
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"jsx": "react",
|
||||||
|
"baseUrl": "./src",
|
||||||
|
"paths": {
|
||||||
|
"~*": ["./*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"]
|
||||||
|
}
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
entry: ['./src/index.js'],
|
|
||||||
output: {
|
|
||||||
path: __dirname,
|
|
||||||
publicPath: '/',
|
|
||||||
filename: 'bundle.js'
|
|
||||||
},
|
|
||||||
module: {
|
|
||||||
loaders: [
|
|
||||||
{
|
|
||||||
exclude: /node_modules/,
|
|
||||||
loader: 'babel',
|
|
||||||
query: {
|
|
||||||
presets: ['react', 'es2015', 'stage-1']
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
resolve: {
|
|
||||||
extensions: ['', '.js', '.jsx']
|
|
||||||
},
|
|
||||||
devServer: {
|
|
||||||
historyApiFallback: true,
|
|
||||||
contentBase: './',
|
|
||||||
watchOptions: {
|
|
||||||
aggregateTimeout: 300,
|
|
||||||
poll: 1000
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||