Convert app to TypeScript

This commit is contained in:
Anders Ytterström 2020-03-05 08:07:46 +01:00
parent 39f0542ef2
commit cb9f3ec3d1
No known key found for this signature in database
GPG key ID: B205A5092BDF55E0
42 changed files with 485 additions and 418 deletions

1
.gitignore vendored
View file

@ -9,3 +9,4 @@ npm-debug.log
.cache
_build
.vscode

View file

@ -9,5 +9,5 @@
<body>
<div id="brutal"></div>
</body>
<script src="./src/index.js"></script>
<script src="./src/index.tsx"></script>
</html>

6
package-lock.json generated
View file

@ -8758,6 +8758,12 @@
"integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=",
"dev": true
},
"typescript": {
"version": "3.6.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-3.6.3.tgz",
"integrity": "sha512-N7bceJL1CtRQ2RiG0AQME13ksR7DiuQh/QehubYcghzv20tnh+MQnQIuJddTmsbqYj+dztchykemz0zFzlvdQw==",
"dev": true
},
"typescript-compare": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/typescript-compare/-/typescript-compare-0.0.2.tgz",

View file

@ -1,36 +1,37 @@
{
"scripts": {
"start": "cross-env NODE_ENV=development parcel index.html --public-url / --out-dir _build",
"build": "cross-env NODE_ENV=production parcel build index.html --public-url /urban-enigma/ --out-dir docs --no-source-maps --no-content-hash",
"lint": "cross-env NODE_ENV=development prettier --check src/**/* assets/*.css"
},
"devDependencies": {
"@babel/core": "^7.4.0",
"@babel/plugin-transform-runtime": "^7.4.0",
"@babel/preset-env": "^7.5.5",
"@babel/preset-react": "^7.0.0",
"autoprefixer": "^9.5.0",
"cross-env": "^5.2.0",
"jest": "^24.9.0",
"parcel-bundler": "^1.12.3",
"parcel-plugin-static-files-copy": "^2.3.1",
"prettier": "^1.18.2"
},
"dependencies": {
"@babel/runtime-corejs2": "^7.4.2",
"react": "^16.8.5",
"react-dom": "^16.8.5",
"react-redux": "^6.0.1",
"redux": "^4.0.1",
"redux-saga": "^1.0.2"
},
"postcss": {
"modules": false,
"plugins": {
"autoprefixer": {}
}
},
"browserslist": [
"defaults"
]
"scripts": {
"start": "cross-env NODE_ENV=development parcel index.html --public-url / --out-dir _build",
"build": "cross-env NODE_ENV=production parcel build index.html --public-url /urban-enigma/ --out-dir docs --no-source-maps --no-content-hash",
"lint": "cross-env NODE_ENV=development prettier --check src/**/*"
},
"devDependencies": {
"@babel/core": "^7.4.0",
"@babel/plugin-transform-runtime": "^7.4.0",
"@babel/preset-env": "^7.5.5",
"@babel/preset-react": "^7.0.0",
"autoprefixer": "^9.5.0",
"cross-env": "^5.2.0",
"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": {
"@babel/runtime-corejs2": "^7.4.2",
"react": "^16.8.5",
"react-dom": "^16.8.5",
"react-redux": "^6.0.1",
"redux": "^4.0.1",
"redux-saga": "^1.0.2"
},
"postcss": {
"modules": false,
"plugins": {
"autoprefixer": {}
}
},
"browserslist": [
"defaults"
]
}

View file

@ -1,38 +0,0 @@
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 => ({
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,
},
})
export const selectAlbum = album => ({
type: SELECT_ALBUM,
payload: {
album
},
});
export const unselectAlbum = () => ({
type: UNSELECT_ALBUM,
});

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,24 +0,0 @@
import React, { Component } from 'react';
import Album from './album';
export default class AlbumList extends Component {
render() {
const {
albums,
handleOnClick,
blurred,
} = this.props;
const classNames = blurred ? 'blur' : ''
return (
<div className={"albums " + classNames}>
{albums.map(album => (
<Album
key={album.id}
album={album}
handleOnClick={handleOnClick}
/>
))}
</div>
);
}
}

View file

@ -0,0 +1,25 @@
import React from "react";
import Album from "./album";
import * as interfaces from "../interfaces";
export default (props: AlbumList) => {
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>
);
};
interface AlbumList {
albums: Array<interfaces.Album>;
handleOnClick: Function;
blurred: boolean;
}

View file

@ -1,38 +0,0 @@
import React, { Component } from 'react';
export default class Album extends Component {
handleKeyPress(e, callback) {
const SPACE_KEY = 32
const ENTER_KEY = 13
if (e.charCode === SPACE_KEY || e.charCode === ENTER_KEY) {
e.preventDefault();
callback();
}
}
render() {
const { album, handleOnClick } = this.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>
#{id+1}: {artist} - {song}, från "{title}" ({year})<br />
<small> {purchased_on}</small>
</span>
</article>
);
}
}

43
src/components/album.tsx Normal file
View file

@ -0,0 +1,43 @@
import React from "react";
import * as interfaces from "../interfaces";
export default (props: Album) => {
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>
#{id + 1}: {artist} - {song}, från "{title}" ({year})<br />
<small> {purchased_on}</small>
</span>
</article>
);
};
interface Album {
album: interfaces.Album;
handleOnClick: Function;
}

View file

@ -1,19 +0,0 @@
import React, { Component } from 'react';
import AlbumList from '../containers/album-list';
import FilterInput from '../containers/filter-input';
import Modal from '../containers/modal';
export default class App extends Component {
render() {
return (
<React.Fragment>
<header>
<h1>Brütal Legend</h1>
<FilterInput />
</header>
<AlbumList />
<Modal />
</React.Fragment>
);
}
}

15
src/components/app.tsx Normal file
View file

@ -0,0 +1,15 @@
import React from "react";
import AlbumList from "../containers/album-list";
import FilterInput from "../containers/filter-input";
import Modal from "../containers/modal";
export default () => (
<React.Fragment>
<header>
<h1>Brütal Legend</h1>
<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,20 @@
import React from "react";
export default (props: FilterInput) => {
const { value, handleOnChange } = props;
return (
<div>
<input
type="text"
value={value}
onChange={evt => handleOnChange(evt.target.value)}
placeholder="Filtrera på år, artist, låt, skivtitel ..."
/>
</div>
);
};
interface FilterInput {
value: string;
handleOnChange: Function;
}

View file

@ -1,46 +0,0 @@
import React, { Component } from 'react';
export default class Modal extends Component {
handleKeyPress(e, callback) {
alert("sdsdsd")
console.log(e.charCode)
callback();
}
render() {
const {
id,
artist,
title,
songs,
year,
img,
description,
handleOnClick,
} = this.props;
if (id === undefined) {
return '';
}
const imagePath = `assets/covers/${img}`;
const song = songs.join(', ');
return (
<div className="selected-album"
tabIndex="0"
onClick={handleOnClick}
onKeyPress={e => this.handleKeyPress(e, handleOnClick)}>
<div className="selected-album__inner">
<span className="selected-album__summary">
#{id+1}: {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>
);
}
}

42
src/components/modal.tsx Normal file
View file

@ -0,0 +1,42 @@
import React from "react";
import { Album } from "../interfaces";
interface Modal {
album: Album;
handleOnClick: Function;
}
export default (props: Modal) => {
const handleKeyPress = (e: KeyboardEvent, callback: Function) => {
console.log(e.charCode);
callback();
};
const { album, handleOnClick } = props;
const { id, artist, title, songs, year, img, description } = album;
if (id === undefined) {
return "";
}
// const imagePath = `assets/covers/${img}`;
const song = songs.join(", ");
return (
<div
className="selected-album"
tabIndex={0}
onClick={() => handleOnClick()}
onKeyPress={e => handleKeyPress(e, handleOnClick)}
>
<div className="selected-album__inner">
<span className="selected-album__summary">
#{id + 1}: {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>
);
};

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,24 @@
import React from "react";
export default (props: SortSelect) => {
const { value, handleOnChange } = 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>
);
};
interface SortSelect {
value: string;
handleOnChange: Function;
}

View file

@ -1,24 +0,0 @@
import React from 'react';
import { connect } from 'react-redux';
import AlbumList from '../components/album-list';
import { selectAlbum } from '../actions'
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),
blurred: "id" in state.selectedAlbum,
});
const mapDispatchToProps = dispatch => ({
handleOnClick: album => dispatch(selectAlbum(album)),
});
export default connect(mapStateToProps, mapDispatchToProps)(AlbumList);

View file

@ -0,0 +1,26 @@
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) => {
if (filter) {
const term = filter.toLowerCase();
return albums.filter((album: Album) => atos(album).match(term));
}
return albums;
};
const mapStateToProps = (state: State) => ({
albums: getAlbums(state.albums, state.visibilityFilter),
blurred: "id" in state.selectedAlbum
});
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);

View file

@ -1,14 +0,0 @@
import React from 'react';
import { connect } from 'react-redux';
import Modal from '../components/modal';
import { unselectAlbum } from '../actions';
const mapStateToProps = state => ({
...state.selectedAlbum,
});
const mapDispatchToProps = dispatch => ({
handleOnClick: album => dispatch(unselectAlbum(album)),
});
export default connect(mapStateToProps, mapDispatchToProps)(Modal);

15
src/containers/modal.ts Normal file
View file

@ -0,0 +1,15 @@
import React from "react";
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) => ({
handleOnClick: () => 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,15 @@
import React from "react";
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, 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';
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' }});

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";
interface AlbumsAction {
type: string;
payload: {
albums: Array<Album>;
};
}
export default (state: Array<Album> = [], action: AlbumsAction) => {
switch (action.type) {
case LOAD_ALBUMS_OK:
const { albums } = action.payload;
return albums;
default:
return state;
}
};

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

0
src/reducers/index.ts Normal file
View file

View file

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

View file

@ -0,0 +1,20 @@
import { SELECT_ALBUM, UNSELECT_ALBUM } from "../actions";
import { Album } from "../interfaces";
interface AlbumAction {
type: string;
payload: {
album: Album;
};
}
export default (state: Object = {}, action: AlbumAction) => {
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";
interface SortKeyAction {
type: string;
payload: {
key: string;
};
}
export default (state: string = "id", action: SortKeyAction) => {
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";
interface VisibilityFilterAction {
type: string;
payload: {
filter: string;
};
}
export default (state: string = "", action: VisibilityFilterAction) => {
switch (action.type) {
case SET_VISIBILITY_FILTER:
return action.payload.filter;
default:
return state;
}
};

View file

@ -1,25 +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) {
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(),
]);
};

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

10
tsconfig.json Normal file
View file

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