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 ( +
+ +

Redux Todo

+ +
+ ); +} + +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 ( +
+ +