rename 'react' to 'client'

This commit is contained in:
2018-09-22 17:45:45 +03:00
parent 66fc2758b0
commit cc9c897bc0
53 changed files with 45 additions and 17 deletions

30
client/.eslintrc.json Normal file
View File

@@ -0,0 +1,30 @@
{
"extends": [
"airbnb",
"plugin:jest/recommended",
"prettier",
"prettier/react"
],
"plugins": ["jest", "prettier"],
"rules": {
"react/jsx-filename-extension": [
1,
{
"extensions": [".js", ".jsx"]
}
],
"linebreak-style": "off",
"no-unused-expressions": [
"error",
{
"allowTernary": true
}
],
"react/forbid-prop-types": "off",
"prettier/prettier": "error"
},
"env": {
"browser": true
}
}

21
client/.gitignore vendored Normal file
View File

@@ -0,0 +1,21 @@
# See https://help.github.com/ignore-files/ for more about ignoring files.
# dependencies
/node_modules
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

4
client/.prettierrc Normal file
View File

@@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

2444
client/README.md Normal file

File diff suppressed because it is too large Load Diff

12630
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

44
client/package.json Normal file
View File

@@ -0,0 +1,44 @@
{
"name": "todolist-web",
"version": "0.1.0",
"private": true,
"dependencies": {
"@material-ui/core": "^3.0.3",
"@material-ui/icons": "^3.0.1",
"@redux-offline/redux-offline": "^2.4.0",
"localforage": "^1.7.2",
"prop-types": "^15.6.2",
"react": "^16.5.0",
"react-dom": "^16.5.0",
"react-redux": "^5.0.7",
"react-router-dom": "^4.3.1",
"react-router-redux": "^4.0.8",
"react-scripts": "1.1.5",
"react-spring": "^5.7.2",
"redux": "^4.0.0",
"redux-form": "^7.4.2",
"redux-thunk": "^2.3.0"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test --env=jsdom",
"eject": "react-scripts eject"
},
"proxy": {
"/__": {
"target": "http://localhost:4000",
"ws": true
}
},
"devDependencies": {
"eslint": "^5.5.0",
"eslint-config-airbnb": "^17.1.0",
"eslint-config-prettier": "^3.0.1",
"eslint-plugin-import": "^2.14.0",
"eslint-plugin-jest": "^21.22.0",
"eslint-plugin-jsx-a11y": "^6.1.1",
"eslint-plugin-react": "^7.11.1",
"prettier-eslint": "^8.8.2"
}
}

BIN
client/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

44
client/public/index.html Normal file
View File

@@ -0,0 +1,44 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="theme-color" content="#4ba6fa">
<!--
manifest.json provides metadata used when your web app is added to the
homescreen on Android. See https://developers.google.com/web/fundamentals/engage-and-retain/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json">
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
<link rel="preconnect" href="http://fonts.googleapis.com">
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Todolist</title>
</head>
<body>
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

View File

@@ -0,0 +1,15 @@
{
"short_name": "Todolist",
"name": "Simple todo list",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
}
],
"start_url": "./",
"display": "standalone",
"theme_color": "#4ba6fa",
"background_color": "#fbfbfb"
}

View File

@@ -0,0 +1,38 @@
export const ADD_LIST = 'ADD_LIST';
export const REMOVE_LIST = 'REMOVE_LIST';
export const EDIT_LIST_NAME = 'EDIT_LIST_NAME';
export const RECIEVE_LISTS = 'RECIEVE_LISTS';
export const REQUEST_LISTS = 'REQUEST_LISTS';
export const INVALIDATE_LISTS = 'INVALIDATE_LISTS';
export const VALIDATE_LISTS = 'VALIDATE_LISTS';
export const CHANGE_LIST = 'CHANGE_LIST';
export const START_CREATE_LIST = 'START_CREATE_LIST';
export const START_EDIT_LIST = 'START_EDIT_LIST';
export const STOP_CREATE_LIST = 'STOP_CREATE_LIST';
export const STOP_EDIT_LIST = 'STOP_EDIT_LIST';
export const ADD_TODO = 'ADD_TODO';
export const REMOVE_TODO = 'REMOVE_TODO';
export const TOGGLE_TODO = 'TOGGLE_TODO';
export const EDIT_TODO = 'EDIT_TODO';
export const SET_VISIBILITY_FILTER = 'SET_VISIBILITY_FILTER';
export const RECIEVE_TODOS = 'RECIEVE_TODOS';
export const REQUEST_TODOS = 'REQUEST_TODOS';
export const INVALIDATE_TODOS = 'INVALIDATE_TODOS';
export const VALIDATE_TODOS = 'VALIDATE_TODOS';
export const VisibilityFilters = {
SHOW_ALL: 'SHOW_ALL',
SHOW_COMPLETED: 'SHOW_COMPLETED',
SHOW_ACTIVE: 'SHOW_ACTIVE',
};
export const LOGIN_SUCCESS = 'LOGIN_SUCCESS';
export const LOGIN_FAIL = 'LOGIN_FAIL';
export const SIGNUP_SUCCESS = 'SIGNUP_SUCCESS';
export const SIGNUP_FAIL = 'SIGNUP_FAIL';
export const LOGOUT = 'LOGOUT';
export const START_LOGIN = 'INVALIDATE_USER';
export const REQUEST_USER = 'REQUEST_USER';
export const VALIDATE_USER = 'VALIDATE_USER';
export const RESET_USER = 'RESET_USER';

162
client/src/actions/lists.js Normal file
View File

@@ -0,0 +1,162 @@
import {
REQUEST_LISTS,
RECIEVE_LISTS,
CHANGE_LIST,
START_CREATE_LIST,
START_EDIT_LIST,
STOP_CREATE_LIST,
STOP_EDIT_LIST,
ADD_LIST,
INVALIDATE_LISTS,
REMOVE_LIST,
EDIT_LIST_NAME,
RECIEVE_TODOS,
} from './defs';
import { API_ROOT, getToken, mongoObjectId } from './util';
function requestLists() {
return { type: REQUEST_LISTS };
}
function recieveLists(lists) {
return { type: RECIEVE_LISTS, lists };
}
export function changeList(list) {
return { type: CHANGE_LIST, list };
}
export function startCreateList() {
return { type: START_CREATE_LIST };
}
export function startEditList() {
return { type: START_EDIT_LIST };
}
export function stopCreateList() {
return { type: STOP_CREATE_LIST };
}
export function stopEditList() {
return { type: STOP_EDIT_LIST };
}
export function addList(name) {
return async dispatch => {
const id = mongoObjectId();
dispatch({
type: ADD_LIST,
list: {
name,
id,
todos: [],
},
meta: {
offline: {
effect: {
url: `${API_ROOT}/lists`,
body: JSON.stringify({ name, id }),
headers: {
Authorization: `Bearer ${getToken()}`,
'content-type': 'application/json',
},
method: 'POST',
},
rollback: {
type: INVALIDATE_LISTS,
},
},
},
});
};
}
export function removeList() {
return async (dispatch, getState) => {
const state = getState();
const { list } = state.lists;
dispatch({
type: REMOVE_LIST,
list,
meta: {
offline: {
effect: {
url: `${API_ROOT}/lists/${list}`,
headers: {
Authorization: `Bearer ${getToken()}`,
'content-type': 'application/json',
},
method: 'DELETE',
},
rollback: {
type: INVALIDATE_LISTS,
},
},
},
});
};
}
export function editList(name) {
return async (dispatch, getState) => {
const state = getState();
const { list } = state.lists;
dispatch({
type: EDIT_LIST_NAME,
list,
name,
meta: {
offline: {
effect: {
url: `${API_ROOT}/lists/${list}`,
body: JSON.stringify({ name }),
headers: {
Authorization: `Bearer ${getToken()}`,
'content-type': 'application/json',
},
method: 'PATCH',
},
rollback: {
type: INVALIDATE_LISTS,
},
},
},
});
};
}
function normalizeTodos(lists) {
return lists.reduce((todos, list) => {
const listTodosObj = list.todos.reduce(
(listTodos, todo) => ({
...listTodos,
[todo.id]: { ...todo },
}),
{},
);
return { ...todos, ...listTodosObj };
}, {});
}
export function fetchLists() {
return async dispatch => {
dispatch(requestLists());
const response = await fetch(`${API_ROOT}/lists`, {
headers: {
Authorization: `Bearer ${getToken()}`,
},
});
const json = await response.json();
const lists = json.data;
const listsObj = lists.reduce((obj, curList) => {
const newObj = { ...obj };
newObj[curList.id] = {
dirty: true,
fetching: false,
editing: false,
...curList,
todos: curList.todos.map(todo => todo.id),
};
return newObj;
}, {});
dispatch({ type: RECIEVE_TODOS, todos: normalizeTodos(lists) });
dispatch(recieveLists(listsObj));
};
}

137
client/src/actions/todos.js Normal file
View File

@@ -0,0 +1,137 @@
import {
REQUEST_TODOS,
RECIEVE_TODOS,
ADD_TODO,
REMOVE_TODO,
TOGGLE_TODO,
EDIT_TODO,
INVALIDATE_LISTS,
} from './defs';
import { API_ROOT, getToken, mongoObjectId } from './util';
export function fetchTodos() {
return async dispatch => {
dispatch({ type: REQUEST_TODOS });
const response = await fetch(`${API_ROOT}/todos`, {
headers: {
Authorization: `Bearer ${getToken()}`,
},
});
const json = await response.json();
const todos = json.data;
dispatch({ type: RECIEVE_TODOS, todos });
};
}
export function addTodo(text) {
return async (dispatch, getState) => {
const state = getState();
const { list } = state.lists;
const id = mongoObjectId();
if (list) {
dispatch({
type: ADD_TODO,
todo: {
text,
id,
completed: false,
},
meta: {
offline: {
effect: {
url: `${API_ROOT}/lists/${list}/todos`,
body: JSON.stringify({ text, id }),
headers: {
Authorization: `Bearer ${getToken()}`,
'content-type': 'application/json',
},
method: 'POST',
},
rollback: {
type: INVALIDATE_LISTS,
},
},
},
});
}
};
}
export function removeTodo(id) {
return async dispatch => {
dispatch({
type: REMOVE_TODO,
id,
meta: {
offline: {
effect: {
url: `${API_ROOT}/todos/${id}`,
headers: {
Authorization: `Bearer ${getToken()}`,
'content-type': 'application/json',
},
method: 'DELETE',
},
rollback: {
type: INVALIDATE_LISTS,
},
},
},
});
};
}
export function toggleTodo(id) {
return async (dispatch, getState) => {
const state = getState();
const todoObj = state.todos.todos[id];
const completed = !todoObj.completed;
dispatch({
type: TOGGLE_TODO,
id,
meta: {
offline: {
effect: {
url: `${API_ROOT}/todos/${id}`,
body: JSON.stringify({ completed }),
headers: {
Authorization: `Bearer ${getToken()}`,
'content-type': 'application/json',
},
method: 'PATCH',
},
rollback: {
type: INVALIDATE_LISTS,
},
},
},
});
};
}
export function editTodo(id, text) {
return async dispatch => {
dispatch({
type: EDIT_TODO,
id,
text,
meta: {
offline: {
effect: {
url: `${API_ROOT}/todos/${id}`,
body: JSON.stringify({ text }),
headers: {
Authorization: `Bearer ${getToken()}`,
'content-type': 'application/json',
},
method: 'PATCH',
},
rollback: {
type: INVALIDATE_LISTS,
},
},
},
});
};
}

133
client/src/actions/user.js Normal file
View File

@@ -0,0 +1,133 @@
import {
START_LOGIN,
LOGIN_SUCCESS,
LOGIN_FAIL,
VALIDATE_USER,
SIGNUP_SUCCESS,
SIGNUP_FAIL,
RESET_USER,
LOGOUT,
} from './defs';
import { API_ROOT, getToken, setToken } from './util';
import { fetchLists } from './lists';
function startLogin() {
return { type: START_LOGIN };
}
function loginSuccess(user) {
return { type: LOGIN_SUCCESS, user };
}
function loginFail(error) {
return { type: LOGIN_FAIL, error };
}
function validateUser() {
return { type: VALIDATE_USER };
}
export function loadUser() {
return async dispatch => {
if (getToken()) {
const response = await fetch(`${API_ROOT}/users/user`, {
headers: {
Authorization: `Bearer ${getToken()}`,
'content-type': 'application/json',
},
method: 'GET',
});
const json = await response.json();
if (json.success) {
dispatch(loginSuccess(json.data));
dispatch(fetchLists());
} else {
dispatch(loginFail(json.error));
}
} else {
dispatch(validateUser());
}
};
}
export function login(user) {
return async dispatch => {
dispatch(startLogin());
const response = await fetch(`${API_ROOT}/users/login`, {
body: JSON.stringify(user),
headers: {
'content-type': 'application/json',
},
method: 'POST',
});
const json = await response.json();
if (json.success) {
setToken(json.data.jwt);
dispatch(loginSuccess(json.data));
dispatch(fetchLists());
} else {
dispatch(loginFail(json.error));
}
};
}
export function loginJWT(jwt) {
return async dispatch => {
dispatch(startLogin());
const response = await fetch(`${API_ROOT}/users/user`, {
headers: {
'content-type': 'application/json',
Authorization: `Bearer ${jwt}`,
},
method: 'GET',
});
const json = await response.json();
if (json.success) {
setToken(jwt);
dispatch(loginSuccess(json.data));
dispatch(fetchLists());
} else {
dispatch(loginFail(json.error));
}
};
}
function signupSuccess(user) {
return { type: SIGNUP_SUCCESS, user };
}
function signupFail(error) {
return { type: SIGNUP_FAIL, error };
}
export function signup(user) {
return async dispatch => {
dispatch(startLogin());
const response = await fetch(`${API_ROOT}/users`, {
body: JSON.stringify(user),
headers: {
'content-type': 'application/json',
},
method: 'POST',
});
const json = await response.json();
if (json.success) {
setToken(json.data.jwt);
dispatch(signupSuccess(json.data));
dispatch(fetchLists());
} else {
dispatch(signupFail(json.error));
}
};
}
export function reset() {
return { type: RESET_USER };
}
export function logout() {
return async dispatch => {
dispatch({ type: LOGOUT });
};
}

View File

@@ -0,0 +1,23 @@
export const API_ROOT = '/__';
let token = null;
export function setToken(_token) {
token = _token;
}
export function getToken() {
return token;
}
export function mongoObjectId() {
// eslint-disable-next-line
const timestamp = ((new Date().getTime() / 1000) | 0).toString(16);
return (
timestamp +
'xxxxxxxxxxxxxxxx'
// eslint-disable-next-line
.replace(/[x]/g, () => ((Math.random() * 16) | 0).toString(16))
.toLowerCase()
);
}

View File

@@ -0,0 +1,5 @@
import { SET_VISIBILITY_FILTER } from './defs';
export default function setVisibilityFilter(filter) {
return { type: SET_VISIBILITY_FILTER, filter };
}

View File

@@ -0,0 +1,208 @@
#lists-header {
display: flex;
justify-content: space-between;
background-color: #fbfbfb;
}
#lists {
display: flex;
flex-direction: row;
flex-grow: 1;
width: 80%;
margin-right: 1rem;
}
.loading {
align-self: center;
margin-left: 2rem;
}
#listactions {
display: flex;
flex-direction: column;
justify-content: center;
margin: 0.2rem 0.1rem;
margin-left: 0.5rem;
}
#listactions button {
color: #555555;
background: none;
border: none;
margin: 0.1rem 0.3rem;
padding: 0.3rem 0.7em;
}
#listactions .backbutton {
margin: 1rem 0;
}
#filters {
display: flex;
justify-content: center;
margin: 0;
align-self: center;
flex-shrink: 0;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
position: sticky;
z-index: 5;
}
#filters button {
min-width: 3rem;
}
#inputs {
box-shadow: 0 2px 7px rgba(0, 0, 0, 0.1);
display: flex;
height: 2.5rem;
position: relative;
z-index: 10;
}
#input {
color: black;
background: white;
font-family: 'Roboto';
box-sizing: border-box;
font-size: 1rem;
flex-grow: 1;
margin: 0;
padding: 0;
padding-left: 1rem;
height: 100%;
line-height: 100%;
border: none;
}
#input::placeholder {
opacity: 0.35;
}
#input:focus::placeholder {
opacity: 0;
}
#add {
color: black;
flex-grow: 0;
margin: 0;
padding: 0;
height: 100%;
max-width: 2rem;
background-color: white;
border: none;
}
li:first-child .delete,
li:first-child .edit,
li:first-child .save,
li:first-child .todo {
border-top: none;
}
.done {
color: rgba(0, 0, 0, 0.6);
text-decoration: line-through;
}
li button {
color: black;
outline: none;
text-align: left;
flex-shrink: 0;
box-sizing: border-box;
padding: 0;
text-align: center;
border: none;
border-top: 1px solid #f0f0f0;
background: none;
width: 2rem;
font-size: 1rem;
transition: 0.17s ease-in-out;
overflow: hidden;
box-shadow: inset 3px 0 6px -3px rgba(0, 0, 0, 0.3);
}
/* make buttons bigger on smartphones */
@media only screen and (max-width: 600px) {
li button {
padding: 0 1.5rem !important;
}
}
li button.todo {
padding: 0.5rem;
box-shadow: none;
}
ul {
margin: 0;
padding: 0;
}
li {
height: 60px;
width: 100%;
font-weight: 500;
overflow: hidden;
font-size: 1rem;
display: flex;
}
.todo {
text-align: left;
box-sizing: border-box;
word-wrap: break-word;
width: 100%;
padding: 0.5rem;
padding-left: 1rem;
border-top: 1px solid #f0f0f0;
font-weight: 400;
flex-grow: 2;
flex-shrink: 1;
transition: 0.1s ease-in-out;
font-family: Roboto;
}
textarea.todo--input {
background: #fafafa;
color: black;
resize: none;
border: none;
margin: 0;
padding: 0;
box-sizing: border-box;
word-wrap: break-word;
width: 100%;
height: 100%;
max-height: 100%;
font-family: 'Roboto';
font-size: 1rem;
transition: 0.1s ease-in-out;
}
.disabled {
width: 2rem;
background-color: #fafafa;
color: #555555;
box-shadow: none;
padding: 0.5rem 1rem;
border-top: 1px solid #f0f0f0;
}
.filter {
margin: 0.1rem;
color: #555555;
border: none;
background: none;
transition: 0.1s ease-in-out;
font-size: 1rem;
font-weight: 400;
font-family: Roboto;
}
.filter--active {
font-weight: 500;
color: black;
}

View File

@@ -0,0 +1,37 @@
import * as React from 'react';
import PropTypes from 'prop-types';
import { BrowserRouter as Router, Route } from 'react-router-dom';
import CssBaseline from '@material-ui/core/CssBaseline';
import './Container.css';
import './App.css';
import TodosView from './todolist/TodosView';
import LoginForm from './user/LoginForm';
import SignupForm from './user/SignupForm';
export default class App extends React.PureComponent {
componentDidMount() {
const { loadUser } = this.props;
loadUser();
}
render() {
return (
<React.Fragment>
<CssBaseline />
<Router>
<div id="container">
<Route exact path="/" component={TodosView} />
<Route path="/login" component={LoginForm} />
<Route path="/signup" component={SignupForm} />
</div>
</Router>
</React.Fragment>
);
}
}
App.propTypes = {
loadUser: PropTypes.func.isRequired,
};

View File

@@ -0,0 +1,22 @@
import { connect } from 'react-redux';
import App from './App';
import { loadUser } from '../actions/user';
function mapStateToProps(state) {
return {
user: state.user,
};
}
function mapDispatchToProps(dispatch) {
return {
loadUser: () => dispatch(loadUser()),
};
}
export default connect(
mapStateToProps,
mapDispatchToProps,
)(App);

View File

@@ -0,0 +1,57 @@
@import url('https://fonts.googleapis.com/css?family=Roboto:300,400,500|Material+Icons');
body {
background: white;
color: black;
font-family: 'Roboto';
user-select: none;
}
#root {
margin-top: 5rem;
}
#container {
overflow: hidden;
margin: 0 auto;
padding: 0;
max-width: 25rem;
border: 1px solid #dddddd;
border-radius: 7px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
}
@media only screen and (max-width: 600px) {
#container {
max-width: 100%;
}
}
#user-header {
display: flex;
height: 2rem;
position: relative;
z-index: 20;
box-shadow: 0 2px 7px rgba(0, 0, 0, 0.1);
justify-content: flex-end;
align-content: center;
}
#user-header button {
box-sizing: border-box;
margin-right: 0.2rem;
height: 100%;
flex-grow: 0;
flex-shrink: 1;
color: #888888;
border: none;
background: none;
transition: 0.1s ease-in-out;
font-size: 0.8rem;
font-weight: 300;
font-family: Roboto;
}
#user-header button:hover {
color: #555555;
}

View File

@@ -0,0 +1,12 @@
import React from 'react';
import UserHeader from './user/UserHeader';
import Lists from './lists/Lists';
export default function Header() {
return (
<div id="header">
<UserHeader />
<Lists />
</div>
);
}

View File

@@ -0,0 +1,51 @@
import { connect } from 'react-redux';
import React from 'react';
import PropTypes from 'prop-types';
import { ButtonBase } from '@material-ui/core';
import setVisibilityFilter from '../../actions/visibilityFilter';
function Link({ active, onClick, children }) {
const classes = ['filter'];
if (active) {
classes.push('filter--active');
}
return (
<ButtonBase
style={{
padding: '0 1rem',
color: active ? 'black' : '#444444',
height: '2rem',
}}
className={classes.join(' ')}
onClick={e => {
e.preventDefault();
onClick();
}}
>
{children}
</ButtonBase>
);
}
Link.propTypes = {
active: PropTypes.bool.isRequired,
onClick: PropTypes.func.isRequired,
children: PropTypes.node.isRequired,
};
function mapStateToProps(state, ownProps) {
return {
active: ownProps.filter === state.visibilityFilter,
};
}
function mapDispatchToProps(dispatch, ownProps) {
return {
onClick: () => dispatch(setVisibilityFilter(ownProps.filter)),
};
}
export default connect(
mapStateToProps,
mapDispatchToProps,
)(Link);

View File

@@ -0,0 +1,17 @@
import React from 'react';
import FilterLink from './FilterLink';
import { VisibilityFilters } from '../../actions/defs';
function Filters(styles) {
return (
<div style={styles} id="filters">
<FilterLink filter={VisibilityFilters.SHOW_ALL}>all</FilterLink>
<FilterLink filter={VisibilityFilters.SHOW_ACTIVE}>active</FilterLink>
<FilterLink filter={VisibilityFilters.SHOW_COMPLETED}>
completed
</FilterLink>
</div>
);
}
export default Filters;

View File

@@ -0,0 +1,112 @@
import DeleteIcon from '@material-ui/icons/Delete';
import AddIcon from '@material-ui/icons/Add';
import EditIcon from '@material-ui/icons/Edit';
import BackButton from '@material-ui/icons/ArrowBack';
import React from 'react';
import PropTypes from 'prop-types';
import { Transition, config, animated } from 'react-spring';
const button = {
width: 30,
height: 30,
padding: 0,
};
const icon = {
fontSize: 24,
};
export default function ListActions({
startCreateList,
removeList,
startEditList,
stopCreateList,
stopEditList,
creating,
editing,
list,
}) {
function back() {
if (editing) {
stopEditList();
}
if (creating) {
stopCreateList();
}
}
const actions = [];
if (!creating && !editing) {
actions.push(styles => (
<animated.button
key="create"
style={{ ...button, ...styles }}
onClick={() => startCreateList()}
>
<AddIcon style={icon} />
</animated.button>
));
}
if (list && !creating && !editing) {
actions.push(styles => (
<animated.button
key="remove"
style={{ ...button, ...styles }}
onClick={() => removeList()}
>
<DeleteIcon style={icon} />
</animated.button>
));
}
if (list && !creating && !editing) {
actions.push(styles => (
<animated.button
key="edit"
style={{ ...button, ...styles }}
onClick={() => startEditList()}
>
<EditIcon style={icon} />
</animated.button>
));
}
if (creating || editing) {
actions.push(styles => (
<animated.button
key="back"
style={{ ...button, ...styles }}
className="backbutton"
onClick={() => back()}
>
<BackButton style={icon} />
</animated.button>
));
}
return (
<div id="listactions">
<Transition
native
config={config.stiff}
keys={actions.map(action => action({}).key)}
from={{ opacity: 0, height: 0, margin: 0, padding: 0 }}
enter={{ opacity: 1, height: 30, margin: 0, padding: 0 }}
leave={{ opacity: 0, height: 0, margin: 0, padding: 0 }}
>
{actions}
</Transition>
</div>
);
}
ListActions.defaultProps = {
list: '',
};
ListActions.propTypes = {
startCreateList: PropTypes.func.isRequired,
removeList: PropTypes.func.isRequired,
startEditList: PropTypes.func.isRequired,
creating: PropTypes.bool.isRequired,
editing: PropTypes.bool.isRequired,
list: PropTypes.string,
stopCreateList: PropTypes.func.isRequired,
stopEditList: PropTypes.func.isRequired,
};

View File

@@ -0,0 +1,31 @@
import { connect } from 'react-redux';
import ListActions from './ListActions';
import {
startCreateList,
startEditList,
removeList,
stopCreateList,
stopEditList,
} from '../../actions/lists';
function mapStateToProps(state) {
return {
list: state.lists.list,
creating: state.lists.creating,
editing: state.lists.editing,
};
}
function mapDispatchToProps(dispatch) {
return {
startCreateList: () => dispatch(startCreateList()),
startEditList: () => dispatch(startEditList()),
stopCreateList: () => dispatch(stopCreateList()),
stopEditList: () => dispatch(stopEditList()),
removeList: () => dispatch(removeList()),
};
}
export default connect(
mapStateToProps,
mapDispatchToProps,
)(ListActions);

View File

@@ -0,0 +1,36 @@
import { connect } from 'react-redux';
import React from 'react';
import PropTypes from 'prop-types';
import ListActionsContainer from './ListActionsContainer';
import SelectorContainer from './SelectorContainer';
function Lists({ userLoaded, listsLoaded }) {
return (
<div id="lists-header">
{userLoaded &&
listsLoaded && (
<div id="lists">
<ListActionsContainer />
<SelectorContainer />
</div>
)}
{!userLoaded && <span className="loading">loading.</span>}
{userLoaded && !listsLoaded && <span className="loading">loading..</span>}
</div>
);
}
Lists.propTypes = {
userLoaded: PropTypes.bool.isRequired,
listsLoaded: PropTypes.bool.isRequired,
};
function mapStateToProps(state) {
return {
userLoaded: state.user.loaded,
listsLoaded: state.lists.loaded,
};
}
export default connect(mapStateToProps)(Lists);

View File

@@ -0,0 +1,53 @@
#listselector {
display: flex;
margin-left: 1rem;
overflow: hidden;
align-self: center;
background-color: #fbfbfb;
flex-grow: 1;
margin-top: 1rem;
margin-bottom: 1rem;
}
#listselector input {
padding: 0;
color: black;
font-size: 1.5rem;
font-weight: 400;
outline: none;
border: none;
background-color: #fbfbfb;
border-bottom: 1px solid #888888;
width: 80%;
}
#listselector button {
align-self: center;
width: 20%;
font-size: 0.9rem;
color: #1b881b;
background: none;
border: none;
margin: 0.1rem 0.3rem;
padding: 0.3rem 0.7em;
}
#listselector select {
max-width: 100%;
color: black;
font-size: 1.5rem;
font-weight: 400;
outline: none;
border: none;
background-color: #fbfbfb;
}
#listselector select option {
max-width: 100%;
color: black;
font-size: 1.5rem;
font-weight: 400;
outline: none;
border: none;
background-color: #fbfbfb;
}

View File

@@ -0,0 +1,134 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Select, MenuItem } from '@material-ui/core';
import AddIcon from '@material-ui/icons/Add';
import CheckIcon from '@material-ui/icons/Check';
import { Transition, config } from 'react-spring';
import './Selector.css';
const button = {
width: 36,
height: 36,
};
const icon = {
fontSize: 24,
};
export default function Selector({
lists,
list,
onChange,
editing,
creating,
addList,
editList,
}) {
if (creating) {
let input = null;
return (
<div id="listselector" className="list--input">
<Transition
native
config={config.stiff}
from={{ paddingBottom: 18 }}
enter={{ paddingBottom: 0 }}
leave={{ paddingBottom: 18 }}
>
{styles => (
<input
ref={node => {
input = node;
}}
style={styles}
id="input"
type="text"
onKeyPress={e => {
if (e.key === 'Enter') {
addList(input.value);
}
}}
/>
)}
</Transition>
<button
style={button}
type="submit"
onClick={() => input.value.trim() && addList(input.value)}
>
<AddIcon style={icon} />
</button>
</div>
);
}
if (editing) {
let input = null;
return (
<div id="listselector" className="list--input">
<Transition
config={config.stiff}
from={{ paddingBottom: 18 }}
enter={{ paddingBottom: 0 }}
leave={{ paddingBottom: 18 }}
>
{styles => (
<input
ref={node => {
input = node;
}}
style={styles}
defaultValue={lists.lists[list].name}
id="input"
type="text"
onKeyPress={e => {
if (e.key === 'Enter') {
editList(input.value);
}
}}
/>
)}
</Transition>
<button
style={button}
type="submit"
onClick={() => input.value.trim() && editList(input.value)}
>
<CheckIcon style={icon} />
</button>
</div>
);
}
if (list) {
return (
<div id="listselector">
<Select
style={{ fontSize: '1.5rem', width: '100%' }}
value={list}
onChange={e => onChange(e.target.value)}
>
{Object.values(lists.lists).map(elem => (
<MenuItem key={elem.id} value={elem.id}>
{elem.name}
</MenuItem>
))}
</Select>
</div>
);
}
return null;
}
Selector.defaultProps = {
list: '',
};
Selector.propTypes = {
list: PropTypes.string,
editing: PropTypes.bool.isRequired,
creating: PropTypes.bool.isRequired,
onChange: PropTypes.func.isRequired,
editList: PropTypes.func.isRequired,
addList: PropTypes.func.isRequired,
lists: PropTypes.object.isRequired,
};

View File

@@ -0,0 +1,25 @@
import { connect } from 'react-redux';
import Selector from './Selector';
import { changeList, addList, editList } from '../../actions/lists';
function mapStateToProps(state) {
return {
lists: state.lists,
list: state.lists.list,
editing: state.lists.editing,
creating: state.lists.creating,
};
}
function mapDispatchToProps(dispatch) {
return {
onChange: list => dispatch(changeList(list)),
addList: name => dispatch(addList(name)),
editList: name => dispatch(editList(name)),
};
}
export default connect(
mapStateToProps,
mapDispatchToProps,
)(Selector);

View File

@@ -0,0 +1,162 @@
import * as React from 'react';
import PropTypes from 'prop-types';
import { animated } from 'react-spring';
import { ButtonBase } from '@material-ui/core';
import DeleteIcon from '@material-ui/icons/Delete';
import EditIcon from '@material-ui/icons/Edit';
import CheckIcon from '@material-ui/icons/Check';
const icon = {
fontSize: 24,
padding: 0,
};
const disabledAction = {
backgroundColor: '#fafafa',
color: '#dddddd',
};
class Todo extends React.Component {
constructor(props) {
super(props);
this.state = {
hover: false,
};
this.onMouseOver = this.onMouseOver.bind(this);
this.onMouseOut = this.onMouseOut.bind(this);
this.startEdit = this.startEdit.bind(this);
this.stopEdit = this.stopEdit.bind(this);
}
onMouseOver() {
const { state } = this;
this.setState({
...state,
hover: true,
});
}
onMouseOut() {
const { state } = this;
this.setState({
...state,
hover: false,
});
}
startEdit() {
const { state } = this;
this.setState({
...state,
editing: true,
});
}
stopEdit(value) {
const { editTodo } = this.props;
editTodo(value);
const { state } = this;
this.setState({
...state,
editing: false,
hover: false,
});
}
render() {
const deleteClasses = ['delete'];
const editClasses = ['edit'];
const { hover, editing } = this.state;
const { todo, removeTodo, toggleTodo, style } = this.props;
if (!hover) {
deleteClasses.push('disabled');
editClasses.push('disabled');
}
let input;
const text = editing ? (
<div className="todo">
<textarea
className="todo--input"
defaultValue={todo.text}
ref={node => {
input = node;
}}
/>
</div>
) : (
<ButtonBase
style={{
justifyContent: 'left',
paddingLeft: '1rem',
textDecoration: todo.completed ? 'line-through' : 'none',
color: todo.completed ? '#888888' : 'black',
}}
className="todo"
onClick={() => {
toggleTodo();
}}
>
{todo.text}
</ButtonBase>
);
const ButtonBases = editing
? [
<ButtonBase
key="save"
style={{ backgroundColor: 'lightgreen' }}
className="save"
onClick={() => this.stopEdit(input.value)}
>
<CheckIcon style={icon} />
</ButtonBase>,
]
: [
<ButtonBase
key="remove"
style={hover ? { backgroundColor: 'pink' } : disabledAction}
className={deleteClasses.join(' ')}
onClick={removeTodo}
>
<DeleteIcon style={icon} />
</ButtonBase>,
<ButtonBase
key="edit"
style={hover ? { backgroundColor: 'lightcyan' } : disabledAction}
className={editClasses.join(' ')}
onClick={this.startEdit}
>
<EditIcon style={icon} />
</ButtonBase>,
];
return (
<animated.li
style={{
...style,
borderTop: '1px solid #f0f0f0',
}}
onMouseOver={this.onMouseOver}
onFocus={this.onMouseOver}
onMouseOut={this.onMouseOut}
onBlur={this.onMouseOut}
>
{text}
{ButtonBases}
</animated.li>
);
}
}
Todo.propTypes = {
todo: PropTypes.shape({
id: PropTypes.string.isRequired,
text: PropTypes.string.isRequired,
completed: PropTypes.bool.isRequired,
}).isRequired,
removeTodo: PropTypes.func.isRequired,
toggleTodo: PropTypes.func.isRequired,
editTodo: PropTypes.func.isRequired,
style: PropTypes.shape({ height: PropTypes.object.isRequired }).isRequired,
};
export default Todo;

View File

@@ -0,0 +1,71 @@
import * as React from 'react';
import PropTypes from 'prop-types';
import { Transition, config } from 'react-spring';
import Todo from './Todo';
export default function TodosContainer({
todos,
toggleTodo,
removeTodo,
editTodo,
}) {
return (
<ul id="list">
<Transition
native
config={{
...config.default,
overshootClamping: true,
restSpeedThreshold: 1,
restDisplacementThreshold: 1,
}}
items={todos}
keys={todo => todo.id}
from={{
height: 0,
borderColor: '#f0f0f0',
opacity: 0.7,
}}
enter={{
height: 60,
borderColor: '#f0f0f0',
opacity: 1,
}}
leave={{
height: 0,
borderColor: '#ffffff',
borderWidth: 0,
opacity: 0.3,
padding: 0,
margin: 0,
pointerEvents: 'none',
}}
>
{todos.map(todo => styles => (
<Todo
key={todo.id}
todo={todo}
style={styles}
toggleTodo={() => toggleTodo(todo.id)}
removeTodo={() => removeTodo(todo.id)}
editTodo={text => editTodo(todo.id, text)}
/>
))}
</Transition>
</ul>
);
}
TodosContainer.propTypes = {
todos: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.string.isRequired,
text: PropTypes.string.isRequired,
completed: PropTypes.bool.isRequired,
}),
).isRequired,
removeTodo: PropTypes.func.isRequired,
toggleTodo: PropTypes.func.isRequired,
editTodo: PropTypes.func.isRequired,
};

View File

@@ -0,0 +1,34 @@
import { connect } from 'react-redux';
import TodoList from './TodoList';
import { toggleTodo, removeTodo, editTodo } from '../../actions/todos';
import getVisibleTodos from './getVisibleTodos';
function mapStateToProps(state) {
return {
todos: state.lists.list
? getVisibleTodos(
state.lists.lists[state.lists.list].todos.map(
id => state.todos.todos[id],
),
state.visibilityFilter,
)
: [],
dirty: state.todos.dirty,
};
}
function mapDispatchToProps(dispatch) {
return {
toggleTodo: id => dispatch(toggleTodo(id)),
removeTodo: id => dispatch(removeTodo(id)),
editTodo: (id, text) => dispatch(editTodo(id, text)),
};
}
const TodosContainer = connect(
mapStateToProps,
mapDispatchToProps,
)(TodoList);
export default TodosContainer;

View File

@@ -0,0 +1,59 @@
import { connect } from 'react-redux';
import React from 'react';
import PropTypes from 'prop-types';
import { Transition } from 'react-spring';
import withRouter from 'react-router-dom/withRouter';
import Input from '../todos/Input';
import TodoListContainer from './TodoListContainer';
import Header from '../Header';
import Filters from '../filters/Filters';
class Todos extends React.PureComponent {
componentDidUpdate() {
const { user, history } = this.props;
if (!user.user && !user.dirty) {
history.replace('/login');
}
}
render() {
const { list } = this.props;
return (
<div id="todos">
<Header />
<Transition
from={{ opacity: 0, maxHeight: 0 }}
enter={{ opacity: 1, maxHeight: 38 }}
leave={{ opacity: 0, maxHeight: 0 }}
>
{list && (styles => <Input styles={styles} />)}
</Transition>
<TodoListContainer />
<Transition
from={{ opacity: 0, maxHeight: 0 }}
enter={{ opacity: 1, maxHeight: 32 }}
leave={{ opacity: 0, maxHeight: 0 }}
>
{list && Filters}
</Transition>
</div>
);
}
}
Todos.propTypes = {
list: PropTypes.bool.isRequired,
user: PropTypes.any.isRequired,
history: PropTypes.any.isRequired,
};
function mapStateToProps(state) {
return {
list: Boolean(state.lists.list),
user: state.user,
};
}
export default withRouter(connect(mapStateToProps)(Todos));

View File

@@ -0,0 +1,14 @@
import { VisibilityFilters } from '../../actions/defs';
export default function getVisibleTodos(todos, filter) {
switch (filter) {
case VisibilityFilters.SHOW_ALL:
return todos.filter(todo => todo);
case VisibilityFilters.SHOW_ACTIVE:
return todos.filter(todo => todo).filter(todo => !todo.completed);
case VisibilityFilters.SHOW_COMPLETED:
return todos.filter(todo => todo).filter(todo => todo.completed);
default:
return todos.filter(todo => todo);
}
}

View File

@@ -0,0 +1,59 @@
import { connect } from 'react-redux';
import * as React from 'react';
import PropTypes from 'prop-types';
import { Button } from '@material-ui/core';
import AddIcon from '@material-ui/icons/Add';
import { addTodo } from '../../actions/todos';
function Input({ onClick, styles }) {
let input;
function submit() {
if (input.value.trim() !== '') {
onClick(input.value);
}
input.value = '';
}
return (
<div style={styles} id="inputs">
<input
ref={node => {
input = node;
}}
id="input"
type="text"
placeholder="Add something!"
onKeyPress={e => {
if (e.key === 'Enter') {
submit();
}
}}
/>
<Button style={{ borderRadius: 0 }} id="add" onClick={() => submit()}>
<AddIcon />
</Button>
</div>
);
}
Input.propTypes = {
styles: PropTypes.any.isRequired,
onClick: PropTypes.func.isRequired,
};
function mapStateToProps(state, ownProps) {
return { ...ownProps };
}
function mapDispatchToProps(dispatch) {
return {
onClick: text => dispatch(addTodo(text)),
};
}
export default connect(
mapStateToProps,
mapDispatchToProps,
)(Input);

View File

@@ -0,0 +1,40 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import { ButtonBase } from '@material-ui/core';
import { fetchLists } from '../../actions/lists';
function FetchButton({ onClick, children }) {
return (
<ButtonBase
style={{
marginLeft: 0,
marginRight: 'auto',
padding: '0 1rem',
}}
onClick={e => {
e.preventDefault();
onClick();
}}
>
{children}
</ButtonBase>
);
}
FetchButton.propTypes = {
onClick: PropTypes.func.isRequired,
children: PropTypes.node.isRequired,
};
function mapDispatchToProps(dispatch) {
return {
onClick: () => dispatch(fetchLists()),
};
}
export default connect(
null,
mapDispatchToProps,
)(FetchButton);

View File

@@ -0,0 +1,33 @@
#form {
margin: 2rem;
display: flex;
justify-content: center;
}
form {
max-width: 80%;
display: flex;
flex-direction: column;
width: 20rem;
}
.error {
margin: 1rem;
color: red;
}
#googlebutton {
margin: auto;
margin-left: 0rem;
}
#submitbutton {
margin: auto;
margin-right: 0rem;
}
#buttons {
margin-top: 1rem;
display: flex;
justify-content: space-around;
}

View File

@@ -0,0 +1,35 @@
import React from 'react';
import PropTypes from 'prop-types';
import { TextField } from '@material-ui/core';
export default function InputField({
required,
input,
label,
meta: { touched, error },
type,
}) {
return (
<React.Fragment>
<TextField
label={label}
required={required}
{...input}
type={type}
style={{ marginBottom: '1rem' }}
/>
{touched && error && <span className="error">{error}</span>}
</React.Fragment>
);
}
InputField.propTypes = {
required: PropTypes.bool.isRequired,
input: PropTypes.any.isRequired,
label: PropTypes.string.isRequired,
meta: PropTypes.shape({
touched: PropTypes.bool,
error: PropTypes.string,
}).isRequired,
type: PropTypes.string.isRequired,
};

View File

@@ -0,0 +1,132 @@
import React from 'react';
import { Field, reduxForm } from 'redux-form';
import { connect } from 'react-redux';
import { withRouter } from 'react-router-dom';
import PropTypes from 'prop-types';
import { ButtonBase, Button } from '@material-ui/core';
import InputField from './InputField';
import UserErrors from './UserErrors';
import './Form.css';
import { login, reset, loginJWT } from '../../actions/user';
class LoginForm extends React.PureComponent {
componentDidMount() {
const { setJWT } = this.props;
const params = new URLSearchParams(new URL(window.location).search);
if (params.has('jwt')) {
const jwt = params.get('jwt');
setJWT(jwt);
}
}
componentDidUpdate() {
const { user, history } = this.props;
if (user.user) {
history.push('/');
}
}
render() {
const { resetUser, history, handleSubmit, user, onLogin } = this.props;
return (
<React.Fragment>
<div id="user-header">
<ButtonBase
style={{
marginRight: '1rem',
padding: '0 0.5rem',
borderRadius: '7px',
}}
onClick={() => {
resetUser();
history.push('/signup');
}}
>
signup
</ButtonBase>
</div>
<div id="form">
<form onSubmit={handleSubmit(onLogin)}>
<UserErrors user={user} />
<Field
label="username"
name="username"
required
component={InputField}
type="text"
/>
<Field
label="password"
name="password"
required
component={InputField}
type="password"
/>
<div id="buttons">
<Button
id="googlebutton"
variant="raised"
onClick={() => {
window.location = '/__/users/login/google/';
}}
>
Google
</Button>
<Button
id="submitbutton"
variant="raised"
color="primary"
type="submit"
>
Login
</Button>
</div>
</form>
</div>
</React.Fragment>
);
}
}
LoginForm.propTypes = {
handleSubmit: PropTypes.func.isRequired,
onLogin: PropTypes.func.isRequired,
user: PropTypes.object.isRequired,
history: PropTypes.any.isRequired,
resetUser: PropTypes.func.isRequired,
setJWT: PropTypes.func.isRequired,
};
function mapStateToProps(state) {
return {
user: state.user,
};
}
function mapDispatchToProps(dispatch) {
return {
resetUser: () => dispatch(reset()),
onLogin: ({ username, password }) =>
dispatch(login({ username, password })),
setJWT: jwt => dispatch(loginJWT(jwt)),
};
}
export default reduxForm({
form: 'loginForm',
initialValues: {
username: '',
password: '',
},
})(
withRouter(
connect(
mapStateToProps,
mapDispatchToProps,
)(LoginForm),
),
);

View File

@@ -0,0 +1,40 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import { ButtonBase } from '@material-ui/core';
import { logout } from '../../actions/user';
function Link({ onClick, children }) {
return (
<ButtonBase
style={{
marginLeft: 'auto',
marginRight: 0,
padding: '0 1rem',
}}
onClick={e => {
e.preventDefault();
onClick();
}}
>
{children}
</ButtonBase>
);
}
Link.propTypes = {
onClick: PropTypes.func.isRequired,
children: PropTypes.node.isRequired,
};
function mapDispatchToProps(dispatch) {
return {
onClick: () => dispatch(logout()),
};
}
export default connect(
null,
mapDispatchToProps,
)(Link);

View File

@@ -0,0 +1,121 @@
import React from 'react';
import { Field, reduxForm } from 'redux-form';
import { connect } from 'react-redux';
import { withRouter } from 'react-router-dom';
import PropTypes from 'prop-types';
import { ButtonBase, Button } from '@material-ui/core';
import InputField from './InputField';
import UserErrors from './UserErrors';
import './Form.css';
import { signup, reset } from '../../actions/user';
function validate(values) {
const errors = {};
if (values.password !== values.passwordRepeat) {
errors.passwordRepeat = 'Passwords should match';
}
return errors;
}
function SignupForm({ handleSubmit, onSignup, user, history, resetUser }) {
if (user.user) {
history.push('/');
}
return (
<React.Fragment>
<div id="user-header">
<ButtonBase
style={{
marginRight: '1rem',
padding: '0 0.5rem',
borderRadius: '7px',
}}
onClick={() => {
resetUser();
history.push('/login');
}}
>
login
</ButtonBase>
</div>
<div id="form">
<form onSubmit={handleSubmit(onSignup)}>
<UserErrors user={user} />
<Field
label="username"
name="username"
required
component={InputField}
type="text"
/>
<Field
label="password"
name="password"
required
component={InputField}
type="password"
/>
<Field
label="repeat pasword"
name="passwordRepeat"
required
component={InputField}
type="password"
/>
<div id="buttons">
<Button
id="submitbutton"
variant="raised"
color="primary"
type="submit"
>
Signup
</Button>
</div>
</form>
</div>
</React.Fragment>
);
}
SignupForm.propTypes = {
handleSubmit: PropTypes.func.isRequired,
onSignup: PropTypes.func.isRequired,
user: PropTypes.object.isRequired,
history: PropTypes.any.isRequired,
resetUser: PropTypes.func.isRequired,
};
function mapStateToProps(state) {
return {
user: state.user,
};
}
function mapDispatchToProps(dispatch) {
return {
resetUser: () => dispatch(reset()),
onSignup: ({ username, password }) =>
dispatch(signup({ username, password })),
};
}
export default reduxForm({
form: 'signupForm',
initialValues: {
username: '',
password: '',
passwordRepeat: '',
},
validate,
})(
withRouter(
connect(
mapStateToProps,
mapDispatchToProps,
)(SignupForm),
),
);

View File

@@ -0,0 +1,34 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import { ButtonBase } from '@material-ui/core';
function Status({ userFetching, listsFetching }) {
return (
<ButtonBase
style={{
marginRight: 'auto',
padding: '0 0.5rem',
borderRadius: '7px',
marginLeft: '1rem',
}}
>
{userFetching ? 'loading user' : null}
{listsFetching ? 'loading lists' : null}
</ButtonBase>
);
}
Status.propTypes = {
userFetching: PropTypes.bool.isRequired,
listsFetching: PropTypes.bool.isRequired,
};
function mapStateToProps(state) {
return {
userFetching: state.user.fetching,
listsFetching: state.lists.fetching,
};
}
export default connect(mapStateToProps)(Status);

View File

@@ -0,0 +1,32 @@
import React from 'react';
function UserErrors({ user }) {
const errors = [];
if (user.errors) {
if (user.errors.name === 'AuthenticationError') {
errors.push(
<div key="wrongauth" className="error">
Wrong username or password
</div>,
);
}
if (user.errors.name === 'ValidationError') {
if (user.errors.message.split(' ').includes('unique.')) {
errors.push(
<div key="exists" className="error">
User already exists
</div>,
);
} else {
errors.push(
<div key="invalid" className="error">
Validation error
</div>,
);
}
}
}
return errors || null;
}
export default UserErrors;

View File

@@ -0,0 +1,15 @@
import React from 'react';
import LogoutLink from './LogoutLink';
import FetchButton from './FetchButton';
import Status from './Status';
export default function UserHeader() {
return (
<div id="user-header">
<FetchButton>sync</FetchButton>
<Status />
<LogoutLink>logout</LogoutLink>
</div>
);
}

38
client/src/index.js Normal file
View File

@@ -0,0 +1,38 @@
import React from 'react';
import ReactDOM from 'react-dom';
import thunk from 'redux-thunk';
import { Provider } from 'react-redux';
import { applyMiddleware, createStore, compose } from 'redux';
import { offline } from '@redux-offline/redux-offline';
import offlineConfig from '@redux-offline/redux-offline/lib/defaults';
import AppContainer from './components/AppContainer';
import registerServiceWorker from './registerServiceWorker';
import todoApp from './reducers';
import { setToken } from './actions/util';
import keepSynced from './middleware/keepSynced';
let store;
const persistCallback = () => {
const state = store.getState();
if (state.user.user) {
setToken(state.user.user.jwt);
}
ReactDOM.render(
<Provider store={store}>
<AppContainer />
</Provider>,
document.getElementById('root'),
);
};
store = createStore(
todoApp,
compose(
offline({ ...offlineConfig, persistCallback }),
applyMiddleware(thunk, keepSynced),
),
);
registerServiceWorker();

View File

@@ -0,0 +1,20 @@
import { REQUEST_LISTS, INVALIDATE_LISTS } from '../actions/defs';
import { fetchLists } from '../actions/lists';
export default store => next => action => {
next(action);
if (action.type !== REQUEST_LISTS && typeof action !== 'function') {
const state = store.getState();
if (state.user.user) {
const dirtyLists = state.lists.dirty || false;
const dirtyTodos = state.todos.dirty || false;
const fetchingLists = state.lists.fetching || false;
if (
((dirtyLists || dirtyTodos) && !fetchingLists) ||
action.type === INVALIDATE_LISTS
) {
store.dispatch(fetchLists());
}
}
}
};

View File

@@ -0,0 +1,17 @@
import { combineReducers } from 'redux';
import { reducer as formReducer } from 'redux-form';
import lists from './lists';
import visibilityFilter from './visibilityFilter';
import user from './user';
import todos from './todos';
const todoApp = combineReducers({
lists,
todos,
visibilityFilter,
form: formReducer,
user,
});
export default todoApp;

View File

@@ -0,0 +1,160 @@
import {
CHANGE_LIST,
INVALIDATE_LISTS,
VALIDATE_LISTS,
REQUEST_LISTS,
RECIEVE_LISTS,
ADD_LIST,
REMOVE_LIST,
EDIT_LIST_NAME,
START_CREATE_LIST,
START_EDIT_LIST,
STOP_CREATE_LIST,
STOP_EDIT_LIST,
REMOVE_TODO,
ADD_TODO,
LOGOUT,
} from '../actions/defs';
export default function lists(
state = {
dirty: true,
fetching: false,
lists: null,
loaded: false,
creating: false,
list: null,
editing: false,
},
action,
) {
switch (action.type) {
case LOGOUT:
return {
dirty: true,
fetching: false,
lists: null,
loaded: false,
creating: false,
list: null,
editing: false,
};
case CHANGE_LIST:
return { ...state, list: action.list };
case RECIEVE_LISTS: {
const newLists = Object.values(action.lists);
let { list } = state;
if (newLists.length !== 0) {
if (!newLists.some(curList => curList.id === list)) {
list = newLists[0].id || null;
}
} else {
list = null;
}
return {
...state,
dirty: false,
loaded: true,
fetching: false,
lists: action.lists,
list,
};
}
case START_CREATE_LIST:
return {
...state,
creating: true,
};
case STOP_CREATE_LIST:
return {
...state,
creating: false,
};
case ADD_LIST:
return {
...state,
creating: false,
lists: { ...state.lists, [action.list.id]: action.list },
list: action.list.id,
};
case REMOVE_LIST: {
const newLists = { ...state.lists };
delete newLists[action.list];
const listsObjs = Object.values(newLists);
const list = listsObjs.length ? listsObjs[listsObjs.length - 1].id : null;
return {
...state,
list,
lists: newLists,
};
}
case START_EDIT_LIST: {
return {
...state,
editing: true,
};
}
case STOP_EDIT_LIST: {
return {
...state,
editing: false,
};
}
case EDIT_LIST_NAME: {
return {
...state,
editing: false,
lists: {
...state.lists,
[action.list]: {
...state.lists[action.list],
name: action.name,
},
},
};
}
case REMOVE_TODO: {
return {
...state,
lists: {
...state.lists,
[state.list]: {
...state.lists[state.list],
todos: state.lists[state.list].todos.filter(
todo => todo !== action.id,
),
},
},
};
}
case ADD_TODO: {
return {
...state,
lists: {
...state.lists,
[state.list]: {
...state.lists[state.list],
todos: [action.todo.id, ...state.lists[state.list].todos],
},
},
};
}
case INVALIDATE_LISTS:
return {
...state,
dirty: true,
};
case VALIDATE_LISTS:
return {
...state,
dirty: false,
};
case REQUEST_LISTS:
return {
...state,
fetching: true,
};
default:
return state;
}
}

View File

@@ -0,0 +1,99 @@
import {
ADD_TODO,
REMOVE_TODO,
TOGGLE_TODO,
RECIEVE_TODOS,
REQUEST_TODOS,
INVALIDATE_TODOS,
VALIDATE_TODOS,
EDIT_TODO,
REMOVE_LIST,
LOGOUT,
} from '../actions/defs';
export default function todos(
state = {
dirty: true,
fetching: false,
todos: null,
},
action,
) {
switch (action.type) {
case LOGOUT:
return {
dirty: true,
fetching: false,
todos: null,
};
case RECIEVE_TODOS:
return {
...state,
dirty: false,
fetching: false,
todos: action.todos,
};
case ADD_TODO:
return {
...state,
todos: { [action.todo.id]: action.todo, ...state.todos },
};
case INVALIDATE_TODOS:
return {
...state,
dirty: true,
};
case VALIDATE_TODOS:
return {
...state,
dirty: false,
};
case EDIT_TODO:
return {
...state,
todos: {
...state.todos,
[action.id]: { ...state.todos[action.id], text: action.text },
},
};
case REQUEST_TODOS:
return {
...state,
fetching: true,
};
case REMOVE_TODO: {
const newTodos = { ...state.todos };
delete newTodos[action.id];
return {
...state,
todos: newTodos,
};
}
case REMOVE_LIST: {
const newTodos = { ...state.todos };
Object.keys(newTodos).forEach(todoId => {
if (newTodos[todoId].list === action.list) {
delete newTodos[todoId];
}
});
return {
...state,
todos: newTodos,
};
}
case TOGGLE_TODO: {
return {
...state,
todos: {
...state.todos,
[action.id]: {
...state.todos[action.id],
completed: !state.todos[action.id].completed,
},
},
};
}
default:
return state;
}
}

View File

@@ -0,0 +1,70 @@
import {
LOGIN_SUCCESS,
LOGIN_FAIL,
START_LOGIN,
LOGOUT,
SIGNUP_FAIL,
SIGNUP_SUCCESS,
VALIDATE_USER,
RESET_USER,
} from '../actions/defs';
export default function user(
state = {
dirty: true,
fetching: false,
user: null,
loaded: false,
errors: null,
},
action,
) {
switch (action.type) {
case VALIDATE_USER:
return {
...state,
dirty: false,
};
case START_LOGIN:
return {
...state,
fetching: true,
};
case SIGNUP_SUCCESS:
case LOGIN_SUCCESS:
return {
...state,
user: action.user,
errors: null,
dirty: false,
loaded: true,
fetching: false,
};
case SIGNUP_FAIL:
case LOGIN_FAIL:
return {
...state,
user: null,
errors: action.error,
dirty: false,
fetching: false,
loaded: false,
};
case RESET_USER:
return {
...state,
fetching: false,
loaded: false,
user: null,
errors: null,
};
case LOGOUT:
return {
...state,
loaded: false,
user: null,
};
default:
return state;
}
}

View File

@@ -0,0 +1,12 @@
import { VisibilityFilters, SET_VISIBILITY_FILTER } from '../actions/defs';
const { SHOW_ALL } = VisibilityFilters;
export default function visibilityFilter(state = SHOW_ALL, action) {
switch (action.type) {
case SET_VISIBILITY_FILTER:
return action.filter;
default:
return state;
}
}

View File

@@ -0,0 +1,118 @@
// In production, we register a service worker to serve assets from local cache.
// This lets the app load faster on subsequent visits in production, and gives
// it offline capabilities. However, it also means that developers (and users)
// will only see deployed updates on the "N+1" visit to a page, since previously
// cached resources are updated in the background.
// To learn more about the benefits of this model, read https://goo.gl/KwvDNy.
// This link also includes instructions on opting out of this behavior.
const isLocalhost = Boolean(
window.location.hostname === 'localhost' ||
// [::1] is the IPv6 localhost address.
window.location.hostname === '[::1]' ||
// 127.0.0.1/8 is considered localhost for IPv4.
window.location.hostname.match(
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/,
),
);
function registerValidSW(swUrl) {
navigator.serviceWorker
.register(swUrl)
.then(registration => {
// eslint-disable-next-line no-param-reassign
registration.onupdatefound = () => {
const installingWorker = registration.installing;
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
// At this point, the old content will have been purged and
// the fresh content will have been added to the cache.
// It's the perfect time to display a "New content is
// available; please refresh." message in your web app.
console.log('New content is available; please refresh.');
} else {
// At this point, everything has been precached.
// It's the perfect time to display a
// "Content is cached for offline use." message.
console.log('Content is cached for offline use.');
}
}
};
};
})
.catch(error => {
console.error('Error during service worker registration:', error);
});
}
function checkValidServiceWorker(swUrl) {
// Check if the service worker can be found. If it can't reload the page.
fetch(swUrl)
.then(response => {
// Ensure service worker exists, and that we really are getting a JS file.
if (
response.status === 404 ||
response.headers.get('content-type').indexOf('javascript') === -1
) {
// No service worker found. Probably a different app. Reload the page.
navigator.serviceWorker.ready.then(registration => {
registration.unregister().then(() => {
window.location.reload();
});
});
} else {
// Service worker found. Proceed as normal.
registerValidSW(swUrl);
}
})
.catch(() => {
console.log(
'No internet connection found. App is running in offline mode.',
);
});
}
export default function register() {
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
// The URL constructor is available in all browsers that support SW.
const publicUrl = new URL(process.env.PUBLIC_URL, window.location);
if (publicUrl.origin !== window.location.origin) {
// Our service worker won't work if PUBLIC_URL is on a different origin
// from what our page is served on. This might happen if a CDN is used to
// serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374
return;
}
window.addEventListener('load', () => {
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
if (isLocalhost) {
// This is running on localhost. Lets check if a service worker still exists or not.
checkValidServiceWorker(swUrl);
// Add some additional logging to localhost, pointing developers to the
// service worker/PWA documentation.
navigator.serviceWorker.ready.then(() => {
console.log(
'This web app is being served cache-first by a service ' +
'worker. To learn more, visit https://goo.gl/SC7cgQ',
);
});
} else {
// Is not local host. Just register service worker
registerValidSW(swUrl);
}
});
}
}
export function unregister() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready.then(registration => {
registration.unregister();
});
}
}