├── .babelrc ├── .eslintrc ├── .gitignore ├── .prettierignore ├── LICENSE ├── README.md ├── example.gif ├── package-lock.json ├── package.json ├── src ├── app │ ├── components │ │ ├── App.jsx │ │ ├── App.scss │ │ ├── Board │ │ │ ├── Board.jsx │ │ │ ├── Board.scss │ │ │ └── BoardContainer.jsx │ │ ├── BoardHeader │ │ │ ├── BoardDeleter.jsx │ │ │ ├── BoardDeleter.scss │ │ │ ├── BoardHeader.jsx │ │ │ ├── BoardHeader.scss │ │ │ ├── BoardTitle.jsx │ │ │ ├── BoardTitle.scss │ │ │ ├── ColorPicker.jsx │ │ │ └── ColorPicker.scss │ │ ├── Card │ │ │ ├── Card.jsx │ │ │ ├── Card.scss │ │ │ └── formatMarkdown.js │ │ ├── CardAdder │ │ │ ├── CardAdder.jsx │ │ │ └── CardAdder.scss │ │ ├── CardBadges │ │ │ ├── CardBadges.jsx │ │ │ └── CardBadges.scss │ │ ├── CardModal │ │ │ ├── Calendar.jsx │ │ │ ├── CardModal.jsx │ │ │ ├── CardModal.scss │ │ │ ├── CardOptions.jsx │ │ │ ├── CardOptions.scss │ │ │ └── ReactDayPicker.css │ │ ├── ClickOutside │ │ │ └── ClickOutside.jsx │ │ ├── Header │ │ │ ├── Header.jsx │ │ │ └── Header.scss │ │ ├── Home │ │ │ ├── BoardAdder.jsx │ │ │ ├── Home.jsx │ │ │ └── Home.scss │ │ ├── LandingPage │ │ │ ├── LandingPage.jsx │ │ │ └── LandingPage.scss │ │ ├── List │ │ │ ├── Cards.jsx │ │ │ ├── List.jsx │ │ │ ├── List.scss │ │ │ ├── ListHeader.jsx │ │ │ └── ListHeader.scss │ │ ├── ListAdder │ │ │ ├── ListAdder.jsx │ │ │ └── ListAdder.scss │ │ └── utils.js │ ├── middleware │ │ └── persistMiddleware.js │ ├── reducers │ │ ├── boardsById.js │ │ ├── cardsById.js │ │ ├── currentBoardId.js │ │ ├── index.js │ │ ├── isGuest.js │ │ ├── listsById.js │ │ └── user.js │ └── variables.scss ├── assets │ ├── favicons │ │ ├── apple-touch-icon-144x144.png │ │ ├── apple-touch-icon-152x152.png │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── favicon.ico │ │ ├── mstile-144x144.png │ │ └── og-kanban-logo.png │ └── images │ │ ├── color-icon.png │ │ ├── google-logo.svg │ │ ├── kanban-logo.svg │ │ ├── postits-1366.jpg │ │ └── postits-1920.jpg ├── client.jsx └── server │ ├── createWelcomeBoard.js │ ├── fetchBoardData.js │ ├── passport.js │ ├── renderPage.jsx │ ├── routes │ ├── api.js │ └── auth.js │ └── server.js ├── webpack.config.client.js ├── webpack.config.js └── webpack.config.server.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/env", 5 | { 6 | "modules": false, 7 | "loose": true 8 | } 9 | ], 10 | ["@babel/preset-stage-2", { "decoratorsLegacy": true }], 11 | "@babel/react" 12 | ], 13 | "env": { 14 | "production": { 15 | "plugins": [ 16 | ["@babel/plugin-transform-runtime", { "useBuiltIns": true }], 17 | [ 18 | "transform-react-remove-prop-types", 19 | { 20 | "removeImport": true 21 | } 22 | ] 23 | ] 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true, 5 | "node": true, 6 | "jest": true 7 | }, 8 | "extends": ["airbnb", "prettier", "prettier/react"], 9 | "parser": "babel-eslint", 10 | "parserOptions": { 11 | "ecmaFeatures": { 12 | "jsx": true 13 | }, 14 | "sourceType": "module" 15 | }, 16 | "rules": { 17 | "react/prefer-stateless-function": 0, 18 | "react/no-array-index-key": 0, 19 | "no-underscore-dangle": ["error", { "allow": ["_id", "_json"] }], 20 | "jsx-a11y/no-autofocus": 0, 21 | "jsx-a11y/anchor-is-valid": [ 22 | 2, 23 | { 24 | "components": ["Link"], 25 | "specialLink": ["to", "hrefLeft", "hrefRight"], 26 | "aspects": ["noHref", "invalidHref", "preferButton"] 27 | } 28 | ], 29 | "react/require-default-props": 0, 30 | "react/forbid-prop-types": 0, 31 | "import/prefer-default-export": 0 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | stats.json 4 | .env 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | package-lock.json 3 | 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Markus Englund 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # React Kanban 4 | 5 | A server-rendered React app inspired by [Trello](https://trello.com/home). 6 | 7 | ![react kanban example](https://github.com/yogaboll/react-kanban/blob/master/example.gif?raw=true) 8 | 9 | [Check out the live website](https://www.reactkanban.com) 10 | 11 | ### Features 12 | 13 | * It has most of the features available on Trello, like creating and editing new cards, dragging around cards and so on. 14 | * Supports GitHub flavored markdown, which enables stuff like headings and checklists on the cards. 15 | * Works great on touch devices. 16 | 17 | ### Tech stack 18 | 19 | * [React](https://github.com/facebook/react) 20 | * [Redux](https://github.com/reactjs/redux) 21 | * [React-beautiful-dnd](https://github.com/atlassian/react-beautiful-dnd) 22 | * [Sass](https://github.com/sass/sass) 23 | * [Webpack](https://github.com/webpack/webpack) 24 | * [Babel](https://github.com/babel/babel) 25 | * [Express](https://github.com/expressjs/express) 26 | * [MongoDB](https://github.com/mongodb/mongo) 27 | * [Passport](https://github.com/jaredhanson/passport) 28 | 29 | 30 | ### Development 31 | 32 | Setting up the full app with your own mongoDB instance and auth credentials for Twitter and Google sign-in requires significant effort. Use the simplified set up if you don't want to bother with that. 33 | 34 | #### Simplified setup 35 | 36 | ```shell 37 | # Clone the simple-dev branch which does not include db and social sign-in stuff 38 | git clone https://github.com/yogaboll/react-kanban.git -b simple-dev 39 | 40 | cd react-kanban 41 | 42 | npm install 43 | 44 | npm run build 45 | 46 | # Open a second terminal window and run: 47 | npm run serve 48 | ``` 49 | 50 | The app will run on http://127.0.0.1:1337 51 | 52 | #### Full setup 53 | 54 | ```shell 55 | git clone https://github.com/yogaboll/react-kanban.git 56 | 57 | cd react-kanban 58 | 59 | npm install 60 | ``` 61 | 62 | You need to add your own mongoDB url as well as auth credentials for the Google and Twitter sign in. You need to create a file with the name `.env` in the root directory with the following variables: 63 | 64 | ``` 65 | MONGODB_URL 66 | MONGODB_NAME 67 | TWITTER_API_KEY 68 | TWITTER_API_SECRET 69 | GOOGLE_CLIENT_ID 70 | GOOGLE_CLIENT_SECRET 71 | SESSION_SECRET 72 | 73 | # Has to be port 1337 74 | ROOT_URL=http://127.0.0.1:1337 75 | ``` 76 | 77 | ```shell 78 | npm run build 79 | npm run serve 80 | ``` 81 | 82 | For production deployment run: 83 | 84 | ```shell 85 | npm run build:prod 86 | npm run serve:prod 87 | ``` 88 | -------------------------------------------------------------------------------- /example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/markusenglund/react-kanban/4edf4f52a7d27551c0cd16f389a568c898b311ea/example.gif -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-kanban", 3 | "version": "0.1.0", 4 | "description": "An open source kanban application created with React and Redux.", 5 | "main": "dist/server.js", 6 | "scripts": { 7 | "build": "webpack --watch --mode development", 8 | "build:prod": "BABEL_ENV=production webpack --mode production", 9 | "serve": "nodemon dist/server.js", 10 | "serve:prod": "NODE_ENV=production node dist/server.js", 11 | "analyze-bundle": "BABEL_ENV=production webpack --mode production --profile --json > stats.json && webpack-bundle-analyzer stats.json dist/public -s gzip", 12 | "lint": "eslint --ext .js,.jsx src/", 13 | "format": "prettier --write '**/*.{js,jsx,css,scss,json,md}'" 14 | }, 15 | "author": "Markus Englund ", 16 | "license": "MIT", 17 | "dependencies": { 18 | "@babel/runtime": "^7.0.0-beta.53", 19 | "classnames": "^2.2.5", 20 | "compression": "^1.7.2", 21 | "connect-mongo": "^2.0.1", 22 | "date-fns": "^1.29.0", 23 | "dotenv": "^5.0.1", 24 | "express": "^4.16.3", 25 | "express-session": "^1.15.6", 26 | "express-static-gzip": "^0.3.2", 27 | "helmet": "^3.12.1", 28 | "marked": "^0.3.19", 29 | "mini-css-extract-plugin": "^0.4.0", 30 | "mongodb": "^3.1.0-beta4", 31 | "morgan": "^1.9.0", 32 | "normalizr": "^3.2.4", 33 | "passport": "^0.4.0", 34 | "passport-google-oauth20": "^1.0.0", 35 | "passport-twitter": "^1.0.4", 36 | "prop-types": "^15.6.1", 37 | "react": "^16.2.0", 38 | "react-aria-menubutton": "^5.1.1", 39 | "react-beautiful-dnd": "github:yogaboll/react-beautiful-dnd#include-generated", 40 | "react-day-picker": "^7.1.9", 41 | "react-dom": "^16.2.0", 42 | "react-head": "^2.1.0", 43 | "react-icons": "^2.2.7", 44 | "react-modal": "^3.4.4", 45 | "react-onclickoutside": "^6.7.1", 46 | "react-redux": "^5.0.7", 47 | "react-router": "^4.2.0", 48 | "react-router-dom": "^4.2.2", 49 | "react-textarea-autosize": "^6.1.0", 50 | "redux": "^3.7.2", 51 | "redux-devtools-extension": "^2.13.2", 52 | "serve-favicon": "^2.5.0", 53 | "shortid": "^2.2.8", 54 | "slugify": "^1.3.0" 55 | }, 56 | "devDependencies": { 57 | "@babel/core": "^7.0.0-beta.47", 58 | "@babel/plugin-transform-runtime": "^7.0.0-beta.53", 59 | "@babel/preset-env": "^7.0.0-beta.47", 60 | "@babel/preset-react": "^7.0.0-beta.47", 61 | "@babel/preset-stage-2": "^7.0.0-beta.47", 62 | "autoprefixer": "^8.5.0", 63 | "babel-eslint": "^8.2.3", 64 | "babel-loader": "^8.0.0-beta.0", 65 | "babel-plugin-transform-react-remove-prop-types": "^0.4.13", 66 | "clean-webpack-plugin": "^0.1.19", 67 | "compression-webpack-plugin": "^2.0.0", 68 | "copy-webpack-plugin": "^4.5.1", 69 | "css-loader": "^0.28.11", 70 | "eslint": "^4.19.1", 71 | "eslint-config-airbnb": "^16.1.0", 72 | "eslint-config-prettier": "^2.9.0", 73 | "eslint-plugin-import": "^2.12.0", 74 | "eslint-plugin-jsx-a11y": "^6.0.3", 75 | "eslint-plugin-react": "^7.8.2", 76 | "file-loader": "^1.1.11", 77 | "ignore-loader": "^0.1.2", 78 | "node-sass": "^4.11.0", 79 | "nodemon": "^1.17.4", 80 | "postcss-loader": "^2.1.6", 81 | "prettier": "^1.12.1", 82 | "sass-loader": "^7.0.3", 83 | "svg-url-loader": "^2.3.2", 84 | "uglifyjs-webpack-plugin": "^1.2.5", 85 | "url-loader": "^1.0.1", 86 | "webpack": "^4.8.3", 87 | "webpack-bundle-analyzer": "^2.12.0", 88 | "webpack-cli": "^2.1.3", 89 | "webpack-manifest-plugin": "^2.0.2", 90 | "webpack-node-externals": "^1.7.2" 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/app/components/App.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import { Route, Redirect, Switch, withRouter } from "react-router-dom"; 4 | import { connect } from "react-redux"; 5 | import Home from "./Home/Home"; 6 | import BoardContainer from "./Board/BoardContainer"; 7 | import LandingPage from "./LandingPage/LandingPage"; 8 | import "./App.scss"; 9 | 10 | const App = ({ user, isGuest }) => { 11 | // Serve different pages depending on if user is logged in or not 12 | if (user || isGuest) { 13 | return ( 14 | 15 | 16 | 17 | 18 | 19 | ); 20 | } 21 | 22 | // If not logged in, always redirect to landing page 23 | return ( 24 | 25 | 26 | 27 | 28 | ); 29 | }; 30 | 31 | App.propTypes = { user: PropTypes.object, isGuest: PropTypes.bool.isRequired }; 32 | 33 | const mapStateToProps = state => ({ user: state.user, isGuest: state.isGuest }); 34 | 35 | // Use withRouter to prevent strange glitch where other components 36 | // lower down in the component tree wouldn't update from URL changes 37 | export default withRouter(connect(mapStateToProps)(App)); 38 | -------------------------------------------------------------------------------- /src/app/components/App.scss: -------------------------------------------------------------------------------- 1 | // Base styles 2 | 3 | html { 4 | height: 100%; 5 | width: 100%; 6 | &::-webkit-scrollbar { 7 | width: 10px; 8 | height: 10px; 9 | background: #ddd; 10 | } 11 | 12 | &::-webkit-scrollbar-thumb { 13 | border-radius: 3px; 14 | background: #aaa; 15 | } 16 | } 17 | 18 | body { 19 | height: 100%; 20 | width: 100%; 21 | margin: 0; 22 | font-family: "Helvetica Neue", "Segoe UI", "Trebuchet MS", Geneva, Tahoma, 23 | sans-serif; 24 | line-height: 1; 25 | } 26 | 27 | #app { 28 | display: inline-flex; 29 | height: 100%; 30 | min-width: 100%; 31 | } 32 | 33 | button, 34 | span, 35 | a { 36 | vertical-align: baseline; 37 | margin: 0; 38 | padding: 0; 39 | border: 0; 40 | font-size: 100%; 41 | font: inherit; 42 | } 43 | -------------------------------------------------------------------------------- /src/app/components/Board/Board.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import PropTypes from "prop-types"; 3 | import { connect } from "react-redux"; 4 | import { Title } from "react-head"; 5 | import { DragDropContext, Droppable } from "react-beautiful-dnd"; 6 | import classnames from "classnames"; 7 | import List from "../List/List"; 8 | import ListAdder from "../ListAdder/ListAdder"; 9 | import Header from "../Header/Header"; 10 | import BoardHeader from "../BoardHeader/BoardHeader"; 11 | import "./Board.scss"; 12 | 13 | class Board extends Component { 14 | static propTypes = { 15 | lists: PropTypes.arrayOf( 16 | PropTypes.shape({ _id: PropTypes.string.isRequired }) 17 | ).isRequired, 18 | boardId: PropTypes.string.isRequired, 19 | boardTitle: PropTypes.string.isRequired, 20 | boardColor: PropTypes.string.isRequired, 21 | dispatch: PropTypes.func.isRequired 22 | }; 23 | 24 | constructor(props) { 25 | super(props); 26 | this.state = { 27 | startX: null, 28 | startScrollX: null 29 | }; 30 | } 31 | 32 | // boardId is stored in the redux store so that it is available inside middleware 33 | componentDidMount = () => { 34 | const { boardId, dispatch } = this.props; 35 | dispatch({ 36 | type: "PUT_BOARD_ID_IN_REDUX", 37 | payload: { boardId } 38 | }); 39 | }; 40 | 41 | handleDragEnd = ({ source, destination, type }) => { 42 | // dropped outside the list 43 | if (!destination) { 44 | return; 45 | } 46 | const { dispatch, boardId } = this.props; 47 | 48 | // Move list 49 | if (type === "COLUMN") { 50 | // Prevent update if nothing has changed 51 | if (source.index !== destination.index) { 52 | dispatch({ 53 | type: "MOVE_LIST", 54 | payload: { 55 | oldListIndex: source.index, 56 | newListIndex: destination.index, 57 | boardId: source.droppableId 58 | } 59 | }); 60 | } 61 | return; 62 | } 63 | // Move card 64 | if ( 65 | source.index !== destination.index || 66 | source.droppableId !== destination.droppableId 67 | ) { 68 | dispatch({ 69 | type: "MOVE_CARD", 70 | payload: { 71 | sourceListId: source.droppableId, 72 | destListId: destination.droppableId, 73 | oldCardIndex: source.index, 74 | newCardIndex: destination.index, 75 | boardId 76 | } 77 | }); 78 | } 79 | }; 80 | 81 | // The following three methods implement dragging of the board by holding down the mouse 82 | handleMouseDown = ({ target, clientX }) => { 83 | if (target.className !== "list-wrapper" && target.className !== "lists") { 84 | return; 85 | } 86 | window.addEventListener("mousemove", this.handleMouseMove); 87 | window.addEventListener("mouseup", this.handleMouseUp); 88 | this.setState({ 89 | startX: clientX, 90 | startScrollX: window.scrollX 91 | }); 92 | }; 93 | 94 | // Go to new scroll position every time the mouse moves while dragging is activated 95 | handleMouseMove = ({ clientX }) => { 96 | const { startX, startScrollX } = this.state; 97 | const scrollX = startScrollX - clientX + startX; 98 | window.scrollTo(scrollX, 0); 99 | const windowScrollX = window.scrollX; 100 | if (scrollX !== windowScrollX) { 101 | this.setState({ 102 | startX: clientX + windowScrollX - startScrollX 103 | }); 104 | } 105 | }; 106 | 107 | // Remove drag event listeners 108 | handleMouseUp = () => { 109 | if (this.state.startX) { 110 | window.removeEventListener("mousemove", this.handleMouseMove); 111 | window.removeEventListener("mouseup", this.handleMouseUp); 112 | this.setState({ startX: null, startScrollX: null }); 113 | } 114 | }; 115 | 116 | handleWheel = ({ target, deltaY }) => { 117 | // Scroll page right or left as long as the mouse is not hovering a card-list (which could have vertical scroll) 118 | if ( 119 | target.className !== "list-wrapper" && 120 | target.className !== "lists" && 121 | target.className !== "open-composer-button" && 122 | target.className !== "list-title-button" 123 | ) { 124 | return; 125 | } 126 | // Move the board 80 pixes on every wheel event 127 | if (Math.sign(deltaY) === 1) { 128 | window.scrollTo(window.scrollX + 80, 0); 129 | } else if (Math.sign(deltaY) === -1) { 130 | window.scrollTo(window.scrollX - 80, 0); 131 | } 132 | }; 133 | 134 | render = () => { 135 | const { lists, boardTitle, boardId, boardColor } = this.props; 136 | return ( 137 | <> 138 |
139 | {boardTitle} | React Kanban 140 |
141 | 142 | {/* eslint-disable jsx-a11y/no-static-element-interactions */} 143 |
148 | {/* eslint-enable jsx-a11y/no-static-element-interactions */} 149 | 150 | 155 | {provided => ( 156 |
157 | {lists.map((list, index) => ( 158 | 164 | ))} 165 | {provided.placeholder} 166 | 167 |
168 | )} 169 |
170 |
171 |
172 |
173 |
174 | 175 | ); 176 | }; 177 | } 178 | 179 | const mapStateToProps = (state, ownProps) => { 180 | const { board } = ownProps; 181 | return { 182 | lists: board.lists.map(listId => state.listsById[listId]), 183 | boardTitle: board.title, 184 | boardColor: board.color, 185 | boardId: board._id 186 | }; 187 | }; 188 | 189 | export default connect(mapStateToProps)(Board); 190 | -------------------------------------------------------------------------------- /src/app/components/Board/Board.scss: -------------------------------------------------------------------------------- 1 | @import "../../variables.scss"; 2 | 3 | .board { 4 | display: inline-flex; 5 | height: 100%; 6 | min-width: 100%; 7 | } 8 | 9 | .board-underlay { 10 | position: fixed; 11 | top: 0; 12 | bottom: 0; 13 | left: 0; 14 | right: 0; 15 | z-index: -1; 16 | transition: background 0.3s; 17 | } 18 | .green .board-underlay { 19 | background: $green; 20 | } 21 | .blue .board-underlay { 22 | background: $blue; 23 | } 24 | .red .board-underlay { 25 | background: $red; 26 | } 27 | .pink .board-underlay { 28 | background: $pink; 29 | } 30 | 31 | .lists-wrapper { 32 | display: inline-flex; 33 | box-sizing: border-box; 34 | height: 100%; 35 | padding: 85px 5px 8px 5px; 36 | } 37 | 38 | .lists { 39 | display: inline-flex; 40 | align-items: flex-start; 41 | height: 100%; 42 | user-select: none; 43 | } 44 | -------------------------------------------------------------------------------- /src/app/components/Board/BoardContainer.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Redirect } from "react-router"; 3 | import { connect } from "react-redux"; 4 | import PropTypes from "prop-types"; 5 | import Board from "./Board"; 6 | 7 | // This components only purpose is to redirect requests for board pages that don't exist 8 | // or which the user is not authorized to visit, in order to prevent errors 9 | const BoardContainer = props => 10 | props.board ? : ; 11 | 12 | BoardContainer.propTypes = { board: PropTypes.object }; 13 | 14 | const mapStateToProps = (state, ownProps) => { 15 | const { boardId } = ownProps.match.params; 16 | const board = state.boardsById[boardId]; 17 | return { board }; 18 | }; 19 | 20 | export default connect(mapStateToProps)(BoardContainer); 21 | -------------------------------------------------------------------------------- /src/app/components/BoardHeader/BoardDeleter.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import PropTypes from "prop-types"; 3 | import { withRouter } from "react-router-dom"; 4 | import { connect } from "react-redux"; 5 | import { Button, Wrapper, Menu, MenuItem } from "react-aria-menubutton"; 6 | import FaTrash from "react-icons/lib/fa/trash"; 7 | import "./BoardDeleter.scss"; 8 | 9 | class BoardDeleter extends Component { 10 | static propTypes = { 11 | match: PropTypes.shape({ 12 | params: PropTypes.shape({ boardId: PropTypes.string }) 13 | }).isRequired, 14 | history: PropTypes.shape({ push: PropTypes.func.isRequired }).isRequired, 15 | dispatch: PropTypes.func.isRequired 16 | }; 17 | 18 | handleSelection = () => { 19 | const { dispatch, match, history } = this.props; 20 | const { boardId } = match.params; 21 | dispatch({ type: "DELETE_BOARD", payload: { boardId } }); 22 | history.push("/"); 23 | }; 24 | 25 | render = () => ( 26 | 30 | 36 | 37 |
Are you sure?
38 | Delete 39 |
40 |
41 | ); 42 | } 43 | 44 | export default withRouter(connect()(BoardDeleter)); 45 | -------------------------------------------------------------------------------- /src/app/components/BoardHeader/BoardDeleter.scss: -------------------------------------------------------------------------------- 1 | @import "../../variables.scss"; 2 | 3 | .board-deleter-wrapper { 4 | position: relative; 5 | flex-shrink: 0; 6 | font-size: 16px; 7 | } 8 | 9 | .board-deleter-button { 10 | display: flex; 11 | justify-content: space-around; 12 | align-items: center; 13 | padding: 8px 10px 8px 10px; 14 | border-radius: 3px; 15 | color: $white; 16 | transition: background 0.1s; 17 | cursor: pointer; 18 | } 19 | 20 | .board-deleter-button:hover, 21 | .board-deleter-button:focus { 22 | background: $transparent-black; 23 | } 24 | 25 | .board-deleter-button { 26 | cursor: pointer; 27 | } 28 | 29 | .board-deleter-menu { 30 | position: absolute; 31 | top: 100%; 32 | right: 0; 33 | display: flex; 34 | flex-direction: column; 35 | width: 140px; 36 | margin-top: 4px; 37 | padding: 5px; 38 | border-radius: 3px; 39 | color: $black-text; 40 | background: $white; 41 | box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.6); 42 | font-weight: 700; 43 | text-align: center; 44 | } 45 | 46 | .board-deleter-confirm { 47 | margin-top: 5px; 48 | padding: 8px 30px; 49 | border-radius: 6px; 50 | color: white; 51 | background: red; 52 | text-align: center; 53 | cursor: pointer; 54 | } 55 | 56 | .board-deleter-confirm:hover, 57 | .board-deleter-confirm:focus { 58 | background: darkred; 59 | } 60 | -------------------------------------------------------------------------------- /src/app/components/BoardHeader/BoardHeader.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import BoardTitle from "./BoardTitle"; 3 | import ColorPicker from "./ColorPicker"; 4 | import BoardDeleter from "./BoardDeleter"; 5 | import "./BoardHeader.scss"; 6 | 7 | const BoardHeader = () => ( 8 |
9 | 10 |
11 | 12 |
13 | 14 |
15 |
16 | ); 17 | 18 | export default BoardHeader; 19 | -------------------------------------------------------------------------------- /src/app/components/BoardHeader/BoardHeader.scss: -------------------------------------------------------------------------------- 1 | @import "../../variables.scss"; 2 | 3 | .board-header { 4 | position: fixed; 5 | top: 40px; 6 | display: flex; 7 | justify-content: space-between; 8 | align-items: center; 9 | box-sizing: border-box; 10 | height: 40px; 11 | width: 100%; 12 | padding: 8px 10px 5px 10px; 13 | color: $white; 14 | z-index: 1; 15 | } 16 | 17 | .board-header-right { 18 | display: flex; 19 | align-items: center; 20 | flex-shrink: 0; 21 | } 22 | 23 | .vertical-line { 24 | width: 1px; 25 | height: 16px; 26 | margin: 0 10px; 27 | background: $transparent-white; 28 | } 29 | 30 | @media (max-width: 700px) { 31 | .board-header-right-text { 32 | display: none; 33 | } 34 | .vertical-line { 35 | margin: 0 4px; 36 | } 37 | .board-header { 38 | padding-left: 4px; 39 | padding-right: 4px; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/app/components/BoardHeader/BoardTitle.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import PropTypes from "prop-types"; 3 | import { connect } from "react-redux"; 4 | import { withRouter } from "react-router-dom"; 5 | import "./BoardTitle.scss"; 6 | 7 | class BoardTitle extends Component { 8 | static propTypes = { 9 | boardTitle: PropTypes.string.isRequired, 10 | boardId: PropTypes.string.isRequired, 11 | dispatch: PropTypes.func.isRequired 12 | }; 13 | 14 | constructor(props) { 15 | super(props); 16 | this.state = { 17 | isOpen: false, 18 | newTitle: props.boardTitle 19 | }; 20 | } 21 | 22 | handleClick = () => { 23 | this.setState({ isOpen: true }); 24 | }; 25 | 26 | handleChange = event => { 27 | this.setState({ newTitle: event.target.value }); 28 | }; 29 | 30 | submitTitle = () => { 31 | const { dispatch, boardId, boardTitle } = this.props; 32 | const { newTitle } = this.state; 33 | if (newTitle === "") return; 34 | if (boardTitle !== newTitle) { 35 | dispatch({ 36 | type: "CHANGE_BOARD_TITLE", 37 | payload: { 38 | boardTitle: newTitle, 39 | boardId 40 | } 41 | }); 42 | } 43 | this.setState({ isOpen: false }); 44 | }; 45 | 46 | revertTitle = () => { 47 | const { boardTitle } = this.props; 48 | this.setState({ newTitle: boardTitle, isOpen: false }); 49 | }; 50 | 51 | handleKeyDown = event => { 52 | if (event.keyCode === 13) { 53 | this.submitTitle(); 54 | } else if (event.keyCode === 27) { 55 | this.revertTitle(); 56 | } 57 | }; 58 | 59 | handleFocus = event => { 60 | event.target.select(); 61 | }; 62 | 63 | render() { 64 | const { isOpen, newTitle } = this.state; 65 | const { boardTitle } = this.props; 66 | return isOpen ? ( 67 | 78 | ) : ( 79 | 82 | ); 83 | } 84 | } 85 | 86 | const mapStateToProps = (state, ownProps) => { 87 | const { boardId } = ownProps.match.params; 88 | return { 89 | boardTitle: state.boardsById[boardId].title, 90 | boardId 91 | }; 92 | }; 93 | 94 | export default withRouter(connect(mapStateToProps)(BoardTitle)); 95 | -------------------------------------------------------------------------------- /src/app/components/BoardHeader/BoardTitle.scss: -------------------------------------------------------------------------------- 1 | @import "../../variables.scss"; 2 | 3 | .board-title-button { 4 | display: flex; 5 | min-width: 0; 6 | padding: 6px 10px 6px 10px; 7 | border: 0; 8 | border-radius: 3px; 9 | background: transparent; 10 | transition: background 0.1s; 11 | cursor: pointer; 12 | } 13 | 14 | .board-title-button:hover, 15 | .board-title-button:focus { 16 | background: $transparent-black; 17 | } 18 | 19 | .board-title-text { 20 | margin: 0; 21 | color: $white; 22 | font-size: 20px; 23 | overflow: hidden; 24 | text-overflow: ellipsis; 25 | overflow-wrap: break-word; 26 | white-space: nowrap; 27 | } 28 | 29 | .board-title-input { 30 | width: 100%; 31 | padding: 6px 10px 6px 10px; 32 | border: 0; 33 | color: inherit; 34 | background: inherit; 35 | font-size: 20px; 36 | font-weight: 700; 37 | font-family: inherit; 38 | } 39 | -------------------------------------------------------------------------------- /src/app/components/BoardHeader/ColorPicker.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import PropTypes from "prop-types"; 3 | import { connect } from "react-redux"; 4 | import { withRouter } from "react-router-dom"; 5 | import { Button, Wrapper, Menu, MenuItem } from "react-aria-menubutton"; 6 | import classnames from "classnames"; 7 | import FaCheck from "react-icons/lib/fa/check"; 8 | import colorIcon from "../../../assets/images/color-icon.png"; 9 | import "./ColorPicker.scss"; 10 | 11 | class ColorPicker extends Component { 12 | static propTypes = { 13 | boardId: PropTypes.string.isRequired, 14 | boardColor: PropTypes.string.isRequired, 15 | dispatch: PropTypes.func.isRequired 16 | }; 17 | 18 | handleSelection = color => { 19 | const { dispatch, boardId, boardColor } = this.props; 20 | // Dispatch update only if selected color is not the same as current board color. 21 | if (color !== boardColor) { 22 | dispatch({ type: "CHANGE_BOARD_COLOR", payload: { boardId, color } }); 23 | } 24 | }; 25 | 26 | render() { 27 | const { boardColor } = this.props; 28 | const colors = ["blue", "green", "red", "pink"]; 29 | return ( 30 | 34 | 40 | 41 | {colors.map(color => ( 42 | 47 | {color === boardColor && } 48 | 49 | ))} 50 | 51 | 52 | ); 53 | } 54 | } 55 | 56 | const mapStateToProps = (state, ownProps) => { 57 | const { boardId } = ownProps.match.params; 58 | return { 59 | boardColor: state.boardsById[boardId].color, 60 | boardId 61 | }; 62 | }; 63 | 64 | export default withRouter(connect(mapStateToProps)(ColorPicker)); 65 | -------------------------------------------------------------------------------- /src/app/components/BoardHeader/ColorPicker.scss: -------------------------------------------------------------------------------- 1 | @import "../../variables.scss"; 2 | 3 | .color-picker-wrapper { 4 | position: relative; 5 | font-size: 16px; 6 | } 7 | 8 | .color-picker { 9 | display: flex; 10 | justify-content: space-around; 11 | align-items: center; 12 | padding: 8px 10px 8px 10px; 13 | border-radius: 3px; 14 | color: $white; 15 | transition: background 0.1s; 16 | cursor: pointer; 17 | } 18 | 19 | .color-picker:hover, 20 | .color-picker:focus { 21 | background: $transparent-black; 22 | } 23 | 24 | .color-picker-menu { 25 | position: absolute; 26 | top: 100%; 27 | left: 50%; 28 | transform: translateX(-50%); 29 | display: flex; 30 | flex-direction: column; 31 | width: 100px; 32 | margin-top: 4px; 33 | padding: 3px; 34 | border-radius: 3px; 35 | box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.6); 36 | color: white; 37 | background: rgba(255, 255, 255, 0.9); 38 | } 39 | 40 | .color-picker-item { 41 | display: flex; 42 | justify-content: center; 43 | align-items: center; 44 | box-sizing: border-box; 45 | height: 40px; 46 | margin: 3px; 47 | cursor: pointer; 48 | 49 | &.blue { 50 | background: $blue; 51 | &:focus { 52 | background: $dark-blue; 53 | } 54 | } 55 | &.green { 56 | background: $green; 57 | &:focus { 58 | background: $dark-green; 59 | } 60 | } 61 | &.red { 62 | background: $red; 63 | &:focus { 64 | background: $dark-red; 65 | } 66 | } 67 | &.pink { 68 | background: $pink; 69 | &:focus { 70 | background: $dark-pink; 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/app/components/Card/Card.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import PropTypes from "prop-types"; 3 | import { connect } from "react-redux"; 4 | import { Draggable } from "react-beautiful-dnd"; 5 | import classnames from "classnames"; 6 | import CardModal from "../CardModal/CardModal"; 7 | import CardBadges from "../CardBadges/CardBadges"; 8 | import { findCheckboxes } from "../utils"; 9 | import formatMarkdown from "./formatMarkdown"; 10 | import "./Card.scss"; 11 | 12 | class Card extends Component { 13 | static propTypes = { 14 | card: PropTypes.shape({ 15 | _id: PropTypes.string.isRequired, 16 | text: PropTypes.string.isRequired, 17 | color: PropTypes.string 18 | }).isRequired, 19 | listId: PropTypes.string.isRequired, 20 | isDraggingOver: PropTypes.bool.isRequired, 21 | index: PropTypes.number.isRequired, 22 | dispatch: PropTypes.func.isRequired 23 | }; 24 | 25 | constructor() { 26 | super(); 27 | this.state = { 28 | isModalOpen: false 29 | }; 30 | } 31 | 32 | toggleCardEditor = () => { 33 | this.setState({ isModalOpen: !this.state.isModalOpen }); 34 | }; 35 | 36 | handleClick = event => { 37 | const { tagName, checked, id } = event.target; 38 | if (tagName.toLowerCase() === "input") { 39 | // The id is a string that describes which number in the order of checkboxes this particular checkbox has 40 | this.toggleCheckbox(checked, parseInt(id, 10)); 41 | } else if (tagName.toLowerCase() !== "a") { 42 | this.toggleCardEditor(event); 43 | } 44 | }; 45 | 46 | handleKeyDown = event => { 47 | // Only open card on enter since spacebar is used by react-beautiful-dnd for keyboard dragging 48 | if (event.keyCode === 13 && event.target.tagName.toLowerCase() !== "a") { 49 | event.preventDefault(); 50 | this.toggleCardEditor(); 51 | } 52 | }; 53 | 54 | // identify the clicked checkbox by its index and give it a new checked attribute 55 | toggleCheckbox = (checked, i) => { 56 | const { card, dispatch } = this.props; 57 | 58 | let j = 0; 59 | const newText = card.text.replace(/\[(\s|x)\]/g, match => { 60 | let newString; 61 | if (i === j) { 62 | newString = checked ? "[x]" : "[ ]"; 63 | } else { 64 | newString = match; 65 | } 66 | j += 1; 67 | return newString; 68 | }); 69 | 70 | dispatch({ 71 | type: "CHANGE_CARD_TEXT", 72 | payload: { cardId: card._id, cardText: newText } 73 | }); 74 | }; 75 | 76 | render() { 77 | const { card, index, listId, isDraggingOver } = this.props; 78 | const { isModalOpen } = this.state; 79 | const checkboxes = findCheckboxes(card.text); 80 | return ( 81 | <> 82 | 83 | {(provided, snapshot) => ( 84 | <> 85 | {/* eslint-disable */} 86 |
{ 91 | provided.innerRef(ref); 92 | this.ref = ref; 93 | }} 94 | {...provided.draggableProps} 95 | {...provided.dragHandleProps} 96 | onClick={event => { 97 | provided.dragHandleProps.onClick(event); 98 | this.handleClick(event); 99 | }} 100 | onKeyDown={event => { 101 | provided.dragHandleProps.onKeyDown(event); 102 | this.handleKeyDown(event); 103 | }} 104 | style={{ 105 | ...provided.draggableProps.style, 106 | background: card.color 107 | }} 108 | > 109 |
115 | {/* eslint-enable */} 116 | {(card.date || checkboxes.total > 0) && ( 117 | 118 | )} 119 |
120 | {/* Remove placeholder when not dragging over to reduce snapping */} 121 | {isDraggingOver && provided.placeholder} 122 | 123 | )} 124 | 125 | 132 | 133 | ); 134 | } 135 | } 136 | 137 | const mapStateToProps = (state, ownProps) => ({ 138 | card: state.cardsById[ownProps.cardId] 139 | }); 140 | 141 | export default connect(mapStateToProps)(Card); 142 | -------------------------------------------------------------------------------- /src/app/components/Card/Card.scss: -------------------------------------------------------------------------------- 1 | @import "../../variables.scss"; 2 | 3 | .card-title { 4 | position: relative; 5 | box-sizing: border-box; 6 | margin: 8px 5px 0 5px; 7 | border-radius: 3px; 8 | color: $black-text; 9 | background: $white; 10 | box-shadow: 0px 0px 5px rgba(0, 0, 0, 0.2); 11 | font-size: 16px; 12 | overflow-wrap: break-word; 13 | transition: box-shadow 0.15s; 14 | user-select: none; 15 | cursor: pointer !important; 16 | } 17 | 18 | .card-title:focus { 19 | box-shadow: 0px 0px 1px 3px rgb(0, 180, 255); 20 | } 21 | 22 | .card-title--drag { 23 | box-shadow: 1px 9px 8px 1px rgba(0, 0, 0, 0.3) !important; 24 | opacity: 1 !important; 25 | } 26 | 27 | .card-title-html { 28 | padding: 6px 8px; 29 | 30 | h1, 31 | h2, 32 | h3, 33 | h4, 34 | h5, 35 | h6 { 36 | margin: 8px 0; 37 | } 38 | 39 | img { 40 | max-width: 100%; 41 | } 42 | 43 | p { 44 | margin: 4px 0; 45 | } 46 | 47 | code, 48 | pre { 49 | white-space: pre-wrap; 50 | } 51 | pre { 52 | margin: 4px 0; 53 | padding: 4px 2px; 54 | background: rgba(100, 100, 100, 0.08); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/app/components/Card/formatMarkdown.js: -------------------------------------------------------------------------------- 1 | import marked from "marked"; 2 | 3 | // Create HTML string from user generated markdown. 4 | // There is some serious hacks going on here with regards to checkboxes. 5 | // Checkboxes are not a feature of marked so are added manually with an id that 6 | // corresponds to its index in the order of all checkboxes on the card. 7 | // The id attribute is then used in the clickhandler of the card to identify which checkbox is clicked. 8 | const formatMarkdown = markdown => { 9 | let i = 0; 10 | return marked(markdown, { sanitize: true, gfm: true, breaks: true }) 11 | .replace(/ { 13 | let newString; 14 | if (match === "[ ]") { 15 | newString = ``; 16 | } else { 17 | newString = ``; 18 | } 19 | i += 1; 20 | return newString; 21 | }); 22 | }; 23 | 24 | export default formatMarkdown; 25 | -------------------------------------------------------------------------------- /src/app/components/CardAdder/CardAdder.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import PropTypes from "prop-types"; 3 | import { connect } from "react-redux"; 4 | import Textarea from "react-textarea-autosize"; 5 | import shortid from "shortid"; 6 | import ClickOutside from "../ClickOutside/ClickOutside"; 7 | import "./CardAdder.scss"; 8 | 9 | class CardAdder extends Component { 10 | static propTypes = { 11 | listId: PropTypes.string.isRequired, 12 | dispatch: PropTypes.func.isRequired 13 | }; 14 | 15 | constructor() { 16 | super(); 17 | this.state = { 18 | newText: "", 19 | isOpen: false 20 | }; 21 | } 22 | 23 | toggleCardComposer = () => { 24 | this.setState({ isOpen: !this.state.isOpen }); 25 | }; 26 | 27 | handleChange = event => { 28 | this.setState({ newText: event.target.value }); 29 | }; 30 | 31 | handleKeyDown = event => { 32 | if (event.keyCode === 13 && event.shiftKey === false) { 33 | this.handleSubmit(event); 34 | } else if (event.keyCode === 27) { 35 | this.toggleCardComposer(); 36 | } 37 | }; 38 | 39 | handleSubmit = event => { 40 | event.preventDefault(); 41 | const { newText } = this.state; 42 | const { listId, dispatch } = this.props; 43 | if (newText === "") return; 44 | 45 | const cardId = shortid.generate(); 46 | dispatch({ 47 | type: "ADD_CARD", 48 | payload: { cardText: newText, cardId, listId } 49 | }); 50 | this.toggleCardComposer(); 51 | this.setState({ newText: "" }); 52 | }; 53 | 54 | render() { 55 | const { newText, isOpen } = this.state; 56 | return isOpen ? ( 57 | 58 |
62 |