WebExtension front-end MVP (#5) r=rnewman

This commit is contained in:
Edouard Oger 2017-12-13 13:44:50 -05:00 коммит произвёл Richard Newman
Родитель c142118898
Коммит d4211edc45
34 изменённых файлов: 10558 добавлений и 0 удалений

4
webextension/.babelrc Normal file
Просмотреть файл

@ -0,0 +1,4 @@
{
"presets": ["react"],
"plugins": ["transform-class-properties", "transform-object-rest-spread"]
}

Просмотреть файл

@ -0,0 +1 @@
extension/dist

67
webextension/.eslintrc.js Normal file
Просмотреть файл

@ -0,0 +1,67 @@
module.exports = {
"extends": [
"eslint:recommended",
"plugin:react/recommended"
],
"plugins": [
"react"
],
"parser": "babel-eslint",
"env": {
"browser": true,
"es6": true,
"webextensions": true
},
"parserOptions": {
"ecmaVersion": 8,
"sourceType": "module",
"ecmaFeatures": {
"jsx": true
}
},
"rules": {
"brace-style": [
"error",
"1tbs"
],
"curly": [
"error"
],
"indent": [
"error",
2
],
"key-spacing": ["error"],
"keyword-spacing": [
"error",
{
"before": true,
"after": true
}
],
"no-console": [
0
],
"no-multi-spaces": [
"error"
],
"no-trailing-spaces": [
"error"
],
"no-var": [
"error"
],
"prefer-template": [
"error"
],
"quotes": [
"error",
"single"
],
"semi": [
"error",
"always"
],
"space-before-blocks": ["error"]
}
};

4
webextension/.gitignore поставляемый Normal file
Просмотреть файл

@ -0,0 +1,4 @@
.DS_Store
extension/dist
web-ext-artifacts
node_modules

Просмотреть файл

@ -0,0 +1,5 @@
browser.browserAction.onClicked.addListener(() => {
chrome.tabs.create({
url: chrome.runtime.getURL('/index.html')
});
});

Просмотреть файл

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>Toodle</title>
<link href="/dist/app.css" rel="stylesheet">
</head>
<body>
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<div id="root"></div>
<script type="text/javascript" src="/dist/app.js"></script></body>
</html>

Просмотреть файл

@ -0,0 +1,15 @@
{
"manifest_version": 2,
"name": "Toodle",
"version": "1.0",
"browser_action": {
"default_title": "Toodle"
},
"background": {
"scripts": ["background.js"]
},
"content_security_policy": "default-src 'self' 'unsafe-eval'"
}

9270
webextension/package-lock.json сгенерированный Normal file

Разница между файлами не показана из-за своего большого размера Загрузить разницу

35
webextension/package.json Normal file
Просмотреть файл

@ -0,0 +1,35 @@
{
"name": "toodle",
"version": "0.1.0",
"description": "",
"license": "MPL-2.0",
"private": true,
"scripts": {
"lint": "eslint .",
"build": "NODE_ENV=production webpack",
"watch": "webpack --watch",
"webext": "web-ext run -s extension/",
"dev": "npm-run-all --parallel watch webext"
},
"devDependencies": {
"babel-eslint": "^8.0.3",
"babel-loader": "^7.1.2",
"babel-plugin-transform-class-properties": "^6.24.1",
"babel-plugin-transform-object-rest-spread": "^6.26.0",
"babel-preset-react": "^6.24.1",
"css-loader": "^0.28.7",
"eslint-plugin-react": "^7.5.1",
"extract-text-webpack-plugin": "^3.0.2",
"npm-run-all": "^4.1.2",
"react": "^16.2.0",
"react-color": "^2.13.8",
"react-dom": "^16.2.0",
"react-onclickoutside": "^6.7.0",
"react-redux": "^5.0.6",
"redux": "^3.7.2",
"redux-promise-middleware": "^5.0.0",
"style-loader": "^0.19.0",
"web-ext": "^2.2.2",
"webpack": "^3.10.0"
}
}

Просмотреть файл

@ -0,0 +1,87 @@
function fakeUuid() {
return Math.random().toString(36).substring(5);
}
function makeFakeTodo(name, dueDate, completionDate, labels = []) {
return {uuid: fakeUuid(), name, dueDate, completionDate, labels};
}
const LABEL_P0 = { name: 'P0', color: 'rgb(184, 0, 0)' };
const LABEL_P1 = { name: 'P1', color: 'rgb(0, 77, 207)' };
const LABEL_SP = { name: 'Storage Prototype', color: 'rgb(83, 0, 235)' };
const LABEL_BL = { name: 'Backlog', color: 'rgb(0, 139, 2)' };
const memoryStore = {
todos: [
makeFakeTodo('Make Toodle WebExtension.', null, Date.now(), [LABEL_P1, LABEL_SP]),
makeFakeTodo('Drink some hot chocolate.', Date.now(), null, [LABEL_P0]),
makeFakeTodo('Double-click on a task name to edit.', Date.now(), null, [LABEL_BL])
],
labels: [LABEL_P0, LABEL_P1, LABEL_SP, LABEL_BL]
};
function getTodoByUUID(uuid) {
return memoryStore.todos.find(t => t.uuid === uuid);
}
function getLabelByName(name) {
return memoryStore.labels.find(l => l.name === name);
}
const FakeApi = {
async createTodo(name) {
const newTodo = makeFakeTodo(name);
memoryStore.todos.push(newTodo);
// We want a deep-copy!
return Object.assign({}, newTodo);
},
async removeTodo(uuid) {
memoryStore.todos = memoryStore.todos.filter(t => t.uuid !== uuid);
return uuid;
},
async getTodos() {
return memoryStore.todos.map(t => Object.assign({}, t));
},
async todoChangeName(uuid, newTodoName) {
const todo = getTodoByUUID(uuid);
todo.name = newTodoName;
return todo;
},
async todoChangeDueDate(uuid, dueDate) {
const todo = getTodoByUUID(uuid);
todo.dueDate = dueDate;
return todo;
},
async todoChangeCompletionDate(uuid, completionDate) {
const todo = getTodoByUUID(uuid);
todo.completionDate = completionDate;
return todo;
},
async todoAddLabel(uuid, labelName) {
const todo = getTodoByUUID(uuid);
todo.labels.push(getLabelByName(labelName));
return Object.assign({}, todo);
},
async todoRemoveLabel(uuid, labelName) {
const todo = getTodoByUUID(uuid);
todo.labels = todo.labels.filter(l => l.name !== labelName);
return Object.assign({}, todo);
},
async getLabels() {
return memoryStore.labels.map(l => Object.assign({}, l));
},
async addLabel(name, color) {
const newLabel = {name, color};
memoryStore.labels.push(newLabel);
return Object.assign({}, newLabel);
},
async removeLabel(labelName) {
memoryStore.labels = memoryStore.labels.filter(l => l.name !== labelName);
for (let todo of memoryStore.todos) {
todo.labels = todo.labels.filter(l => l.name !== labelName);
}
return labelName;
}
};
export default FakeApi;

Просмотреть файл

@ -0,0 +1,56 @@
import FakeApi from './fake_api';
export const populateTodos = () => ({
type: 'POPULATE_TODOS',
payload: FakeApi.getTodos()
});
export const populateLabels = () => ({
type: 'POPULATE_LABELS',
payload: FakeApi.getLabels()
});
export const addTodo = (text) => ({
type: 'ADD_TODO',
payload: FakeApi.createTodo(text)
});
export const removeTodo = (uuid) => ({
type: 'REMOVE_TODO',
payload: FakeApi.removeTodo(uuid)
});
export const todoChangeName = (uuid, newTodoName) => ({
type: 'TODO_CHANGE_NAME',
payload: FakeApi.todoChangeName(uuid, newTodoName)
});
export const todoChangeDueDate = (uuid, dueDate) => ({
type: 'TODO_CHANGE_DUE_DATE',
payload: FakeApi.todoChangeDueDate(uuid, dueDate)
});
export const todoChangeCompletionDate = (uuid, completionDate) => ({
type: 'TODO_CHANGE_COMPLETION_DATE',
payload: FakeApi.todoChangeCompletionDate(uuid, completionDate)
});
export const todoAddLabel = (uuid, labelName) => ({
type: 'TODO_ADD_LABEL',
payload: FakeApi.todoAddLabel(uuid, labelName)
});
export const todoRemoveLabel = (uuid, labelName) => ({
type: 'TODO_REMOVE_LABEL',
payload: FakeApi.todoRemoveLabel(uuid, labelName)
});
export const addLabel = (labelName, color) => ({
type: 'ADD_LABEL',
payload: FakeApi.addLabel(labelName, color)
});
export const removeLabel = (labelName) => ({
type: 'REMOVE_LABEL',
payload: FakeApi.removeLabel(labelName)
});

Просмотреть файл

@ -0,0 +1,36 @@
.add-label-form {
display: flex;
position: relative;
margin-right: 21px;
}
.add-label-input {
flex: 1;
height: 20px;
padding: 5px;
}
.color-popover {
position: absolute;
z-index: 2;
top: 26px;
left: -7px;
}
.color-swatch-wrapper {
padding: 2px;
border: 1px solid grey;
padding: 5px;
background: #fff;
border-radius: var(--border-radius);
box-shadow: 0 0 0 1px rgba(0,0,0,.1);
display: inline-block;
cursor: pointer;
}
.color-swatch {
margin: auto;
width: 20px;
height: 20px;
border-radius: var(--border-radius);
}

Просмотреть файл

@ -0,0 +1,89 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { GithubPicker } from 'react-color';
import onClickOutside from 'react-onclickoutside';
import './AddLabel.css';
const ColorPicker = onClickOutside(class ColorPicker extends Component {
static propTypes = {
requestClose: PropTypes.func.isRequired,
colorChange: PropTypes.func.isRequired,
}
handleColorChange = (color) => {
this.props.colorChange(color);
this.props.requestClose();
}
handleClickOutside = () => {
this.props.requestClose();
};
render() {
return <GithubPicker
onChange={ this.handleColorChange }
/>;
}
});
const DEFAULT_STATE = {displayColorPicker: false, newLabelName: '', newLabelColor: 'rgb(184, 0, 0)'};
class AddLabel extends Component {
static propTypes = {
addLabel: PropTypes.func.isRequired
}
state = DEFAULT_STATE
handleNameChange = (event) => {
this.setState({newLabelName: event.target.value});
}
handleColorClick = () => {
this.setState({ displayColorPicker: !this.state.displayColorPicker });
}
handleColorChange = (color) => {
this.setState({ newLabelColor: color.hex });
}
handleColorClose = () => {
this.setState({ displayColorPicker: false });
}
onSubmit = (e) => {
e.preventDefault();
const newLabelName = this.state.newLabelName;
if (!newLabelName.trim()) {
return;
}
this.props.addLabel(newLabelName, this.state.newLabelColor);
this.setState(DEFAULT_STATE);
}
render() {
return (
<form className="add-label-form" onSubmit={this.onSubmit}>
<div className="color-swatch-wrapper"
onClick={ this.handleColorClick }>
<div style={{ backgroundColor: this.state.newLabelColor}}
className="color-swatch" />
</div>
{ this.state.displayColorPicker ?
<div className="color-popover">
<ColorPicker requestClose={this.handleColorClose} colorChange={this.handleColorChange} />
</div>
: null
}
<input className="add-label-input"
placeholder="Add a New Label"
value={this.state.newLabelName}
onChange={this.handleNameChange} />
</form>
);
}
}
export default AddLabel;

Просмотреть файл

@ -0,0 +1,9 @@
.add-todo-form {
display: flex;
}
.add-todo-input {
height: 25px;
padding: 5px 10px;
flex: 1;
}

Просмотреть файл

@ -0,0 +1,44 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { addTodo } from '../actions';
import './AddTodo.css';
class AddTodo extends Component {
static propTypes = {
dispatch: PropTypes.func.isRequired,
}
state = {
newTodoName: ''
}
onSubmit = (e) => {
const { dispatch } = this.props;
e.preventDefault();
const newTodoName = this.state.newTodoName;
if (!newTodoName.trim()) {
return;
}
dispatch(addTodo(newTodoName));
this.setState({newTodoName: ''});
}
handleChange = (event) => {
this.setState({newTodoName: event.target.value});
}
render() {
return (
<form className="add-todo-form" onSubmit={this.onSubmit}>
<input className="add-todo-input"
placeholder="Add a New Todo"
value={this.state.newTodoName}
onChange={this.handleChange} />
</form>
);
}
}
export default connect()(AddTodo);

Просмотреть файл

@ -0,0 +1,63 @@
:root {
--border-radius: 3px;
}
body {
color: #333;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
}
h1 {
user-select: none;
margin: 10px 0;
}
input {
border: 1px solid grey;
border-radius: var(--border-radius);
}
.app {
display: flex;
}
.labels {
user-select: none;
display: flex;
flex-direction: column;
}
.label {
color: #fff;
display: flex;
align-items: center;
padding: 5px 10px;
margin-bottom: 10px;
height: 30px;
border-radius: var(--border-radius);
font-size: 16px;
}
.label-name {
flex: 1;
}
button {
border: 1px solid grey;
border-radius: var(--border-radius);
cursor: pointer;
}
button:hover {
background-color: #e0dede;
}
button:hover:active {
background-color: #cccccc;
}
.edit-labels-button {
margin: 15px;
width: 100px;
height: 35px;
}

Просмотреть файл

@ -0,0 +1,30 @@
import React, { Component } from 'react';
import TodoList from './TodoList';
import LabelsEditor from './LabelsEditor';
import './App.css';
class App extends Component {
state = {
displayLabelsEditor: false
}
toggleLabelEditor = () => {
this.setState({displayLabelsEditor: !this.state.displayLabelsEditor});
}
render() {
return (
<div>
<div className="app">
<LabelsEditor isOpened={this.state.displayLabelsEditor} />
<TodoList />
</div>
<button className="edit-labels-button" onClick={this.toggleLabelEditor}>Edit Labels</button>
</div>
);
}
}
export default App;

Просмотреть файл

@ -0,0 +1,25 @@
.todo-dates-chooser {
min-width: 130px;
font-size: 16px;
display: flex;
flex-direction: column;
}
.todo-dates-chooser span {
display: block;
margin-bottom: 5px;
}
.todo-dates-chooser input[type="date"] {
font-size: inherit;
padding-left: 5px;
height: 25px;
margin-bottom: 10px;
}
.todo-dates-chooser button {
margin-top: 4px;
width: 60px;
height: 30px;
align-self: flex-end;
}

Просмотреть файл

@ -0,0 +1,69 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import './DatesChooser.css';
// Convert a date object to a yyyy-mm-dd string.
function convertToYYYYMMDD(date) {
if (!date) {
return '';
}
return new Date(date).toISOString().substr(0, 10);
}
// Inverse operation
function convertToDate(dateStr) {
if (!dateStr) {
return null;
}
return new Date(dateStr).getTime();
}
class DatesChooser extends Component {
static propTypes = {
dueDate: PropTypes.number,
completionDate: PropTypes.number,
onTodoChangeDueDate: PropTypes.func.isRequired,
onTodoChangeCompletionDate: PropTypes.func.isRequired,
}
constructor(props) {
super(props);
this.state = { newDueDate: convertToYYYYMMDD(this.props.dueDate),
newCompletionDate: convertToYYYYMMDD(this.props.completionDate) };
}
handleDueDateChange = (e) => {
const newDueDate = e.target.value;
this.setState({ newDueDate });
this.props.onTodoChangeDueDate(convertToDate(newDueDate));
}
handleCompletionDateChange = (e) => {
const newCompletionDate = e.target.value;
if (newCompletionDate !== '' && new Date(newCompletionDate) > new Date()) {
return;
}
this.setState({ newCompletionDate });
this.props.onTodoChangeCompletionDate(convertToDate(newCompletionDate));
}
onSubmit = (e) => {
e.preventDefault();
}
render() {
return (
<div className="todo-dates-chooser">
<span>Due Date</span>
<input type="date" value={this.state.newDueDate}
onChange={this.handleDueDateChange} />
<span>Completion Date</span>
<input type="date" value={this.state.newCompletionDate}
onChange={this.handleCompletionDateChange} />
</div>
);
}
}
export default DatesChooser;

Просмотреть файл

@ -0,0 +1,11 @@
.todo-dropdown-menu .labels {
min-width: 150px;
}
.label:not(.checked) .label-checkmark {
visibility: hidden;
}
.todo-dropdown-menu .label {
cursor: pointer;
}

Просмотреть файл

@ -0,0 +1,33 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import './LabelsChooser.css';
class LabelsChooser extends Component {
static propTypes = {
labels: PropTypes.array.isRequired,
onLabelChecked: PropTypes.func.isRequired,
onLabelUnchecked: PropTypes.func.isRequired
}
render() {
const { labels, onLabelChecked, onLabelUnchecked } = this.props;
const onClick = (label) => label.checked ? onLabelUnchecked(label.name):
onLabelChecked(label.name);
const labelDivs = labels.map(label =>
<div className={`label${ label.checked ? ' checked' : ''}`}
key={label.name}
onClick={() => onClick(label)}
style={{ backgroundColor: label.color}}>
<span className="label-name">{label.name}</span>
<div className="label-checkmark"></div>
</div>
);
return (
<div className="labels">
{labelDivs}
</div>
);
}
}
export default LabelsChooser;

Просмотреть файл

@ -0,0 +1,29 @@
.labels-editor {
border-radius: var(--border-radius);
background-color: #eff7f1;
margin-inline-end: 10px;
padding: 5px 10px;
transition: margin 250ms cubic-bezier(.07,.95,0,1);
}
.labels-editor.hidden {
margin-left: -238px;
}
.editor-label-wrapper {
display: flex;
align-items: baseline;
}
.labels-editor .label {
height: 20px;
}
.label-delete {
margin-left: 5px;
cursor: pointer;
}
.editor-label-wrapper .label {
flex: 1;
}

Просмотреть файл

@ -0,0 +1,56 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { addLabel, removeLabel } from '../actions';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import AddLabel from './AddLabel.js';
import './LabelsEditor.css';
class LabelsEditor extends Component {
static propTypes = {
labels: PropTypes.array.isRequired,
addLabel: PropTypes.func.isRequired,
removeLabel: PropTypes.func.isRequired,
isOpened: PropTypes.bool.isRequired
}
render() {
const {addLabel, removeLabel, labels} = this.props;
return (
<div className={`labels-editor${ !this.props.isOpened ? ' hidden' : ''}`}>
<h1>Labels Editor</h1>
<div className="editor-labels">
{labels.map(l => {
const onRemoveLabelClick = () => removeLabel(l.name);
return (
<div key={l.name} className="editor-label-wrapper">
<div className="label"
style={{backgroundColor: l.color}}
>
{l.name}
</div>
<a className="label-delete" onClick={onRemoveLabelClick}>
<span role="img" aria-label="Delete"></span>
</a>
</div>
);
})}
</div>
<AddLabel addLabel={addLabel} />
</div>
);
}
}
const mapStateToProps = (state) => ({
labels: state.labels
});
const mapDispatchToProps = (dispatch) => ({
...bindActionCreators({ addLabel, removeLabel }, dispatch)
});
export default connect(
mapStateToProps,
mapDispatchToProps
)(LabelsEditor);

Просмотреть файл

@ -0,0 +1,110 @@
.todo-labels > * {
display: inline-block;
min-width: 80px;
margin-inline-end: 2px;
padding: 2px 4px;
border-radius: var(--border-radius);
font-size: 12px;
text-align: center;
color: white;
user-select: none;
}
.todo-labels:not(:empty) {
margin-bottom: 5px;
}
.todo-wrapper {
padding: 4px;
}
.todo {
display: flex;
align-items: center;
border-radius: var(--border-radius);
padding: 10px 5px;
background-color: #fff;
border-bottom: 1px solid #ccc;
}
.todo:hover {
background-color: #edeff0;
}
.todo-content {
flex: 1;
display: flex;
flex-direction: column;
}
.todo-details-wrapper {
flex: 1;
display: flex;
}
.todo-details {
flex: 1;
display: flex;
flex-direction: column;
}
.todo-completed {
margin-right: 15px;
width: 25px;
height: 25px;
}
.todo-name {
font-size: 14px;
}
.todo-buttons-wrapper {
align-self: baseline;
display: flex;
visibility: collapse;
}
.todo.dropdown-open .todo-buttons-wrapper,
.todo:hover .todo-buttons-wrapper {
visibility: visible;
}
.todo-dropdown-wrapper {
position: relative;
}
.todo-label-button,
.todo-dates-button,
.todo-delete-button {
user-select: none;
margin-inline-start: 4px;
border: 1px rgb(226, 228, 230) solid;
border-radius: var(--border-radius);
width: 25px;
height: 20px;
font-size: 12px;
text-align: center;
cursor: pointer;
line-height: 20px;
}
.todo-label-button:hover,
.todo-dates-button:hover,
.todo-delete-button:hover {
background-color: #fff;
}
.todo-edit-name {
padding-left: 4px;
font-size: inherit;
}
.todo-footer {
color: #5d5d5d;
margin-top: 10px;
font-size: 11px;
}
.todo-footer:empty {
display: none;
}

Просмотреть файл

@ -0,0 +1,147 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import TodoDropdownMenu from './TodoDropdownMenu';
import LabelsChooser from './LabelsChooser';
import DatesChooser from './DatesChooser';
import './Todo.css';
function toPrettyDate(date) {
return new Date(date).toISOString().substr(0, 10);
}
class Todo extends Component {
static propTypes = {
onRemoveTodo: PropTypes.func.isRequired,
onTodoLabelAdded: PropTypes.func.isRequired,
onTodoLabelRemoved: PropTypes.func.isRequired,
onTodoNameChanged: PropTypes.func.isRequired,
onTodoCompleted: PropTypes.func.isRequired,
onTodoChangeDueDate: PropTypes.func.isRequired,
onTodoChangeCompletionDate: PropTypes.func.isRequired,
allLabels: PropTypes.array.isRequired,
labels: PropTypes.array.isRequired,
name: PropTypes.string.isRequired,
dueDate: PropTypes.number,
completionDate: PropTypes.number,
uuid: PropTypes.string.isRequired
}
state = {
datesDropdownOpen: false,
labelsDropdownOpen: false,
isEditingName: false,
newName: ''
}
onSubmit = (e) => {
e.preventDefault();
this.toggleEdit();
}
toggleEdit = () => {
this.setState({isEditingName: !this.state.isEditingName});
if (!this.state.isEditingName) {
this.setState({newName: this.props.name});
} else {
this.props.onTodoNameChanged(this.props.uuid, this.state.newName);
}
}
handleCompleted = (e) => {
this.props.onTodoCompleted(this.props.uuid, e.target.checked);
}
handleKeyDown = (e) => {
// Cancel edit on escape key
if (e.keyCode === 27) {
this.setState({isEditingName: !this.state.isEditingName});
}
}
toggleLabelsDropdown = () => {
this.setState({labelsDropdownOpen: !this.state.labelsDropdownOpen});
}
toggleDatesDropdown = () => {
this.setState({datesDropdownOpen: !this.state.datesDropdownOpen});
}
handleNewNameChange = (event) => {
this.setState({newName: event.target.value});
}
render() {
const { uuid, name, labels, dueDate, completionDate, allLabels,
onRemoveTodo, onTodoLabelAdded, onTodoLabelRemoved,
onTodoChangeDueDate, onTodoChangeCompletionDate } = this.props;
const { labelsDropdownOpen, datesDropdownOpen, isEditingName } = this.state;
const labelsChecked = new Set(labels.map(l => l.name));
const availableLabels = allLabels.map(l => {
return Object.assign({}, l, { checked: labelsChecked.has(l.name)});
});
const onLabelChecked = (labelName) => onTodoLabelAdded(uuid, labelName);
const onLabelUnchecked = (labelName) => onTodoLabelRemoved(uuid, labelName);
const todoChangeDueDate = (date) => onTodoChangeDueDate(uuid, date);
const todoChangeCompletionDate = (date) => onTodoChangeCompletionDate(uuid, date);
return (
<div className="todo-wrapper">
<div className={`todo${ labelsDropdownOpen || datesDropdownOpen ? ' dropdown-open' : ''}`}>
<input className="todo-completed" checked={!!completionDate} onChange={this.handleCompleted} type="checkbox"
title={completionDate? `Completed on ${ toPrettyDate(completionDate)}` : ''} />
<div className="todo-content">
<div className="todo-details-wrapper">
<div className="todo-details">
<div className="todo-labels">{labels.map(l => <span key={l.name} style={{backgroundColor: l.color}}>{l.name}</span>)}</div>
<div className="todo-name">
{
!isEditingName ?
<div onDoubleClick={this.toggleEdit}>{name}</div> :
<form onSubmit={this.onSubmit}>
<input className="todo-edit-name" onKeyDown={this.handleKeyDown} value={this.state.newName} onChange={this.handleNewNameChange} />
</form>
}
</div>
</div>
<div className="todo-buttons-wrapper">
<div className="todo-dropdown-wrapper">
<div className="todo-label-button" onClick={this.toggleLabelsDropdown}>
<span role="img" aria-label="Label">🏷</span>
</div>
{labelsDropdownOpen ?
<TodoDropdownMenu onCloseDropdown={this.toggleLabelsDropdown}>
<LabelsChooser labels={availableLabels} onLabelChecked={onLabelChecked} onLabelUnchecked={onLabelUnchecked} />
</TodoDropdownMenu> : null
}
</div>
<div className="todo-dropdown-wrapper">
<div className="todo-dates-button" onClick={this.toggleDatesDropdown}>
<span role="img" aria-label="Dates">📅</span>
</div>
{datesDropdownOpen ?
<TodoDropdownMenu onCloseDropdown={this.toggleDatesDropdown}>
<DatesChooser dueDate={dueDate} completionDate={completionDate}
onTodoChangeDueDate={todoChangeDueDate}
onTodoChangeCompletionDate={todoChangeCompletionDate} />
</TodoDropdownMenu> : null
}
</div>
<div className="todo-delete-button" onClick={onRemoveTodo}>
<span role="img" aria-label="Delete"></span>
</div>
</div>
</div>
<div className="todo-footer">
{dueDate && !completionDate ? `Due on ${ toPrettyDate(dueDate)}` : ''}
</div>
</div>
</div>
</div>
);
}
}
export default Todo;

Просмотреть файл

@ -0,0 +1,10 @@
.todo-dropdown-menu {
position: absolute;
z-index: 3;
top: 21px;
right: 0;
border: 1px rgb(226, 228, 230) solid;
background-color: #fff;
padding: 10px;
border-radius: var(--border-radius);
}

Просмотреть файл

@ -0,0 +1,25 @@
import React, { Component } from 'react';
import onClickOutside from 'react-onclickoutside';
import PropTypes from 'prop-types';
import './TodoDropdownMenu.css';
class TodoDropdownMenu extends Component {
static propTypes = {
children: PropTypes.object.isRequired,
onCloseDropdown: PropTypes.func.isRequired
}
handleClickOutside = () => {
this.props.onCloseDropdown();
};
render() {
return (
<div className="todo-dropdown-menu">
{this.props.children}
</div>
);
}
}
export default onClickOutside(TodoDropdownMenu);

Просмотреть файл

@ -0,0 +1,6 @@
.todo-list {
width: 500px;
background-color: rgb(226, 228, 230);
border-radius: var(--border-radius);
padding: 5px 10px;
}

Просмотреть файл

@ -0,0 +1,81 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import * as Actions from '../actions';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import Todo from './Todo';
import AddTodo from './AddTodo';
import './TodoList.css';
class TodoList extends Component {
static propTypes = {
todos: PropTypes.array.isRequired,
allLabels: PropTypes.array.isRequired,
populateLabels: PropTypes.func.isRequired,
populateTodos: PropTypes.func.isRequired,
todoChangeName: PropTypes.func.isRequired,
todoAddLabel: PropTypes.func.isRequired,
todoRemoveLabel: PropTypes.func.isRequired,
todoChangeCompletionDate: PropTypes.func.isRequired,
todoChangeDueDate: PropTypes.func.isRequired,
removeTodo: PropTypes.func.isRequired
}
componentDidMount() {
const { populateLabels, populateTodos } = this.props;
populateLabels();
populateTodos();
}
onTodoCompleted = (uuid, completed) => {
this.props.todoChangeCompletionDate(uuid, completed ? Date.now() : null);
}
render() {
const { todos, allLabels, todoAddLabel, todoRemoveLabel, todoChangeName,
removeTodo, todoChangeDueDate, todoChangeCompletionDate } = this.props;
return (
<div className="todo-list">
<h1>Todo List</h1>
{todos.map(todo =>
<Todo
key={todo.uuid}
{...todo}
allLabels={allLabels}
onRemoveTodo={() => removeTodo(todo.uuid)}
onTodoLabelAdded={todoAddLabel}
onTodoLabelRemoved={todoRemoveLabel}
onTodoNameChanged={todoChangeName}
onTodoChangeCompletionDate={todoChangeCompletionDate}
onTodoChangeDueDate={todoChangeDueDate}
onTodoCompleted={this.onTodoCompleted}
/>
)}
<div className="todo-wrapper"><AddTodo /></div>
</div>
);
}
}
const mapStateToProps = (state) => ({
todos: state.todos,
allLabels: state.labels
});
const mapDispatchToProps = (dispatch) => {
const { populateLabels, populateTodos, todoChangeName, todoAddLabel,
todoRemoveLabel, todoChangeCompletionDate, todoChangeDueDate,
removeTodo } = Actions;
return {
...bindActionCreators({ populateLabels, populateTodos,
todoChangeCompletionDate, todoChangeDueDate,
todoAddLabel, todoRemoveLabel, todoChangeName,
removeTodo
}, dispatch)
};
};
export default connect(
mapStateToProps,
mapDispatchToProps
)(TodoList);

17
webextension/src/index.js Normal file
Просмотреть файл

@ -0,0 +1,17 @@
import React from 'react';
import { render } from 'react-dom';
import { createStore, applyMiddleware, compose } from 'redux';
import { Provider } from 'react-redux';
import App from './components/App';
import reducer from './reducers';
import promiseMiddleware from 'redux-promise-middleware';
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const store = createStore(reducer, composeEnhancers(applyMiddleware(promiseMiddleware())));
render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root')
);

Просмотреть файл

@ -0,0 +1,10 @@
import { combineReducers } from 'redux';
import todos from './todos';
import labels from './labels';
const todoApp = combineReducers({
labels,
todos
});
export default todoApp;

Просмотреть файл

@ -0,0 +1,14 @@
const labels = (state = [], action) => {
switch (action.type) {
case 'POPULATE_LABELS_FULFILLED':
return action.payload;
case 'ADD_LABEL_FULFILLED':
return [...state, action.payload];
case 'REMOVE_LABEL_FULFILLED':
return state.filter(l => l.name !== action.payload);
default:
return state;
}
};
export default labels;

Просмотреть файл

@ -0,0 +1,36 @@
const todos = (state = [], action) => {
switch (action.type) {
case 'POPULATE_TODOS_FULFILLED':
return action.payload;
case 'ADD_TODO_FULFILLED':
return [
...state,
action.payload
];
case 'REMOVE_TODO_FULFILLED':
return state.filter(t => t.uuid !== action.payload);
case 'TODO_ADD_LABEL_FULFILLED':
case 'TODO_REMOVE_LABEL_FULFILLED':
case 'TODO_CHANGE_NAME_FULFILLED':
case 'TODO_CHANGE_DUE_DATE_FULFILLED':
case 'TODO_CHANGE_COMPLETION_DATE_FULFILLED':
// Update the TODO we have with the one we just received
return state.map(todo => {
if (todo.uuid !== action.payload.uuid) {
return todo;
}
return action.payload;
});
case 'REMOVE_LABEL_FULFILLED':
return state.map(t => {
return {
...t,
labels: t.labels.filter(l => l.name !== action.payload)
};
});
default:
return state;
}
};
export default todos;

Просмотреть файл

@ -0,0 +1,49 @@
/* global __dirname:true, process:true */
/* eslint-env commonjs */
const path = require('path');
const webpack = require('webpack');
const NODE_ENV = process.env.NODE_ENV;
const ExtractTextPlugin = require('extract-text-webpack-plugin');
module.exports = {
entry: {
app: './src/index.js',
},
output: {
path: path.resolve(__dirname, 'extension/dist'),
filename: '[name].js',
},
module: {
rules: [
{
test: /\.js$/,
exclude: /(node_modules)/,
use: {
loader: 'babel-loader'
}
},
{
test: /\.css$/,
use: ExtractTextPlugin.extract({
fallback: 'style-loader',
use: [ 'css-loader' ]
})
}
]
},
resolve: {
extensions: ['.js', '.jsx'],
modules: [
path.resolve(__dirname, 'src'),
'node_modules',
],
},
plugins: [
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(NODE_ENV),
}),
new ExtractTextPlugin('app.css'),
],
devtool: NODE_ENV === 'production' ? 'cheap-module-source-map' : 'source-map'
};