Compare commits

..

24 commits
v1.1 ... main

Author SHA1 Message Date
45a6ea25c8 Upgrade NPM dependencies 2021-06-27 12:26:03 +02:00
da4f93a30b Finish removal of version controlled content
From now on, use README instructions to add example dummy data.
2021-06-27 12:25:14 +02:00
bff9b2fdd7 Remove content from version control
albums.json and the covers are now handled as symbolic links. Some minor
changes are done in the tsx files as well to mimic some adjustments in
the data.
2021-04-25 09:29:14 +02:00
Anders Ytterström
ea6a48deef Add 2021 progress 2021-03-31 10:06:31 +02:00
Anders Ytterström
e0a39dd4d8 Add King Diamond - "Them" 2020-09-14 13:28:26 +02:00
dependabot[bot]
ecaa1271bf Bump acorn from 5.7.3 to 5.7.4
Bumps [acorn](https://github.com/acornjs/acorn) from 5.7.3 to 5.7.4.
- [Release notes](https://github.com/acornjs/acorn/releases)
- [Commits](https://github.com/acornjs/acorn/compare/5.7.3...5.7.4)

Signed-off-by: dependabot[bot] <support@github.com>
2020-04-17 12:06:55 +02:00
3839813a0a
Update README.md 2020-03-12 15:37:56 +01:00
Anders Ytterström
f5f5d61413
Add youtube video to README 2020-03-12 15:36:01 +01:00
9bcf0ecc56 Make modal great again 2020-03-05 21:48:45 +01:00
755cf14828 Add License 2020-03-05 20:19:48 +01:00
4e28567959 Update README 2020-03-05 20:17:45 +01:00
Anders Ytterström
6ba958fafa Replace interfaces with Types 2020-03-05 20:14:09 +01:00
Anders Ytterström
cb9f3ec3d1
Convert app to TypeScript 2020-03-05 08:07:46 +01:00
Anders Ytterström
39f0542ef2
Replace Webpack with Parcel
Change path to some files to ease the no-config static file management
2020-03-04 19:35:29 +01:00
Anders Ytterström
93358ac607
Improve design responsiveness 2019-12-28 13:18:52 +01:00
e04e04bee9 Update dependencies
curse you babel 7, webpack and all other npm crap.
2019-09-17 21:49:19 +02:00
Anders Ytterström
055511e555 Lint code and introduce BEM 2019-04-24 09:58:02 +02:00
Anders Ytterström
d447143312 Display selected album in Modal
A new Component and Container is introduced: Modal.

It dispatches an UNSELECT_ALBUM action when clicked on.

Some CSS is added to bur out the background and display the modal
content properly.
2019-04-24 09:58:02 +02:00
Anders Ytterström
1ceb248df9 Handle click on album
Click on album should dispatch an SELECT_ALBUM action.

This is done by map to AlbumList, and providing a property to each
album. It could also had been done by introducing a container for Album,
but IMHO that's not motivated.
2019-04-24 09:58:02 +02:00
Anders Ytterström
60e70f4b34 Add selected album actions and reducers
Default state is an empty object ({}).
SELECT_ALBUM will update state with an album.
UNSELECT_ALBUM resets state to default state.
2019-04-24 09:58:02 +02:00
Anders Ytterström
c11b1d4653 Load albums asyncronously
Introducing redux-sagas.
2019-04-24 09:58:02 +02:00
Anders Ytterström
3f0ac404f0 Do some spring cleaning 2019-04-24 09:58:02 +02:00
Anders Ytterström
4266dbcf6b Upgrade dependencies 2019-04-24 09:58:02 +02:00
Anders Ytterström
d722027781 🎨 Improve code readability
Rename files for consistency and clarification
2019-04-24 09:57:41 +02:00
66 changed files with 22250 additions and 5844 deletions

View file

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

15
.editorconfig Normal file
View 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
View 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
View file

@ -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
View 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
View file

@ -0,0 +1 @@
nodejs 12.10.0

8
LICENSE Normal file
View 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
* ----------------------------------------------------------------------------
*/

View file

@ -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
Visit site on http://localhost:1337 - TypeScript.
## 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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 443 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 189 KiB

View file

@ -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
View 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 === */

View file

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

26498
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -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": "", "devDependencies": {
"license": "ISC", "@babel/core": "^7.4.0",
"devDependencies": { "@babel/plugin-transform-runtime": "^7.4.0",
"babel-core": "^6.2.1", "@babel/preset-env": "^7.5.5",
"babel-loader": "^6.2.0", "@babel/preset-react": "^7.0.0",
"babel-preset-es2015": "^6.1.18", "autoprefixer": "^9.5.0",
"babel-preset-react": "^6.1.18", "cross-env": "^5.2.0",
"redux-saga": "^1.0.2", "jest": "^24.9.0",
"webpack": "^1.12.9", "parcel-bundler": "^1.12.3",
"webpack-cli": "^3.2.3" "parcel-plugin-static-files-copy": "^2.3.1",
}, "prettier": "^1.18.2",
"dependencies": { "typescript": "^3.6.3"
"babel-polyfill": "^6.26.0", },
"babel-preset-stage-1": "^6.1.18", "dependencies": {
"dev-server": "^0.1.0", "@babel/runtime-corejs2": "^7.4.2",
"react": "16.3.2", "react": "^16.8.5",
"react-dom": ">=16.3.3", "react-dom": "^16.8.5",
"react-redux": "5.0.7", "react-redux": "^6.0.1",
"redux": "4.0.0" "redux": "^4.0.1",
} "redux-saga": "^1.0.2",
"symbol-observable": "^4.0.0"
},
"postcss": {
"modules": false,
"plugins": {
"autoprefixer": {}
}
},
"browserslist": [
"defaults"
]
} }

View file

@ -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
View 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
});

View file

@ -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>
);
}
}

View 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;
};

View file

@ -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
View 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;
};

View file

@ -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
View 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>
);

View file

@ -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>
);
}
}

View 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
View 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;
};

View file

@ -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>
);
}
}

View 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;
};

View file

@ -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);

View 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);

View file

@ -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);

View 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
View 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);

View file

@ -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);

View 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);

View file

@ -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
View 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
View 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;
}

View file

@ -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
View 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;
}
};

View file

@ -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
View 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
});

View 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;
}
};

View file

@ -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
View 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;
}
};

View file

@ -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;
}
};

View 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
View 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;
};
}

View 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"]
}
]

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

10
tsconfig.json Normal file
View file

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

View file

@ -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
}
}
};