зеркало из https://github.com/mozilla/toodle.git
WebExtension front-end MVP (#5) r=rnewman
This commit is contained in:
Родитель
c142118898
Коммит
d4211edc45
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"presets": ["react"],
|
||||
"plugins": ["transform-class-properties", "transform-object-rest-spread"]
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
extension/dist
|
|
@ -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"]
|
||||
}
|
||||
};
|
|
@ -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'"
|
||||
}
|
Разница между файлами не показана из-за своего большого размера
Загрузить разницу
|
@ -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);
|
|
@ -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'
|
||||
};
|
Загрузка…
Ссылка в новой задаче