Compare commits

..

8 commits
main ... v1.1

Author SHA1 Message Date
Anders Ytterström
032f611b70 Load albums asyncronously
Introducing redux-sagas.
2019-03-31 12:30:19 +02:00
0f01801801 Add Angel Witch - Angel Witch 2019-03-17 16:26:38 +01:00
Anders Ytterström
7029ac9330
Create README.md 2019-03-06 16:37:26 +01:00
Anders Ytterström
5307cd3164 Do some spring cleaning 2019-03-06 16:34:36 +01:00
Anders Ytterström
7677570ad2 Remove webpack-dev-server
Severe security problems, and it is not needed since it only is used locally.

Here, it is replaced by a watch script, and relative paths to assets and resources so that index.html can be viewed directly in the browser instead of via a dev server.

Also, some minor production preparing stuffs (hiding elements not yet ready) and some ugly white space inconsistecy fixes.
2019-02-10 11:52:51 +01:00
Anders Ytterström
79bbf9188c Upgrade dependencies 2018-11-27 13:47:28 +01:00
Anders Ytterström
38f4244caf 🎨 Rename files for consistency and clarification 2018-11-26 11:31:53 +01:00
Anders Ytterström
74732c1683 🎨 Improve code readability 2018-11-26 09:46:05 +01:00
66 changed files with 5924 additions and 22330 deletions

View file

@ -1,3 +1,3 @@
{ {
"presets": ["@babel/preset-env", "@babel/react"] "presets": ["react", "es2015", "stage-1"]
} }

View file

@ -1,15 +0,0 @@
# 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

View file

@ -1,216 +0,0 @@
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
View file

@ -6,12 +6,3 @@ npm-debug.log
# IntelliJ # IntelliJ
*.iml *.iml
/.idea /.idea
.cache
_build
.vscode
docs
static/albums.json
static/covers

View file

@ -1,35 +0,0 @@
"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
}
};

View file

@ -1 +0,0 @@
nodejs 12.10.0

View file

@ -1,8 +0,0 @@
/*
* ----------------------------------------------------------------------------
* "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
* ----------------------------------------------------------------------------
*/

View file

@ -1,34 +1,14 @@
# BRÜTAL LEGEND # Getting started
https://www.youtube.com/embed/VW88ofmfF0w Install dependencies and start webpack watcher.
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 install
npm run watch
Also a project for learning in another terminal, start a web server using python.
- CSS Grid layout, python2 -m 'SimpleHTTPServer' 1337
- React, # or
- Redux, python3 -m 'http.server' 1337
- 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

View file

Before

Width:  |  Height:  |  Size: 271 KiB

After

Width:  |  Height:  |  Size: 271 KiB

View file

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

View file

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

View file

Before

Width:  |  Height:  |  Size: 188 KiB

After

Width:  |  Height:  |  Size: 188 KiB

View file

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 96 KiB

View file

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 71 KiB

BIN
assets/covers/06.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

BIN
assets/covers/07.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

BIN
assets/covers/08.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

BIN
assets/covers/09.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
assets/covers/10.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

BIN
assets/covers/11.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 443 KiB

BIN
assets/covers/12.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

BIN
assets/covers/13.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

BIN
assets/covers/14.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

BIN
assets/covers/15.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

BIN
assets/covers/16.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

106
assets/style.css Normal file
View file

@ -0,0 +1,106 @@
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;
}

View file

@ -1,263 +0,0 @@
/* === 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 === */

View file

@ -3,11 +3,12 @@
<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="./css/brutal.css"> <link rel="stylesheet" href="assets/style.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="./src/index.tsx"></script> <script src="bundle.js"></script>
</html> </html>

26658
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,38 +1,27 @@
{ {
"scripts": { "scripts": {
"start": "cross-env NODE_ENV=development parcel index.html --public-url / --out-dir _build", "build": "webpack -p --define process.env.NODE_ENV='\"production\"' --progress --colors",
"build": "cross-env NODE_ENV=production parcel build index.html --public-url /bl/ --out-dir docs --no-source-maps --no-content-hash", "watch": "webpack -p --define process.env.NODE_ENV='\"production\"' --watch --progress --colors",
"lint": "cross-env NODE_ENV=development prettier --check src/**/*" "serve": "DEV_SERVER_PORT=10667 DEV_SERVER_ROOT=. dev-server"
}, },
"devDependencies": { "author": "",
"@babel/core": "^7.4.0", "license": "ISC",
"@babel/plugin-transform-runtime": "^7.4.0", "devDependencies": {
"@babel/preset-env": "^7.5.5", "babel-core": "^6.2.1",
"@babel/preset-react": "^7.0.0", "babel-loader": "^6.2.0",
"autoprefixer": "^9.5.0", "babel-preset-es2015": "^6.1.18",
"cross-env": "^5.2.0", "babel-preset-react": "^6.1.18",
"jest": "^24.9.0", "redux-saga": "^1.0.2",
"parcel-bundler": "^1.12.3", "webpack": "^1.12.9",
"parcel-plugin-static-files-copy": "^2.3.1", "webpack-cli": "^3.2.3"
"prettier": "^1.18.2", },
"typescript": "^3.6.3" "dependencies": {
}, "babel-polyfill": "^6.26.0",
"dependencies": { "babel-preset-stage-1": "^6.1.18",
"@babel/runtime-corejs2": "^7.4.2", "dev-server": "^0.1.0",
"react": "^16.8.5", "react": "16.3.2",
"react-dom": "^16.8.5", "react-dom": ">=16.3.3",
"react-redux": "^6.0.1", "react-redux": "5.0.7",
"redux": "^4.0.1", "redux": "4.0.0"
"redux-saga": "^1.0.2", }
"symbol-observable": "^4.0.0"
},
"postcss": {
"modules": false,
"plugins": {
"autoprefixer": {}
}
},
"browserslist": [
"defaults"
]
} }

25
src/actions/index.js Normal file
View file

@ -0,0 +1,25 @@
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,
},
})

View file

@ -1,40 +0,0 @@
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
});

View file

@ -0,0 +1,17 @@
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>
);
}
}

View file

@ -1,25 +0,0 @@
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;
};

28
src/components/album.jsx Normal file
View file

@ -0,0 +1,28 @@
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>
)
}
}

View file

@ -1,44 +0,0 @@
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;
};

18
src/components/app.jsx Normal file
View file

@ -0,0 +1,18 @@
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>
);
}
}

View file

@ -1,17 +0,0 @@
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>
);

View file

@ -0,0 +1,20 @@
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>
);
}
}

View file

@ -1,23 +0,0 @@
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;
};

View file

@ -1,66 +0,0 @@
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;
};

View file

@ -0,0 +1,20 @@
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>
);
}
}

View file

@ -1,29 +0,0 @@
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;
};

View file

@ -0,0 +1,18 @@
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);

View file

@ -1,29 +0,0 @@
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);

View file

@ -0,0 +1,14 @@
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);

View file

@ -1,14 +0,0 @@
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);

View file

@ -1,14 +0,0 @@
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);

View file

@ -0,0 +1,14 @@
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);

View file

@ -1,14 +0,0 @@
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 Normal file
View file

@ -0,0 +1,32 @@
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' }});

View file

@ -1,31 +0,0 @@
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" } });

View file

@ -1,17 +0,0 @@
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;
}

11
src/reducers/albums.js Normal file
View file

@ -0,0 +1,11 @@
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;
}
};

View file

@ -1,19 +0,0 @@
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;
}
};

10
src/reducers/index.js Normal file
View file

@ -0,0 +1,10 @@
import { combineReducers } from 'redux';
import albums from './albums';
import visibilityFilter from './visibility-filter';
import sortKey from "./sort-key";
export default combineReducers({
albums,
visibilityFilter,
sortKey,
});

View file

@ -1,12 +0,0 @@
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
});

View file

@ -1,20 +0,0 @@
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;
}
};

10
src/reducers/sort-key.js Normal file
View file

@ -0,0 +1,10 @@
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;
}
};

View file

@ -1,17 +0,0 @@
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;
}
};

View file

@ -0,0 +1,10 @@
import { SET_VISIBILITY_FILTER } from '../actions';
export default (state = '', action) => {
switch (action.type) {
case SET_VISIBILITY_FILTER:
return action.payload.filter;
default:
return state;
}
};

View file

@ -1,17 +0,0 @@
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;
}
};

View file

@ -1,32 +0,0 @@
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;
};
}

View file

@ -1,68 +0,0 @@
[
{
"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"]
}
]

View file

@ -1,10 +0,0 @@
{
"compilerOptions": {
"jsx": "react",
"baseUrl": "./src",
"paths": {
"~*": ["./*"]
}
},
"include": ["src/**/*"]
}

30
webpack.config.js Normal file
View file

@ -0,0 +1,30 @@
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
}
}
};