diff --git a/.gitignore b/.gitignore
index b512c09..2053430 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,40 @@
-node_modules
\ No newline at end of file
+# Logs
+logs
+*.log
+npm-debug.log*
+
+# Runtime data
+pids
+*.pid
+*.seed
+
+# Directory for instrumented libs generated by jscoverage/JSCover
+lib-cov
+
+# Coverage directory used by tools like istanbul
+coverage
+
+# nyc test coverage
+.nyc_output
+
+# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
+.grunt
+
+# node-waf configuration
+.lock-wscript
+
+# Compiled binary addons (http://nodejs.org/api/addons.html)
+build/Release
+
+# Dependency directories
+node_modules
+jspm_packages
+
+# Optional npm cache directory
+.npm
+
+# Optional REPL history
+.node_repl_history
+
+# VS Code
+.vscode
\ No newline at end of file
diff --git a/README.md b/README.md
deleted file mode 100644
index 9b93df6..0000000
--- a/README.md
+++ /dev/null
@@ -1,11 +0,0 @@
-# Основы Redux
-
-Исходный код для курса "Основы Redux"
-
----
-
-## Использование Redux
-
-```bash
-git checkout using-redux
-```
\ No newline at end of file
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..6b5a878
--- /dev/null
+++ b/package.json
@@ -0,0 +1,18 @@
+{
+ "name": "redux-basics",
+ "version": "1.0.0",
+ "private": true,
+ "scripts": {
+ "start": "react-scripts start",
+ "build": "react-scripts build",
+ "test": "react-scripts test --env=jsdom",
+ "eject": "react-scripts eject"
+ },
+ "dependencies": {
+ "prop-types": "~15.6.0",
+ "react": "~16.0.0",
+ "react-dom": "~16.0.0",
+ "react-scripts": "~1.0.14",
+ "redux": "~3.7.2"
+ }
+}
\ No newline at end of file
diff --git a/public/app.css b/public/app.css
new file mode 100644
index 0000000..4efdd4b
--- /dev/null
+++ b/public/app.css
@@ -0,0 +1,293 @@
+body {
+ background-color: #fafafa;
+ color: #757575;
+ font-family: "Roboto", Arial, Helvetica, sans-serif;
+ margin: 0;
+}
+
+button {
+ background: 0 0;
+ border: none;
+ border-radius: 2px;
+ color: #757575;
+ position: relative;
+ height: 36px;
+ margin: 0;
+ min-width: 64px;
+ padding: 0 16px;
+ display: inline-block;
+ font-family: "Roboto","Helvetica","Arial",sans-serif;
+ font-size: 14px;
+ font-weight: 500;
+ text-transform: uppercase;
+ letter-spacing: 0;
+ overflow: hidden;
+ will-change: box-shadow;
+ transition: box-shadow .2s cubic-bezier(.4,0,1,1),background-color .2s cubic-bezier(.4,0,.2,1),color .2s cubic-bezier(.4,0,.2,1);
+ outline: none;
+ cursor: pointer;
+ text-decoration: none;
+ text-align: center;
+ line-height: 36px;
+ vertical-align: middle;
+}
+
+button:hover {
+ background-color: rgba(158,158,158,.2);
+}
+
+button:active {
+ background-color: rgba(158,158,158,.4);
+}
+
+button:disabled {
+ color: rgba(0, 0, 0, 0.26);
+ pointer-events: none;
+}
+
+button.icon {
+ border-radius: 50%;
+ font-size: 24px;
+ height: 32px;
+ margin-left: 0;
+ margin-right: 0;
+ min-width: 32px;
+ width: 32px;
+ padding: 0;
+ overflow: hidden;
+ line-height: normal;
+}
+
+button .material-icons {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-12px,-12px);
+ line-height: 24px;
+ width: 24px;
+ vertical-align: middle;
+}
+
+input {
+ font-family: "Roboto", Arial, Helvetica, sans-serif;
+ font-size: 1rem;
+ color: #757575;
+ padding: .5em;
+ border-radius: 2px;
+ border: 1px solid lightgray;
+ outline: none;
+}
+
+main {
+ background: #fff;
+ width: 700px;
+ margin: 50px auto;
+ border-radius: 2px;
+ overflow: hidden;
+ box-shadow: 0 2px 2px 0 rgba(0,0,0,.14),0 3px 1px -2px rgba(0,0,0,.2),0 1px 5px 0 rgba(0,0,0,.12);
+}
+
+
+
+/* Header */
+header {
+ display: flex;
+ align-items: center;
+ padding: 0 1rem;
+ color: white;
+ background-color: #222;
+ text-align: center;
+ text-transform: uppercase;
+}
+
+header h1 {
+ display: inline-block;
+ color: #764abc;
+ margin: 1rem auto;
+}
+
+
+
+/* Stats */
+.stats {
+ margin: 10px;
+}
+
+.stats table {
+ margin: auto;
+}
+
+.stats th {
+ text-align: right;
+ font-weight: normal;
+ letter-spacing: 2px;
+ font-size: .7em;
+}
+
+.stats td {
+ text-align: left;
+ font-size: 1.5rem;
+ font-family: monospace;
+}
+
+
+
+/* Stopwatch */
+.stopwatch {
+ padding: 15px 10px 5px 10px;
+}
+
+.stopwatch-time {
+ font-family: monospace;
+ font-size: 2em;
+}
+
+.stopwatch-controls button {
+ margin: 5px;
+ color: white;
+}
+
+
+
+/* Filter */
+.todo-filter {
+ display: flex;
+}
+
+.todo-filter a {
+ flex: 1;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ height: 40px;
+ position: relative;
+ cursor: pointer;
+ transition: all .2s;
+}
+
+.todo-filter a:hover {
+ background-color: rgba(158,158,158,.1);
+}
+
+.todo-filter a.is-active i {
+ color: #764abc;
+}
+
+.todo-filter a.is-active::after {
+ height: 2px;
+ width: 100%;
+ display: block;
+ content: " ";
+ bottom: -1px;
+ left: 0;
+ position: absolute;
+ background: #764abc;
+}
+
+
+/* Todo */
+.todo {
+ display: flex;
+ font-size: 1rem;
+ border-top: 1px solid rgba(0,0,0,.1);
+ background-color: #fff;
+ -webkit-user-select: none;
+ user-select: none;
+ padding: 1em;
+ align-items: center;
+}
+
+.todo.completed * {
+ color: lightgray;
+ transition: color .2s;
+}
+
+.todo.completed .todo-title {
+ text-decoration: line-through;
+}
+
+.todo .todo-title {
+ margin-right: auto;
+}
+
+.todo .checkbox {
+ margin-right: .5rem;
+}
+
+.todo button:not(.checkbox) {
+ opacity: 0;
+ transition: all .2s;
+}
+
+.todo:hover button:not(.checkbox) {
+ opacity: 1;
+}
+
+.todo-edit-form {
+ display: flex;
+ font-size: 1rem;
+ padding: .85em;
+ border-top: 1px solid rgba(0,0,0,.1);
+}
+
+.todo-edit-form input {
+ flex: 1;
+}
+
+.todo-edit-form .save {
+ margin-left: .5em;
+}
+
+
+
+/* Todo form */
+.todo-add-form {
+ display: flex;
+ background-color: #FAFAFA;
+ border-top: 1px solid rgba(0,0,0,.1);
+ padding: 10px;
+}
+
+.todo-add-form input {
+ flex: 1;
+ outline: none;
+ transition: all .2s;
+}
+
+.todo-add-form input:focus {
+ border: 1px solid #764abc;
+}
+
+.todo-add-form button {
+ margin-left: 10px;
+}
+
+
+
+/* Animations */
+.slide-enter {
+ transform: translateX(-100%);
+}
+
+.slide-enter.slide-enter-active {
+ transform: translateX(0);
+ transition: all .5s;
+}
+
+.slide-leave {
+ transform: translateX(0);
+}
+
+.slide-leave.slide-leave-active {
+ transform: translateX(100%);
+ transition: all .5s;
+}
+
+.slide-appear {
+ opacity: 0;
+}
+
+.slide-appear.slide-appear-active {
+ opacity: 1;
+ transition: all .5s;
+}
\ No newline at end of file
diff --git a/public/favicon.ico b/public/favicon.ico
new file mode 100644
index 0000000..f10af8f
Binary files /dev/null and b/public/favicon.ico differ
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..32692a9
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+ Redux Todo
+
+
+
+ Загрузка...
+
+
\ No newline at end of file
diff --git a/src/App.jsx b/src/App.jsx
new file mode 100644
index 0000000..193842e
--- /dev/null
+++ b/src/App.jsx
@@ -0,0 +1,106 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import Header from './components/Header';
+import List from './components/List';
+import Form from './components/Form';
+
+class App extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ todos: this.props.initialData
+ };
+
+ this._nextId = this.state.todos.length;
+
+ this.handleAdd = this.handleAdd.bind(this);
+ this.handleDelete = this.handleDelete.bind(this);
+ this.handleToggle = this.handleToggle.bind(this);
+ this.handleEdit = this.handleEdit.bind(this);
+ }
+
+ nextId() {
+ return this._nextId += 1;
+ }
+
+ handleAdd(title) {
+ const todo = {
+ id: this.nextId(),
+ title,
+ completed: false
+ };
+
+ const todos = [...this.state.todos, todo];
+
+ this.setState({ todos });
+ }
+
+ handleDelete(id) {
+ const index = this.state.todos.findIndex(todo => todo.id === id);
+ const todos = [
+ ...this.state.todos.slice(0, index),
+ ...this.state.todos.slice(index + 1)
+ ];
+
+ this.setState({ todos });
+ }
+
+ handleToggle(id) {
+ const todos = this.state.todos.map(todo => {
+ if (todo.id !== id) {
+ return todo;
+ }
+
+ return Object.assign({}, todo, {
+ completed: !todo.completed
+ });
+ });
+
+ this.setState({ todos });
+ }
+
+ handleEdit(id, title) {
+ const todos = this.state.todos.map(todo => {
+ if (todo.id !== id) {
+ return todo;
+ }
+
+ return Object.assign({}, todo, {
+ title: title
+ });
+ });
+
+ this.setState({ todos });
+ }
+
+ render() {
+ const todos = this.state.todos;
+
+ return (
+
+
+
+
+
+
+
+ );
+ }
+}
+
+App.propTypes = {
+ initialData: PropTypes.arrayOf(PropTypes.shape({
+ id: PropTypes.number.isRequired,
+ title: PropTypes.string.isRequired,
+ completed: PropTypes.bool.isRequired
+ })).isRequired
+};
+
+export default App;
diff --git a/src/components/Button.jsx b/src/components/Button.jsx
new file mode 100644
index 0000000..46cb5df
--- /dev/null
+++ b/src/components/Button.jsx
@@ -0,0 +1,24 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+function Button(props) {
+ return (
+
+ );
+}
+
+Button.propTypes = {
+ children: PropTypes.node,
+ className: PropTypes.string,
+ icon: PropTypes.string,
+ disabled: PropTypes.bool,
+ onClick: PropTypes.func
+};
+
+export default Button;
\ No newline at end of file
diff --git a/src/components/Checkbox.jsx b/src/components/Checkbox.jsx
new file mode 100644
index 0000000..bffb96f
--- /dev/null
+++ b/src/components/Checkbox.jsx
@@ -0,0 +1,19 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+function Checkbox(props) {
+ return (
+
+ );
+}
+
+Checkbox.propTypes = {
+ checked: PropTypes.bool.isRequired,
+ onChange: PropTypes.func.isRequired
+};
+
+export default Checkbox;
\ No newline at end of file
diff --git a/src/components/Form.jsx b/src/components/Form.jsx
new file mode 100644
index 0000000..77a6d29
--- /dev/null
+++ b/src/components/Form.jsx
@@ -0,0 +1,56 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import Button from './Button';
+
+class Form extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ title: ''
+ };
+
+ this.handleSubmit = this.handleSubmit.bind(this);
+ this.handleChange = this.handleChange.bind(this);
+ }
+
+ handleSubmit(event) {
+ event.preventDefault();
+
+ const title = this.state.title;
+
+ if (title) {
+ this.props.onAdd(title);
+ this.setState({ title: '' });
+ }
+ }
+
+ handleChange(event) {
+ const title = event.target.value;
+
+ this.setState({ title });
+ }
+
+ render() {
+ const disabled = !this.state.title;
+
+ return (
+
+ );
+ }
+}
+
+Form.propTypes = {
+ onAdd: PropTypes.func.isRequired
+};
+
+export default Form;
\ No newline at end of file
diff --git a/src/components/Header.jsx b/src/components/Header.jsx
new file mode 100644
index 0000000..c1dcd3e
--- /dev/null
+++ b/src/components/Header.jsx
@@ -0,0 +1,21 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import Stats from './Stats';
+import Stopwatch from './Stopwatch';
+
+function Header(props) {
+ return (
+
+ );
+}
+
+Header.propTypes = {
+ todos: PropTypes.array.isRequired
+};
+
+export default Header;
\ No newline at end of file
diff --git a/src/components/List.jsx b/src/components/List.jsx
new file mode 100644
index 0000000..7ac19ed
--- /dev/null
+++ b/src/components/List.jsx
@@ -0,0 +1,35 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import Todo from './Todo';
+
+function List(props) {
+ return (
+
+ {props.todos.map(todo =>
+ )
+ }
+
+ );
+}
+
+List.propTypes = {
+ todos: PropTypes.arrayOf(PropTypes.shape({
+ id: PropTypes.number.isRequired,
+ title: PropTypes.string.isRequired,
+ completed: PropTypes.bool.isRequired
+ })).isRequired,
+ onDelete: PropTypes.func.isRequired,
+ onToggle: PropTypes.func.isRequired,
+ onEdit: PropTypes.func.isRequired
+};
+
+export default List;
\ No newline at end of file
diff --git a/src/components/Stats.jsx b/src/components/Stats.jsx
new file mode 100644
index 0000000..1da67d7
--- /dev/null
+++ b/src/components/Stats.jsx
@@ -0,0 +1,37 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+function Stats(props) {
+ const total = props.todos.length;
+ const completed = props.todos.filter(todo => todo.completed).length;
+ const uncompleted = total - completed;
+
+ return (
+
+
+
+ | Всего задач: |
+ {total} |
+
+
+ | Выполнено: |
+ {completed} |
+
+
+ | Осталось: |
+ {uncompleted} |
+
+
+
+ );
+}
+
+Stats.propTypes = {
+ todos: PropTypes.arrayOf(PropTypes.shape({
+ id: PropTypes.number.isRequired,
+ title: PropTypes.string.isRequired,
+ completed: PropTypes.bool.isRequired
+ })).isRequired
+};
+
+export default Stats;
\ No newline at end of file
diff --git a/src/components/Stopwatch.jsx b/src/components/Stopwatch.jsx
new file mode 100644
index 0000000..8f1c133
--- /dev/null
+++ b/src/components/Stopwatch.jsx
@@ -0,0 +1,89 @@
+import React from 'react';
+
+import Button from './Button';
+
+class Stopwatch extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ running: false,
+ elapsed: 0,
+ lastTick: 0
+ };
+
+ this.handleStart = this.handleStart.bind(this);
+ this.handlePause = this.handlePause.bind(this);
+ this.handleStop = this.handleStop.bind(this);
+ this.tick = this.tick.bind(this);
+ }
+
+ componentDidMount() {
+ this.interval = setInterval(this.tick, 1000);
+ }
+
+ componentWillUnmount() {
+ clearInterval(this.interval);
+ }
+
+ tick() {
+ if (this.state.running) {
+ const now = Date.now();
+ const diff = now - this.state.lastTick;
+
+ this.setState({
+ elapsed: this.state.elapsed + diff,
+ lastTick: now
+ });
+ }
+ }
+
+ handleStart() {
+ this.setState({
+ running: true,
+ lastTick: Date.now()
+ });
+ }
+
+ handlePause() {
+ this.setState({ running: false });
+ }
+
+ handleStop() {
+ this.setState({
+ running: false,
+ elapsed: 0,
+ lastTick: 0
+ });
+ }
+
+ format(milliseconds) {
+ const totalSeconds = Math.floor(milliseconds / 1000);
+ const minutes = Math.floor(totalSeconds / 60);
+ const seconds = totalSeconds % 60;
+
+ return `${minutes > 9 ? minutes : '0' + minutes}:${seconds > 9 ? seconds : '0' + seconds}`;
+ }
+
+ render() {
+ const time = this.format(this.state.elapsed);
+
+ return (
+
+ {time}
+
+
+ {this.state.running ?
+
+ :
+
+ }
+
+
+
+
+ );
+ }
+}
+
+export default Stopwatch;
\ No newline at end of file
diff --git a/src/components/Todo.jsx b/src/components/Todo.jsx
new file mode 100644
index 0000000..2ccbb4c
--- /dev/null
+++ b/src/components/Todo.jsx
@@ -0,0 +1,86 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import Checkbox from './Checkbox';
+import Button from './Button';
+
+class Todo extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ editing: false
+ };
+
+ this.handleSubmit = this.handleSubmit.bind(this);
+ this.handleDelete = this.handleDelete.bind(this);
+ this.handleToggle = this.handleToggle.bind(this);
+ this.handleEdit = this.handleEdit.bind(this);
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ if (this.state.editing) {
+ this.refs.title.focus();
+ }
+ }
+
+ handleSubmit(event) {
+ event.preventDefault();
+
+ const title = this.refs.title.value;
+
+ this.props.onEdit(this.props.id, title);
+ this.setState({ editing: false });
+ }
+
+ handleDelete() {
+ this.props.onDelete(this.props.id);
+ }
+
+ handleToggle() {
+ this.props.onToggle(this.props.id);
+ }
+
+ handleEdit() {
+ this.setState({ editing: true });
+ }
+
+ renderDisplay() {
+ const className = `todo${this.props.completed ? ' completed' : ''}`;
+
+ return (
+
+
+
+ {this.props.title}
+
+
+
+
+ );
+ }
+
+ renderForm() {
+ return (
+
+ );
+ }
+
+ render() {
+ return this.state.editing ? this.renderForm() : this.renderDisplay();
+ }
+}
+
+Todo.propTypes = {
+ id: PropTypes.number.isRequired,
+ title: PropTypes.string.isRequired,
+ completed: PropTypes.bool.isRequired,
+ onDelete: PropTypes.func.isRequired,
+ onToggle: PropTypes.func.isRequired,
+ onEdit: PropTypes.func.isRequired
+};
+
+export default Todo;
\ No newline at end of file
diff --git a/src/index.js b/src/index.js
new file mode 100644
index 0000000..34ba894
--- /dev/null
+++ b/src/index.js
@@ -0,0 +1,7 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+
+import todos from './todos';
+import App from './App';
+
+ReactDOM.render(, document.getElementById('root'));
\ No newline at end of file
diff --git a/src/todos.json b/src/todos.json
new file mode 100644
index 0000000..bebaf9a
--- /dev/null
+++ b/src/todos.json
@@ -0,0 +1,22 @@
+[
+ {
+ "id": 1,
+ "title": "Изучить JavaScript",
+ "completed": true
+ },
+ {
+ "id": 2,
+ "title": "Изучить React",
+ "completed": true
+ },
+ {
+ "id": 3,
+ "title": "Изучить Redux",
+ "completed": false
+ },
+ {
+ "id": 4,
+ "title": "Написать приложение",
+ "completed": false
+ }
+]
\ No newline at end of file