├── .firebaserc ├── .gitignore ├── LICENSE ├── README.md ├── circle.yml ├── firebase.json ├── firebase.rules.json ├── package-lock.json ├── package.json ├── public ├── favicon.ico ├── index.html └── manifest.json └── src ├── auth ├── actions.js ├── auth.js ├── index.js ├── reducer.js ├── sagas.js └── selectors.js ├── firebase ├── config.js ├── firebase-list.js ├── firebase.js └── index.js ├── history.js ├── index.js ├── reducers.js ├── register-service-worker.js ├── sagas.js ├── store.js ├── tasks ├── actions.js ├── index.js ├── reducer.js ├── sagas.js ├── selectors.js ├── task-list.js └── task.js └── views ├── app ├── app.js └── index.js ├── components ├── button │ ├── button.js │ ├── button.scss │ ├── button.spec.js │ └── index.js ├── github-logo │ ├── github-logo.js │ └── index.js ├── header │ ├── header.js │ ├── header.scss │ └── index.js ├── icon │ ├── icon.js │ ├── icon.spec.js │ └── index.js ├── require-auth-route │ ├── index.js │ └── require-auth-route.js ├── require-unauth-route │ ├── index.js │ └── require-unauth-route.js ├── task-filters │ ├── index.js │ ├── task-filters.js │ └── task-filters.scss ├── task-form │ ├── index.js │ ├── task-form.js │ └── task-form.scss ├── task-item │ ├── index.js │ ├── task-item.js │ └── task-item.scss └── task-list │ ├── index.js │ ├── task-list.js │ └── task-list.scss ├── pages ├── sign-in │ ├── index.js │ ├── sign-in-page.js │ └── sign-in-page.scss └── tasks │ ├── index.js │ └── tasks-page.js └── styles ├── _grid.scss ├── _settings.scss ├── _shared.scss └── styles.scss /.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "default": "todo-redux-saga" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | #====================================== 2 | # Directories 3 | #-------------------------------------- 4 | build/ 5 | dist/ 6 | coverage/ 7 | node_modules/ 8 | tmp/ 9 | 10 | 11 | #====================================== 12 | # Extensions 13 | #-------------------------------------- 14 | *.css 15 | *.gz 16 | *.local 17 | *.log 18 | *.rar 19 | *.tar 20 | *.zip 21 | 22 | 23 | #====================================== 24 | # IDE generated 25 | #-------------------------------------- 26 | .idea/ 27 | .project 28 | *.iml 29 | 30 | 31 | #====================================== 32 | # OS generated 33 | #-------------------------------------- 34 | __MACOSX/ 35 | .DS_Store 36 | Thumbs.db 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Richard Park (objectiv@gmail.com) 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 | [![CircleCI](https://circleci.com/gh/r-park/todo-redux-saga.svg?style=shield&circle-token=dc7e150ab97aab05db8f8da4b5874488bf8da0c6)](https://circleci.com/gh/r-park/todo-redux-saga) 2 | 3 | 4 | # A simple Todo app example built with [Create React App](https://github.com/facebookincubator/create-react-app), [React Redux](https://github.com/reactjs/react-redux), [Redux Saga](https://github.com/redux-saga/redux-saga), and Firebase 5 | 6 | Try the demo at todo-redux-saga.firebaseapp.com. 7 | 8 | 9 | ## Stack 10 | 11 | - Create React App 12 | - React Redux 13 | - React Router 14 | - React Router Redux 15 | - Redux Saga 16 | - Redux Devtools Extension for Chrome 17 | - Firebase SDK with OAuth authentication 18 | - Immutable 19 | - Reselect 20 | - SASS 21 | 22 | 23 | ## Quick Start 24 | 25 | ```shell 26 | $ git clone https://github.com/r-park/todo-redux-saga.git 27 | $ cd todo-redux-saga 28 | $ npm install 29 | $ npm start 30 | ``` 31 | 32 | ## Deploying to Firebase 33 | #### Prerequisites: 34 | - Create a free Firebase account at https://firebase.google.com 35 | - Create a project from your [Firebase account console](https://console.firebase.google.com) 36 | - Configure the authentication providers for your Firebase project from your Firebase account console 37 | 38 | #### Configure this app with your project-specific details: 39 | ```json 40 | // .firebaserc 41 | 42 | { 43 | "projects": { 44 | "default": "your-project-id" 45 | } 46 | } 47 | ``` 48 | 49 | ```javascript 50 | // src/firebase/config.js 51 | 52 | export const firebaseConfig = { 53 | apiKey: 'your api key', 54 | authDomain: 'your-project-id.firebaseapp.com', 55 | databaseURL: 'https://your-project-id.firebaseio.com', 56 | storageBucket: 'your-project-id.appspot.com' 57 | }; 58 | ``` 59 | 60 | #### Install firebase-tools: 61 | ```shell 62 | $ npm install -g firebase-tools 63 | ``` 64 | 65 | #### Build and deploy the app: 66 | ```shell 67 | $ npm run build 68 | $ firebase login 69 | $ firebase use default 70 | $ firebase deploy 71 | ``` 72 | 73 | 74 | ## NPM Commands 75 | 76 | |Script|Description| 77 | |---|---| 78 | |`npm start`|Start webpack development server @ `localhost:3000`| 79 | |`npm run build`|Build the application to `./build` directory| 80 | |`npm test`|Test the application; watch for changes and retest| 81 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | machine: 2 | node: 3 | version: 8.1 4 | 5 | dependencies: 6 | pre: 7 | - rm -rf node_modules 8 | 9 | test: 10 | override: 11 | - npm run build 12 | - npm test 13 | 14 | deployment: 15 | production: 16 | branch: master 17 | commands: 18 | - ./node_modules/.bin/firebase deploy --token $FIREBASE_TOKEN 19 | -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "database": { 3 | "rules": "firebase.rules.json" 4 | }, 5 | 6 | "hosting": { 7 | "public": "build", 8 | "headers": [ 9 | { 10 | "source": "**/*", 11 | "headers": [ 12 | {"key": "X-Content-Type-Options", "value": "nosniff"}, 13 | {"key": "X-Frame-Options", "value": "DENY"}, 14 | {"key": "X-UA-Compatible", "value": "ie=edge"}, 15 | {"key": "X-XSS-Protection", "value": "1; mode=block"} 16 | ] 17 | }, 18 | { 19 | "source": "**/*.@(css|html|js|map)", 20 | "headers": [ 21 | {"key": "Cache-Control", "value": "max-age=3600"} 22 | ] 23 | } 24 | ], 25 | "rewrites": [ 26 | {"source": "**", "destination": "/index.html"} 27 | ] 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /firebase.rules.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "tasks": { 4 | "$uid": { 5 | ".read": "auth !== null && auth.uid === $uid", 6 | ".write": "auth !== null && auth.uid === $uid" 7 | } 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "todo-redux-saga", 3 | "version": "0.0.0", 4 | "description": "Todo app with React, Redux, Redux-Saga, and Firebase", 5 | "homepage": "https://todo-redux-saga.firebaseapp.com", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+https://github.com/r-park/todo-redux-saga.git" 9 | }, 10 | "author": { 11 | "name": "Richard Park", 12 | "email": "objectiv@gmail.com" 13 | }, 14 | "license": "MIT", 15 | "private": true, 16 | "engines": { 17 | "node": ">=8.1.4" 18 | }, 19 | "scripts": { 20 | "eject": "react-scripts eject", 21 | "build": "run-s build.css build.js", 22 | "build.css": "node-sass-chokidar --include-path ./src --include-path ./node_modules src/ -o src/", 23 | "build.js": "cross-env NODE_PATH=. react-scripts build", 24 | "start": "run-p start.css start.js", 25 | "start.css": "npm run build.css && node-sass-chokidar --include-path ./src --include-path ./node_modules src/ -o src/ --watch --recursive", 26 | "start.js": "cross-env NODE_PATH=. react-scripts start", 27 | "test": "cross-env NODE_PATH=. react-scripts test --env=jsdom", 28 | "test.ci": "cross-env CI=true NODE_PATH=. react-scripts test --env=jsdom" 29 | }, 30 | "dependencies": { 31 | "classnames": "^2.2.5", 32 | "firebase": "^4.1.3", 33 | "history": "^4.6.3", 34 | "immutable": "^3.8.1", 35 | "prop-types": "^15.5.10", 36 | "react": "^15.6.1", 37 | "react-dom": "^15.6.1", 38 | "react-redux": "^5.0.5", 39 | "react-router": "^4.1.1", 40 | "react-router-dom": "^4.1.1", 41 | "react-router-redux": "^5.0.0-alpha.6", 42 | "react-scripts": "1.0.10", 43 | "redux": "^3.7.1", 44 | "redux-saga": "^0.15.4", 45 | "reselect": "^3.0.1" 46 | }, 47 | "devDependencies": { 48 | "cross-env": "^5.0.1", 49 | "enzyme": "^2.9.1", 50 | "firebase-tools": "^3.9.1", 51 | "minx": "r-park/minx.git", 52 | "node-sass-chokidar": "0.0.3", 53 | "npm-run-all": "^4.0.2", 54 | "react-test-renderer": "^15.6.1" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/r-park/todo-redux-saga/d693f0f757f0d158a3195bad7641edb7f5e7aa99/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Todo Redux Saga 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 |
21 | 22 | 23 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Todo Redux Saga", 3 | "name": "Todo app with React, Redux, Redux-Saga, and Firebase", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /src/auth/actions.js: -------------------------------------------------------------------------------- 1 | import firebase from 'firebase/app'; 2 | 3 | 4 | export const authActions = { 5 | SIGN_IN: 'SIGN_IN', 6 | SIGN_IN_FAILED: 'SIGN_IN_FAILED', 7 | SIGN_IN_FULFILLED: 'SIGN_IN_FULFILLED', 8 | 9 | SIGN_OUT: 'SIGN_OUT', 10 | SIGN_OUT_FAILED: 'SIGN_OUT_FAILED', 11 | SIGN_OUT_FULFILLED: 'SIGN_OUT_FULFILLED', 12 | 13 | 14 | signIn: authProvider => ({ 15 | type: authActions.SIGN_IN, 16 | payload: {authProvider} 17 | }), 18 | 19 | signInFailed: error => ({ 20 | type: authActions.SIGN_IN_FAILED, 21 | payload: {error} 22 | }), 23 | 24 | signInFulfilled: authUser => ({ 25 | type: authActions.SIGN_IN_FULFILLED, 26 | payload: {authUser} 27 | }), 28 | 29 | signInWithGithub: () => authActions.signIn( 30 | new firebase.auth.GithubAuthProvider() 31 | ), 32 | 33 | signInWithGoogle: () => authActions.signIn( 34 | new firebase.auth.GoogleAuthProvider() 35 | ), 36 | 37 | signInWithTwitter: () => authActions.signIn( 38 | new firebase.auth.TwitterAuthProvider() 39 | ), 40 | 41 | signOut: () => ({ 42 | type: authActions.SIGN_OUT 43 | }), 44 | 45 | signOutFailed: error => ({ 46 | type: authActions.SIGN_OUT_FAILED, 47 | payload: {error} 48 | }), 49 | 50 | signOutFulfilled: () => ({ 51 | type: authActions.SIGN_OUT_FULFILLED 52 | }) 53 | }; 54 | -------------------------------------------------------------------------------- /src/auth/auth.js: -------------------------------------------------------------------------------- 1 | import { firebaseAuth } from 'src/firebase'; 2 | import { authActions } from './actions'; 3 | 4 | 5 | export function initAuth(dispatch) { 6 | return new Promise((resolve, reject) => { 7 | const unsubscribe = firebaseAuth.onAuthStateChanged( 8 | authUser => { 9 | if (authUser) { 10 | dispatch(authActions.signInFulfilled(authUser)); 11 | } 12 | 13 | resolve(); 14 | unsubscribe(); 15 | }, 16 | 17 | error => reject(error) 18 | ); 19 | }); 20 | } 21 | -------------------------------------------------------------------------------- /src/auth/index.js: -------------------------------------------------------------------------------- 1 | export { authActions } from './actions'; 2 | export { initAuth } from './auth'; 3 | export { authReducer } from './reducer'; 4 | export { authSagas } from './sagas'; 5 | export { getAuth, isAuthenticated } from './selectors'; 6 | -------------------------------------------------------------------------------- /src/auth/reducer.js: -------------------------------------------------------------------------------- 1 | import { Record } from 'immutable'; 2 | import { authActions } from './actions'; 3 | 4 | 5 | export const AuthState = new Record({ 6 | authenticated: false, 7 | uid: null, 8 | user: null 9 | }); 10 | 11 | 12 | export function authReducer(state = new AuthState(), {payload, type}) { 13 | switch (type) { 14 | case authActions.SIGN_IN_FULFILLED: 15 | return state.merge({ 16 | authenticated: true, 17 | uid: payload.uid, 18 | user: payload 19 | }); 20 | 21 | case authActions.SIGN_OUT_FULFILLED: 22 | return state.merge({ 23 | authenticated: false, 24 | uid: null, 25 | user: null 26 | }); 27 | 28 | default: 29 | return state; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/auth/sagas.js: -------------------------------------------------------------------------------- 1 | import { call, fork, put, take } from 'redux-saga/effects'; 2 | import { firebaseAuth } from 'src/firebase'; 3 | import history from 'src/history'; 4 | import { authActions } from './actions'; 5 | 6 | 7 | function* signIn(authProvider) { 8 | try { 9 | const authData = yield call([firebaseAuth, firebaseAuth.signInWithPopup], authProvider); 10 | yield put(authActions.signInFulfilled(authData.user)); 11 | yield history.push('/'); 12 | } 13 | catch (error) { 14 | yield put(authActions.signInFailed(error)); 15 | } 16 | } 17 | 18 | function* signOut() { 19 | try { 20 | yield call([firebaseAuth, firebaseAuth.signOut]); 21 | yield put(authActions.signOutFulfilled()); 22 | yield history.replace('/sign-in'); 23 | } 24 | catch (error) { 25 | yield put(authActions.signOutFailed(error)); 26 | } 27 | } 28 | 29 | 30 | //===================================== 31 | // WATCHERS 32 | //------------------------------------- 33 | 34 | function* watchSignIn() { 35 | while (true) { 36 | let { payload } = yield take(authActions.SIGN_IN); 37 | yield fork(signIn, payload.authProvider); 38 | } 39 | } 40 | 41 | function* watchSignOut() { 42 | while (true) { 43 | yield take(authActions.SIGN_OUT); 44 | yield fork(signOut); 45 | } 46 | } 47 | 48 | 49 | //===================================== 50 | // AUTH SAGAS 51 | //------------------------------------- 52 | 53 | export const authSagas = [ 54 | fork(watchSignIn), 55 | fork(watchSignOut) 56 | ]; 57 | -------------------------------------------------------------------------------- /src/auth/selectors.js: -------------------------------------------------------------------------------- 1 | import { createSelector } from 'reselect'; 2 | 3 | 4 | export function isAuthenticated(state) { 5 | return state.auth.authenticated; 6 | } 7 | 8 | 9 | //===================================== 10 | // MEMOIZED SELECTORS 11 | //------------------------------------- 12 | 13 | export const getAuth = createSelector( 14 | state => state.auth, 15 | auth => auth.toJS() 16 | ); 17 | -------------------------------------------------------------------------------- /src/firebase/config.js: -------------------------------------------------------------------------------- 1 | export const firebaseConfig = { 2 | apiKey: 'AIzaSyCUll5AyYba1XL8NDYKZ51RGt90KofQo6c', 3 | authDomain: 'todo-redux-saga.firebaseapp.com', 4 | databaseURL: 'https://todo-redux-saga.firebaseio.com', 5 | storageBucket: 'todo-redux-saga.appspot.com' 6 | }; 7 | -------------------------------------------------------------------------------- /src/firebase/firebase-list.js: -------------------------------------------------------------------------------- 1 | import { firebaseDb } from './firebase'; 2 | 3 | 4 | export class FirebaseList { 5 | constructor(actions, modelClass) { 6 | this._actions = actions; 7 | this._modelClass = modelClass; 8 | } 9 | 10 | get path() { 11 | return this._path; 12 | } 13 | 14 | set path(value) { 15 | this._path = value; 16 | } 17 | 18 | push(value) { 19 | return new Promise((resolve, reject) => { 20 | firebaseDb.ref(this.path) 21 | .push(value, error => error ? reject(error) : resolve()); 22 | }); 23 | } 24 | 25 | remove(key) { 26 | return new Promise((resolve, reject) => { 27 | firebaseDb.ref(`${this.path}/${key}`) 28 | .remove(error => error ? reject(error) : resolve()); 29 | }); 30 | } 31 | 32 | update(key, value) { 33 | return new Promise((resolve, reject) => { 34 | firebaseDb.ref(`${this.path}/${key}`) 35 | .update(value, error => error ? reject(error) : resolve()); 36 | }); 37 | } 38 | 39 | subscribe(emit) { 40 | let ref = firebaseDb.ref(this.path); 41 | let initialized = false; 42 | let list = []; 43 | 44 | ref.once('value', () => { 45 | initialized = true; 46 | emit(this._actions.onLoad(list)); 47 | }); 48 | 49 | ref.on('child_added', snapshot => { 50 | if (initialized) { 51 | emit(this._actions.onAdd(this.unwrapSnapshot(snapshot))); 52 | } 53 | else { 54 | list.push(this.unwrapSnapshot(snapshot)); 55 | } 56 | }); 57 | 58 | ref.on('child_changed', snapshot => { 59 | emit(this._actions.onChange(this.unwrapSnapshot(snapshot))); 60 | }); 61 | 62 | ref.on('child_removed', snapshot => { 63 | emit(this._actions.onRemove(this.unwrapSnapshot(snapshot))); 64 | }); 65 | 66 | return () => ref.off(); 67 | } 68 | 69 | unwrapSnapshot(snapshot) { 70 | let attrs = snapshot.val(); 71 | attrs.key = snapshot.key; 72 | return new this._modelClass(attrs); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/firebase/firebase.js: -------------------------------------------------------------------------------- 1 | import firebase from 'firebase/app'; 2 | 3 | import 'firebase/auth'; 4 | import 'firebase/database'; 5 | 6 | import { firebaseConfig } from './config'; 7 | 8 | 9 | export const firebaseApp = firebase.initializeApp(firebaseConfig); 10 | export const firebaseAuth = firebase.auth(); 11 | export const firebaseDb = firebase.database(); 12 | -------------------------------------------------------------------------------- /src/firebase/index.js: -------------------------------------------------------------------------------- 1 | export { firebaseApp, firebaseAuth, firebaseDb } from './firebase'; 2 | export { FirebaseList } from './firebase-list'; 3 | -------------------------------------------------------------------------------- /src/history.js: -------------------------------------------------------------------------------- 1 | import createHistory from 'history/createBrowserHistory'; 2 | 3 | 4 | export default createHistory(); 5 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import './views/styles/styles.css'; 2 | 3 | import React from 'react'; 4 | import ReactDOM from 'react-dom'; 5 | import { Provider } from 'react-redux'; 6 | import { ConnectedRouter } from 'react-router-redux'; 7 | 8 | import { initAuth } from './auth'; 9 | import history from './history'; 10 | import configureStore from './store'; 11 | import App from './views/app'; 12 | import registerServiceWorker from './register-service-worker'; 13 | 14 | 15 | const store = configureStore(); 16 | const rootElement = document.getElementById('root'); 17 | 18 | 19 | function render(Component) { 20 | ReactDOM.render( 21 | 22 | 23 |
24 | 25 |
26 |
27 |
, 28 | rootElement 29 | ); 30 | } 31 | 32 | 33 | if (module.hot) { 34 | module.hot.accept('./views/app', () => { 35 | render(require('./views/app').default); 36 | }) 37 | } 38 | 39 | 40 | registerServiceWorker(); 41 | 42 | 43 | initAuth(store.dispatch) 44 | .then(() => render(App)) 45 | .catch(error => console.error(error)); 46 | -------------------------------------------------------------------------------- /src/reducers.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import { routerReducer } from 'react-router-redux'; 3 | import { authReducer } from './auth'; 4 | import { tasksReducer } from './tasks'; 5 | 6 | 7 | export default combineReducers({ 8 | auth: authReducer, 9 | routing: routerReducer, 10 | tasks: tasksReducer 11 | }); 12 | -------------------------------------------------------------------------------- /src/register-service-worker.js: -------------------------------------------------------------------------------- 1 | // In production, we register a service worker to serve assets from local cache. 2 | 3 | // This lets the app load faster on subsequent visits in production, and gives 4 | // it offline capabilities. However, it also means that developers (and users) 5 | // will only see deployed updates on the "N+1" visit to a page, since previously 6 | // cached resources are updated in the background. 7 | 8 | // To learn more about the benefits of this model, read https://goo.gl/KwvDNy. 9 | // This link also includes instructions on opting out of this behavior. 10 | 11 | const isLocalhost = Boolean( 12 | window.location.hostname === 'localhost' || 13 | // [::1] is the IPv6 localhost address. 14 | window.location.hostname === '[::1]' || 15 | // 127.0.0.1/8 is considered localhost for IPv4. 16 | window.location.hostname.match( 17 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 18 | ) 19 | ); 20 | 21 | export default function register() { 22 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 23 | // The URL constructor is available in all browsers that support SW. 24 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location); 25 | if (publicUrl.origin !== window.location.origin) { 26 | // Our service worker won't work if PUBLIC_URL is on a different origin 27 | // from what our page is served on. This might happen if a CDN is used to 28 | // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374 29 | return; 30 | } 31 | 32 | window.addEventListener('load', () => { 33 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 34 | 35 | if (!isLocalhost) { 36 | // Is not local host. Just register service worker 37 | registerValidSW(swUrl); 38 | } else { 39 | // This is running on localhost. Lets check if a service worker still exists or not. 40 | checkValidServiceWorker(swUrl); 41 | } 42 | }); 43 | } 44 | } 45 | 46 | function registerValidSW(swUrl) { 47 | navigator.serviceWorker 48 | .register(swUrl) 49 | .then(registration => { 50 | registration.onupdatefound = () => { 51 | const installingWorker = registration.installing; 52 | installingWorker.onstatechange = () => { 53 | if (installingWorker.state === 'installed') { 54 | if (navigator.serviceWorker.controller) { 55 | // At this point, the old content will have been purged and 56 | // the fresh content will have been added to the cache. 57 | // It's the perfect time to display a "New content is 58 | // available; please refresh." message in your web app. 59 | console.log('New content is available; please refresh.'); 60 | } else { 61 | // At this point, everything has been precached. 62 | // It's the perfect time to display a 63 | // "Content is cached for offline use." message. 64 | console.log('Content is cached for offline use.'); 65 | } 66 | } 67 | }; 68 | }; 69 | }) 70 | .catch(error => { 71 | console.error('Error during service worker registration:', error); 72 | }); 73 | } 74 | 75 | function checkValidServiceWorker(swUrl) { 76 | // Check if the service worker can be found. If it can't reload the page. 77 | fetch(swUrl) 78 | .then(response => { 79 | // Ensure service worker exists, and that we really are getting a JS file. 80 | if ( 81 | response.status === 404 || 82 | response.headers.get('content-type').indexOf('javascript') === -1 83 | ) { 84 | // No service worker found. Probably a different app. Reload the page. 85 | navigator.serviceWorker.ready.then(registration => { 86 | registration.unregister().then(() => { 87 | window.location.reload(); 88 | }); 89 | }); 90 | } else { 91 | // Service worker found. Proceed as normal. 92 | registerValidSW(swUrl); 93 | } 94 | }) 95 | .catch(() => { 96 | console.log( 97 | 'No internet connection found. App is running in offline mode.' 98 | ); 99 | }); 100 | } 101 | 102 | export function unregister() { 103 | if ('serviceWorker' in navigator) { 104 | navigator.serviceWorker.ready.then(registration => { 105 | registration.unregister(); 106 | }); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/sagas.js: -------------------------------------------------------------------------------- 1 | import { all } from 'redux-saga/effects' 2 | import { authSagas } from './auth'; 3 | import { taskSagas } from './tasks'; 4 | 5 | 6 | export default function* sagas() { 7 | yield all([ 8 | ...authSagas, 9 | ...taskSagas 10 | ]); 11 | } 12 | -------------------------------------------------------------------------------- /src/store.js: -------------------------------------------------------------------------------- 1 | import { routerMiddleware } from 'react-router-redux'; 2 | import { applyMiddleware, compose, createStore } from 'redux'; 3 | import createSagaMiddleware from 'redux-saga'; 4 | import history from './history'; 5 | import reducers from './reducers'; 6 | import sagas from './sagas'; 7 | 8 | 9 | export default function configureStore() { 10 | const sagaMiddleware = createSagaMiddleware(); 11 | let middleware = applyMiddleware(sagaMiddleware, routerMiddleware(history)); 12 | 13 | if (process.env.NODE_ENV !== 'production') { 14 | const devToolsExtension = window.devToolsExtension; 15 | if (typeof devToolsExtension === 'function') { 16 | middleware = compose(middleware, devToolsExtension()); 17 | } 18 | } 19 | 20 | const store = createStore(reducers, middleware); 21 | sagaMiddleware.run(sagas); 22 | 23 | if (module.hot) { 24 | module.hot.accept('./reducers', () => { 25 | store.replaceReducer(require('./reducers').default); 26 | }); 27 | } 28 | 29 | return store; 30 | } 31 | -------------------------------------------------------------------------------- /src/tasks/actions.js: -------------------------------------------------------------------------------- 1 | export const taskActions = { 2 | CREATE_TASK: 'CREATE_TASK', 3 | CREATE_TASK_FAILED: 'CREATE_TASK_FAILED', 4 | CREATE_TASK_FULFILLED: 'CREATE_TASK_FULFILLED', 5 | 6 | REMOVE_TASK: 'REMOVE_TASK', 7 | REMOVE_TASK_FAILED: 'REMOVE_TASK_FAILED', 8 | REMOVE_TASK_FULFILLED: 'REMOVE_TASK_FULFILLED', 9 | 10 | UPDATE_TASK: 'UPDATE_TASK', 11 | UPDATE_TASK_FAILED: 'UPDATE_TASK_FAILED', 12 | UPDATE_TASK_FULFILLED: 'UPDATE_TASK_FULFILLED', 13 | 14 | FILTER_TASKS: 'FILTER_TASKS', 15 | LOAD_TASKS_FULFILLED: 'LOAD_TASKS_FULFILLED', 16 | 17 | 18 | createTask: title => ({ 19 | type: taskActions.CREATE_TASK, 20 | payload: {task: {title, completed: false}} 21 | }), 22 | 23 | createTaskFailed: error => ({ 24 | type: taskActions.CREATE_TASK_FAILED, 25 | payload: {error} 26 | }), 27 | 28 | createTaskFulfilled: task => ({ 29 | type: taskActions.CREATE_TASK_FULFILLED, 30 | payload: {task} 31 | }), 32 | 33 | removeTask: task => ({ 34 | type: taskActions.REMOVE_TASK, 35 | payload: {task} 36 | }), 37 | 38 | removeTaskFailed: error => ({ 39 | type: taskActions.REMOVE_TASK_FAILED, 40 | payload: {error} 41 | }), 42 | 43 | removeTaskFulfilled: task => ({ 44 | type: taskActions.REMOVE_TASK_FULFILLED, 45 | payload: {task} 46 | }), 47 | 48 | updateTask: (task, changes) => ({ 49 | type: taskActions.UPDATE_TASK, 50 | payload: {task, changes} 51 | }), 52 | 53 | updateTaskFailed: error => ({ 54 | type: taskActions.UPDATE_TASK_FAILED, 55 | payload: {error} 56 | }), 57 | 58 | updateTaskFulfilled: task => ({ 59 | type: taskActions.UPDATE_TASK_FULFILLED, 60 | payload: {task} 61 | }), 62 | 63 | filterTasks: filterType => ({ 64 | type: taskActions.FILTER_TASKS, 65 | payload: {filterType} 66 | }), 67 | 68 | loadTasksFulfilled: tasks => ({ 69 | type: taskActions.LOAD_TASKS_FULFILLED, 70 | payload: {tasks} 71 | }) 72 | }; 73 | -------------------------------------------------------------------------------- /src/tasks/index.js: -------------------------------------------------------------------------------- 1 | export { taskActions } from './actions'; 2 | export { tasksReducer } from './reducer'; 3 | export { taskSagas } from './sagas'; 4 | export { getVisibleTasks } from './selectors'; 5 | -------------------------------------------------------------------------------- /src/tasks/reducer.js: -------------------------------------------------------------------------------- 1 | import { List, Record } from 'immutable'; 2 | import { taskActions } from './actions'; 3 | 4 | 5 | export const TasksState = new Record({ 6 | filter: '', 7 | list: new List() 8 | }); 9 | 10 | 11 | export function tasksReducer(state = new TasksState(), {payload, type}) { 12 | switch (type) { 13 | case taskActions.CREATE_TASK_FULFILLED: 14 | return state.set('list', state.list.unshift(payload.task)); 15 | 16 | case taskActions.FILTER_TASKS: 17 | return state.set('filter', payload.filterType || ''); 18 | 19 | case taskActions.LOAD_TASKS_FULFILLED: 20 | return state.set('list', new List(payload.tasks.reverse())); 21 | 22 | case taskActions.REMOVE_TASK_FULFILLED: 23 | return state.set('list', state.list.filter(task => { 24 | return task.key !== payload.task.key; 25 | })); 26 | 27 | case taskActions.UPDATE_TASK_FULFILLED: 28 | return state.set('list', state.list.map(task => { 29 | return task.key === payload.task.key ? payload.task : task; 30 | })); 31 | 32 | default: 33 | return state; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/tasks/sagas.js: -------------------------------------------------------------------------------- 1 | import { LOCATION_CHANGE } from 'react-router-redux'; 2 | import { eventChannel } from 'redux-saga'; 3 | import { call, cancel, fork, put, take } from 'redux-saga/effects'; 4 | import { authActions } from 'src/auth'; 5 | import { taskActions } from './actions'; 6 | import { taskList } from './task-list'; 7 | 8 | 9 | function subscribe() { 10 | return eventChannel(emit => taskList.subscribe(emit)); 11 | } 12 | 13 | function* read() { 14 | const channel = yield call(subscribe); 15 | while (true) { 16 | let action = yield take(channel); 17 | yield put(action); 18 | } 19 | } 20 | 21 | function* write(context, method, onError, ...params) { 22 | try { 23 | yield call([context, method], ...params); 24 | } 25 | catch (error) { 26 | yield put(onError(error)); 27 | } 28 | } 29 | 30 | const createTask = write.bind(null, taskList, taskList.push, taskActions.createTaskFailed); 31 | const removeTask = write.bind(null, taskList, taskList.remove, taskActions.removeTaskFailed); 32 | const updateTask = write.bind(null, taskList, taskList.update, taskActions.updateTaskFailed); 33 | 34 | 35 | //===================================== 36 | // WATCHERS 37 | //------------------------------------- 38 | 39 | function* watchAuthentication() { 40 | while (true) { 41 | let { payload } = yield take(authActions.SIGN_IN_FULFILLED); 42 | 43 | taskList.path = `tasks/${payload.authUser.uid}`; 44 | const job = yield fork(read); 45 | 46 | yield take([authActions.SIGN_OUT_FULFILLED]); 47 | yield cancel(job); 48 | } 49 | } 50 | 51 | function* watchCreateTask() { 52 | while (true) { 53 | let { payload } = yield take(taskActions.CREATE_TASK); 54 | yield fork(createTask, payload.task); 55 | } 56 | } 57 | 58 | function* watchLocationChange() { 59 | while (true) { 60 | let { payload } = yield take(LOCATION_CHANGE); 61 | if (payload.pathname === '/') { 62 | const params = new URLSearchParams(payload.search); 63 | const filter = params.get('filter'); 64 | yield put(taskActions.filterTasks(filter)); 65 | } 66 | } 67 | } 68 | 69 | function* watchRemoveTask() { 70 | while (true) { 71 | let { payload } = yield take(taskActions.REMOVE_TASK); 72 | yield fork(removeTask, payload.task.key); 73 | } 74 | } 75 | 76 | function* watchUpdateTask() { 77 | while (true) { 78 | let { payload } = yield take(taskActions.UPDATE_TASK); 79 | yield fork(updateTask, payload.task.key, payload.changes); 80 | } 81 | } 82 | 83 | 84 | //===================================== 85 | // TASK SAGAS 86 | //------------------------------------- 87 | 88 | export const taskSagas = [ 89 | fork(watchAuthentication), 90 | fork(watchCreateTask), 91 | fork(watchLocationChange), 92 | fork(watchRemoveTask), 93 | fork(watchUpdateTask) 94 | ]; 95 | -------------------------------------------------------------------------------- /src/tasks/selectors.js: -------------------------------------------------------------------------------- 1 | import { createSelector } from 'reselect'; 2 | 3 | 4 | export function getTasks(state) { 5 | return state.tasks; 6 | } 7 | 8 | export function getTaskFilter(state) { 9 | return getTasks(state).filter; 10 | } 11 | 12 | export function getTaskList(state) { 13 | return getTasks(state).list; 14 | } 15 | 16 | 17 | //===================================== 18 | // MEMOIZED SELECTORS 19 | //------------------------------------- 20 | 21 | export const getVisibleTasks = createSelector( 22 | getTaskFilter, 23 | getTaskList, 24 | (filter, taskList) => { 25 | switch (filter) { 26 | case 'active': 27 | return taskList.filter(task => !task.completed); 28 | 29 | case 'completed': 30 | return taskList.filter(task => task.completed); 31 | 32 | default: 33 | return taskList; 34 | } 35 | } 36 | ); 37 | -------------------------------------------------------------------------------- /src/tasks/task-list.js: -------------------------------------------------------------------------------- 1 | import { FirebaseList } from 'src/firebase'; 2 | import { taskActions } from './actions'; 3 | import { Task } from './task'; 4 | 5 | 6 | export const taskList = new FirebaseList({ 7 | onAdd: taskActions.createTaskFulfilled, 8 | onChange: taskActions.updateTaskFulfilled, 9 | onLoad: taskActions.loadTasksFulfilled, 10 | onRemove: taskActions.removeTaskFulfilled 11 | }, Task); 12 | -------------------------------------------------------------------------------- /src/tasks/task.js: -------------------------------------------------------------------------------- 1 | import { Record } from 'immutable'; 2 | 3 | 4 | export const Task = new Record({ 5 | completed: false, 6 | key: null, 7 | title: null 8 | }); 9 | -------------------------------------------------------------------------------- /src/views/app/app.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { connect } from 'react-redux'; 4 | import { withRouter } from 'react-router-dom'; 5 | 6 | import { authActions, getAuth } from 'src/auth'; 7 | import Header from '../components/header'; 8 | import RequireAuthRoute from '../components/require-auth-route'; 9 | import RequireUnauthRoute from '../components/require-unauth-route'; 10 | import SignInPage from '../pages/sign-in'; 11 | import TasksPage from '../pages/tasks'; 12 | 13 | 14 | const App = ({authenticated, signOut}) => ( 15 |
16 |
20 | 21 |
22 | 23 | 24 |
25 |
26 | ); 27 | 28 | App.propTypes = { 29 | authenticated: PropTypes.bool.isRequired, 30 | signOut: PropTypes.func.isRequired 31 | }; 32 | 33 | 34 | //===================================== 35 | // CONNECT 36 | //------------------------------------- 37 | 38 | const mapStateToProps = getAuth; 39 | 40 | const mapDispatchToProps = { 41 | signOut: authActions.signOut 42 | }; 43 | 44 | export default withRouter( 45 | connect( 46 | mapStateToProps, 47 | mapDispatchToProps 48 | )(App) 49 | ); 50 | -------------------------------------------------------------------------------- /src/views/app/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './app'; 2 | -------------------------------------------------------------------------------- /src/views/components/button/button.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classNames from 'classnames'; 3 | import PropTypes from 'prop-types'; 4 | 5 | import './button.css'; 6 | 7 | 8 | const Button = ({children, className, onClick, type = 'button'}) => { 9 | const cssClasses = classNames('btn', className); 10 | return ( 11 | 14 | ); 15 | }; 16 | 17 | Button.propTypes = { 18 | children: PropTypes.node, 19 | className: PropTypes.string, 20 | onClick: PropTypes.func, 21 | type: PropTypes.oneOf(['button', 'reset', 'submit']) 22 | }; 23 | 24 | 25 | export default Button; 26 | -------------------------------------------------------------------------------- /src/views/components/button/button.scss: -------------------------------------------------------------------------------- 1 | @import 'views/styles/shared'; 2 | 3 | 4 | .btn { 5 | @include button-base; 6 | outline: none; 7 | border: 0; 8 | padding: 0; 9 | overflow: hidden; 10 | transform: translate(0, 0); 11 | background: transparent; 12 | } 13 | 14 | .btn--icon { 15 | border-radius: 40px; 16 | padding: 8px; 17 | width: 40px; 18 | height: 40px; 19 | } 20 | -------------------------------------------------------------------------------- /src/views/components/button/button.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, shallow } from 'enzyme'; 3 | import Button from './button'; 4 | 5 | 6 | describe('Button', () => { 7 | it('should render a button with text node', () => { 8 | const wrapper = render(); 9 | const button = wrapper.find('button'); 10 | 11 | expect(button.length).toBe(1); 12 | expect(button.text()).toBe('Foo'); 13 | }); 14 | 15 | it('should render a button with child element', () => { 16 | const wrapper = shallow(); 17 | const button = wrapper.find('button'); 18 | 19 | expect(button.length).toBe(1); 20 | expect(button.contains(Foo)).toBe(true); 21 | }); 22 | 23 | it('should set default className', () => { 24 | const wrapper = render( : null} 17 |
  • 18 | 19 | 20 | 21 |
  • 22 | 23 | 24 | 25 | 26 | ); 27 | 28 | Header.propTypes = { 29 | authenticated: PropTypes.bool.isRequired, 30 | signOut: PropTypes.func.isRequired 31 | }; 32 | 33 | 34 | export default Header; 35 | -------------------------------------------------------------------------------- /src/views/components/header/header.scss: -------------------------------------------------------------------------------- 1 | @import 'views/styles/shared'; 2 | 3 | 4 | .header { 5 | padding: 10px 0; 6 | height: 60px; 7 | overflow: hidden; 8 | line-height: 40px; 9 | } 10 | 11 | .header__title { 12 | float: left; 13 | font-size: rem(14px); 14 | font-weight: 400; 15 | text-rendering: auto; 16 | transform: translate(0,0); 17 | 18 | &:before { 19 | padding-right: 5px; 20 | color: #fff; 21 | line-height: 20px; 22 | } 23 | } 24 | 25 | .header__actions { 26 | @include clearfix; 27 | float: right; 28 | padding: 8px 0; 29 | line-height: 24px; 30 | 31 | li { 32 | float: left; 33 | list-style: none; 34 | 35 | &:last-child { 36 | margin-left: 12px; 37 | padding-left: 12px; 38 | border-left: 1px solid #333; 39 | } 40 | 41 | &:first-child { 42 | border: none; 43 | } 44 | } 45 | 46 | .btn { 47 | display: block; 48 | margin: 0; 49 | color: #999; 50 | font-size: rem(14px); 51 | line-height: 24px; 52 | } 53 | 54 | .link { 55 | display: block; 56 | fill: #98999a; 57 | transform: translate(0, 0); 58 | } 59 | 60 | .link--github { 61 | padding-top: 1px; 62 | width: 22px; 63 | height: 24px; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/views/components/header/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './header'; 2 | -------------------------------------------------------------------------------- /src/views/components/icon/icon.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classNames from 'classnames'; 3 | import PropTypes from 'prop-types'; 4 | 5 | 6 | const Icon = ({className, name}) => { 7 | const cssClasses = classNames('material-icons', className); 8 | return {name}; 9 | }; 10 | 11 | Icon.propTypes = { 12 | className: PropTypes.string, 13 | name: PropTypes.string.isRequired 14 | }; 15 | 16 | 17 | export default Icon; 18 | -------------------------------------------------------------------------------- /src/views/components/icon/icon.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, shallow } from 'enzyme'; 3 | import Icon from './icon'; 4 | 5 | 6 | describe('Icon', () => { 7 | it('should render an icon', () => { 8 | const wrapper = shallow(); 9 | expect(wrapper.contains(play)).toBe(true); 10 | }); 11 | 12 | it('should add provided props.className', () => { 13 | const wrapper = render(); 14 | const icon = wrapper.find('span'); 15 | 16 | expect(icon.hasClass('material-icons foo bar')).toBe(true); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/views/components/icon/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './icon'; 2 | -------------------------------------------------------------------------------- /src/views/components/require-auth-route/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './require-auth-route'; 2 | -------------------------------------------------------------------------------- /src/views/components/require-auth-route/require-auth-route.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Route, Redirect } from 'react-router-dom' 3 | 4 | 5 | const RequireAuthRoute = ({component: Component, authenticated, ...rest}) => ( 6 | { 9 | return authenticated ? ( 10 | 11 | ) : ( 12 | 16 | ) 17 | }} 18 | /> 19 | ); 20 | 21 | 22 | export default RequireAuthRoute; 23 | -------------------------------------------------------------------------------- /src/views/components/require-unauth-route/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './require-unauth-route'; 2 | -------------------------------------------------------------------------------- /src/views/components/require-unauth-route/require-unauth-route.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Route, Redirect } from 'react-router-dom' 3 | 4 | 5 | const RequireUnauthRoute = ({component: Component, authenticated, ...rest}) => ( 6 | { 9 | return authenticated ? ( 10 | 14 | ) : ( 15 | 16 | ) 17 | }} 18 | /> 19 | ); 20 | 21 | 22 | export default RequireUnauthRoute; 23 | -------------------------------------------------------------------------------- /src/views/components/task-filters/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './task-filters'; 2 | -------------------------------------------------------------------------------- /src/views/components/task-filters/task-filters.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { NavLink } from 'react-router-dom'; 4 | 5 | import './task-filters.css'; 6 | 7 | 8 | const TaskFilters = ({filter}) => ( 9 |
      10 |
    • !filter} to="/">View All
    • 11 |
    • filter === 'active'} to={{pathname: '/', search: '?filter=active'}}>Active
    • 12 |
    • filter === 'completed'} to={{pathname: '/', search: '?filter=completed'}}>Completed
    • 13 |
    14 | ); 15 | 16 | TaskFilters.propTypes = { 17 | filter: PropTypes.string 18 | }; 19 | 20 | 21 | export default TaskFilters; 22 | -------------------------------------------------------------------------------- /src/views/components/task-filters/task-filters.scss: -------------------------------------------------------------------------------- 1 | @import 'views/styles/shared'; 2 | 3 | 4 | .task-filters { 5 | @include clearfix; 6 | margin-bottom: 45px; 7 | padding-left: 1px; 8 | font-size: rem(16px); 9 | line-height: 24px; 10 | list-style-type: none; 11 | 12 | @include media-query(540) { 13 | margin-bottom: 55px; 14 | } 15 | 16 | li { 17 | float: left; 18 | 19 | &:not(:first-child) { 20 | margin-left: 12px; 21 | } 22 | 23 | &:not(:first-child):before { 24 | padding-right: 12px; 25 | content: '/'; 26 | font-weight: 300; 27 | } 28 | } 29 | 30 | a { 31 | color: #999; 32 | text-decoration: none; 33 | 34 | &.active { 35 | color: #fff; 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/views/components/task-form/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './task-form'; 2 | -------------------------------------------------------------------------------- /src/views/components/task-form/task-form.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import './task-form.css'; 5 | 6 | 7 | export class TaskForm extends Component { 8 | static propTypes = { 9 | handleSubmit: PropTypes.func.isRequired 10 | }; 11 | 12 | constructor() { 13 | super(...arguments); 14 | 15 | this.state = {title: ''}; 16 | 17 | this.handleChange = this.handleChange.bind(this); 18 | this.handleKeyUp = this.handleKeyUp.bind(this); 19 | this.handleSubmit = this.handleSubmit.bind(this); 20 | } 21 | 22 | clearInput() { 23 | this.setState({title: ''}); 24 | } 25 | 26 | handleChange(event) { 27 | this.setState({title: event.target.value}); 28 | } 29 | 30 | handleKeyUp(event) { 31 | if (event.keyCode === 27) this.clearInput(); 32 | } 33 | 34 | handleSubmit(event) { 35 | event.preventDefault(); 36 | const title = this.state.title.trim(); 37 | if (title.length) this.props.handleSubmit(title); 38 | this.clearInput(); 39 | } 40 | 41 | render() { 42 | return ( 43 |
    44 | 55 |
    56 | ); 57 | } 58 | } 59 | 60 | 61 | export default TaskForm; 62 | -------------------------------------------------------------------------------- /src/views/components/task-form/task-form.scss: -------------------------------------------------------------------------------- 1 | @import 'views/styles/shared'; 2 | 3 | 4 | .task-form { 5 | margin: 40px 0 10px; 6 | 7 | @include media-query(540) { 8 | margin: 80px 0 20px; 9 | } 10 | } 11 | 12 | .task-form__input { 13 | outline: none; 14 | border: 0; 15 | border-bottom: 1px dotted #666; 16 | border-radius: 0; 17 | padding: 0 0 5px 0; 18 | width: 100%; 19 | height: 50px; 20 | font-family: inherit; 21 | font-size: rem(24px); 22 | font-weight: 300; 23 | color: #fff; 24 | background: transparent; 25 | 26 | @include media-query(540) { 27 | height: 61px; 28 | font-size: rem(32px); 29 | } 30 | 31 | &::placeholder { 32 | color: #999; 33 | opacity: 1; // firefox native placeholder style has opacity < 1 34 | } 35 | 36 | &:focus::placeholder { 37 | color: #777; 38 | opacity: 1; 39 | } 40 | 41 | // webkit input doesn't inherit font-smoothing from ancestors 42 | -webkit-font-smoothing: antialiased; 43 | 44 | // remove `x` 45 | &::-ms-clear { 46 | display: none; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/views/components/task-item/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './task-item'; 2 | -------------------------------------------------------------------------------- /src/views/components/task-item/task-item.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import classNames from 'classnames'; 3 | import PropTypes from 'prop-types'; 4 | import Button from '../button'; 5 | import Icon from '../icon'; 6 | 7 | import './task-item.css'; 8 | 9 | 10 | export class TaskItem extends Component { 11 | constructor() { 12 | super(...arguments); 13 | 14 | this.state = {editing: false}; 15 | 16 | this.edit = this.edit.bind(this); 17 | this.handleKeyUp = this.handleKeyUp.bind(this); 18 | this.remove = this.remove.bind(this); 19 | this.save = this.save.bind(this); 20 | this.stopEditing = this.stopEditing.bind(this); 21 | this.toggleStatus = this.toggleStatus.bind(this); 22 | } 23 | 24 | edit() { 25 | this.setState({editing: true}); 26 | } 27 | 28 | handleKeyUp(event) { 29 | if (event.keyCode === 13) { 30 | this.save(event); 31 | } 32 | else if (event.keyCode === 27) { 33 | this.stopEditing(); 34 | } 35 | } 36 | 37 | remove() { 38 | this.props.removeTask(this.props.task); 39 | } 40 | 41 | save(event) { 42 | if (this.state.editing) { 43 | const { task } = this.props; 44 | const title = event.target.value.trim(); 45 | 46 | if (title.length && title !== task.title) { 47 | this.props.updateTask(task, {title}); 48 | } 49 | 50 | this.stopEditing(); 51 | } 52 | } 53 | 54 | stopEditing() { 55 | this.setState({editing: false}); 56 | } 57 | 58 | toggleStatus() { 59 | const { task } = this.props; 60 | this.props.updateTask(task, {completed: !task.completed}); 61 | } 62 | 63 | renderTitle(task) { 64 | return ( 65 |
    66 | {task.title} 67 |
    68 | ); 69 | } 70 | 71 | renderTitleInput(task) { 72 | return ( 73 | 82 | ); 83 | } 84 | 85 | render() { 86 | const { editing } = this.state; 87 | const { task } = this.props; 88 | 89 | let containerClasses = classNames('task-item', { 90 | 'task-item--completed': task.completed, 91 | 'task-item--editing': editing 92 | }); 93 | 94 | return ( 95 |
    96 |
    97 | 102 |
    103 | 104 |
    105 | {editing ? this.renderTitleInput(task) : this.renderTitle(task)} 106 |
    107 | 108 |
    109 | 114 | 119 | 124 |
    125 |
    126 | ); 127 | } 128 | } 129 | 130 | TaskItem.propTypes = { 131 | removeTask: PropTypes.func.isRequired, 132 | task: PropTypes.object.isRequired, 133 | updateTask: PropTypes.func.isRequired 134 | }; 135 | 136 | 137 | export default TaskItem; 138 | -------------------------------------------------------------------------------- /src/views/components/task-item/task-item.scss: -------------------------------------------------------------------------------- 1 | @import 'views/styles/shared'; 2 | 3 | 4 | .task-item { 5 | display: flex; 6 | outline: none; 7 | border-bottom: 1px dotted #666; 8 | height: 60px; 9 | overflow: hidden; 10 | color: #fff; 11 | font-size: rem(18px); 12 | font-weight: 300; 13 | 14 | @include media-query(540) { 15 | font-size: rem(24px); 16 | } 17 | } 18 | 19 | .task-item--editing { 20 | border-bottom: 1px dotted #ccc; 21 | } 22 | 23 | 24 | //===================================== 25 | // Cells 26 | //------------------------------------- 27 | .cell { 28 | &:first-child, 29 | &:last-child { 30 | display: flex; 31 | flex: 0 0 auto; 32 | align-items: center; 33 | } 34 | 35 | &:first-child { 36 | padding-right: 20px; 37 | } 38 | 39 | &:nth-child(2) { 40 | flex: 1; 41 | padding-right: 30px; 42 | overflow: hidden; 43 | } 44 | } 45 | 46 | 47 | //===================================== 48 | // Buttons 49 | //------------------------------------- 50 | .task-item__button { 51 | margin-left: 5px; 52 | background: #2a2a2a; 53 | 54 | &:first-child { 55 | margin: 0; 56 | } 57 | 58 | color: #555; 59 | 60 | &:hover { 61 | color: #999; 62 | } 63 | 64 | &:active { 65 | background: #262626; 66 | } 67 | 68 | &.active { 69 | color: #85bf6b; 70 | } 71 | } 72 | 73 | 74 | //===================================== 75 | // Title (static) 76 | //------------------------------------- 77 | .task-item__title { 78 | display: inline-block; 79 | position: relative; 80 | max-width: 100%; 81 | line-height: 60px; 82 | outline: none; 83 | overflow: hidden; 84 | text-overflow: ellipsis; 85 | white-space: nowrap; 86 | 87 | &:after { 88 | position: absolute; 89 | left: 0; 90 | bottom: 0; 91 | border-top: 2px solid #85bf6b; 92 | width: 0; 93 | height: 46%; 94 | content: ''; 95 | } 96 | 97 | .task-item--completed & { 98 | color: #666; 99 | } 100 | 101 | .task-item--completed &:after { 102 | width: 100%; 103 | } 104 | } 105 | 106 | 107 | //===================================== 108 | // Title (input) 109 | //------------------------------------- 110 | .task-item__input { 111 | outline: none; 112 | border: 0; 113 | padding: 0; 114 | width: 100%; 115 | height: 60px; 116 | color: inherit; 117 | font: inherit; 118 | background: transparent; 119 | 120 | // hide `x` 121 | &::-ms-clear { 122 | display: none; 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/views/components/task-list/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './task-list'; 2 | -------------------------------------------------------------------------------- /src/views/components/task-list/task-list.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { List } from 'immutable'; 3 | import PropTypes from 'prop-types'; 4 | import TaskItem from '../task-item'; 5 | 6 | import './task-list.css'; 7 | 8 | 9 | const TaskList = ({removeTask, tasks, updateTask}) => { 10 | let taskItems = tasks.map((task, index) => { 11 | return ( 12 | 18 | ); 19 | }); 20 | 21 | return ( 22 |
    23 | {taskItems} 24 |
    25 | ); 26 | }; 27 | 28 | TaskList.propTypes = { 29 | removeTask: PropTypes.func.isRequired, 30 | tasks: PropTypes.instanceOf(List), 31 | updateTask: PropTypes.func.isRequired 32 | }; 33 | 34 | 35 | export default TaskList; 36 | -------------------------------------------------------------------------------- /src/views/components/task-list/task-list.scss: -------------------------------------------------------------------------------- 1 | .task-list { 2 | border-top: 1px dotted #666; 3 | } 4 | -------------------------------------------------------------------------------- /src/views/pages/sign-in/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './sign-in-page'; 2 | -------------------------------------------------------------------------------- /src/views/pages/sign-in/sign-in-page.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { connect } from 'react-redux'; 4 | import { withRouter } from 'react-router-dom'; 5 | import { authActions } from 'src/auth'; 6 | import Button from 'src/views/components/button'; 7 | 8 | import './sign-in-page.css'; 9 | 10 | 11 | const SignInPage = ({signInWithGithub, signInWithGoogle, signInWithTwitter}) => { 12 | return ( 13 |
    14 |
    15 |

    Sign in

    16 | 17 | 18 | 19 |
    20 |
    21 | ); 22 | }; 23 | 24 | SignInPage.propTypes = { 25 | signInWithGithub: PropTypes.func.isRequired, 26 | signInWithGoogle: PropTypes.func.isRequired, 27 | signInWithTwitter: PropTypes.func.isRequired 28 | }; 29 | 30 | 31 | //===================================== 32 | // CONNECT 33 | //------------------------------------- 34 | 35 | const mapDispatchToProps = { 36 | signInWithGithub: authActions.signInWithGithub, 37 | signInWithGoogle: authActions.signInWithGoogle, 38 | signInWithTwitter: authActions.signInWithTwitter 39 | }; 40 | 41 | export default withRouter( 42 | connect( 43 | null, 44 | mapDispatchToProps 45 | )(SignInPage) 46 | ); 47 | -------------------------------------------------------------------------------- /src/views/pages/sign-in/sign-in-page.scss: -------------------------------------------------------------------------------- 1 | @import 'views/styles/shared'; 2 | 3 | 4 | .sign-in { 5 | margin-top: 90px; 6 | max-width: 300px; 7 | } 8 | 9 | .sign-in__heading { 10 | margin-bottom: 36px; 11 | font-size: 30px; 12 | font-weight: 300; 13 | text-align: center; 14 | } 15 | 16 | .sign-in__button { 17 | margin-bottom: 10px; 18 | border: 1px solid #555; 19 | width: 100%; 20 | height: 48px; 21 | font-family: inherit; 22 | font-size: rem(18px); 23 | line-height: 48px; 24 | color: #999; 25 | 26 | &:hover { 27 | border: 2px solid #aaa; 28 | line-height: 46px; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/views/pages/tasks/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './tasks-page'; 2 | -------------------------------------------------------------------------------- /src/views/pages/tasks/tasks-page.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { List } from 'immutable'; 3 | import PropTypes from 'prop-types'; 4 | import { connect } from 'react-redux'; 5 | import { withRouter } from 'react-router-dom'; 6 | import { taskActions, getVisibleTasks } from 'src/tasks'; 7 | import TaskFilters from 'src/views/components/task-filters'; 8 | import TaskForm from 'src/views/components/task-form'; 9 | import TaskList from 'src/views/components/task-list'; 10 | 11 | 12 | const TasksPage = ({createTask, location, removeTask, tasks, updateTask}) => { 13 | const params = new URLSearchParams(location.search); 14 | const filter = params.get('filter'); 15 | 16 | return ( 17 |
    18 |
    19 | 20 |
    21 | 22 |
    23 | 24 | 30 |
    31 |
    32 | ); 33 | }; 34 | 35 | TasksPage.propTypes = { 36 | createTask: PropTypes.func.isRequired, 37 | filterTasks: PropTypes.func.isRequired, 38 | location: PropTypes.object.isRequired, 39 | removeTask: PropTypes.func.isRequired, 40 | tasks: PropTypes.instanceOf(List), 41 | updateTask: PropTypes.func.isRequired 42 | }; 43 | 44 | 45 | //===================================== 46 | // CONNECT 47 | //------------------------------------- 48 | 49 | const mapStateToProps = state => ({ 50 | tasks: getVisibleTasks(state) 51 | }); 52 | 53 | const mapDispatchToProps = { 54 | createTask: taskActions.createTask, 55 | filterTasks: taskActions.filterTasks, 56 | removeTask: taskActions.removeTask, 57 | updateTask: taskActions.updateTask 58 | }; 59 | 60 | export default withRouter( 61 | connect( 62 | mapStateToProps, 63 | mapDispatchToProps 64 | )(TasksPage) 65 | ); 66 | -------------------------------------------------------------------------------- /src/views/styles/_grid.scss: -------------------------------------------------------------------------------- 1 | .g-row { 2 | @include grid-row; 3 | } 4 | 5 | .g-col { 6 | @include grid-column; 7 | width: 100%; 8 | } 9 | -------------------------------------------------------------------------------- /src/views/styles/_settings.scss: -------------------------------------------------------------------------------- 1 | $base-background-color: #222 !default; 2 | $base-font-color: #999 !default; 3 | $base-font-family: 'aktiv-grotesk-std', Helvetica Neue, Arial, sans-serif !default; 4 | $base-font-size: 18px !default; 5 | $base-line-height: 24px !default; 6 | 7 | 8 | //===================================== 9 | // Grid 10 | //------------------------------------- 11 | $grid-max-width: 810px !default; 12 | -------------------------------------------------------------------------------- /src/views/styles/_shared.scss: -------------------------------------------------------------------------------- 1 | @import 2 | './settings', 3 | 'minx/src/settings', 4 | 'minx/src/functions', 5 | 'minx/src/mixins'; 6 | -------------------------------------------------------------------------------- /src/views/styles/styles.scss: -------------------------------------------------------------------------------- 1 | @import 2 | './shared', 3 | 'minx/src/reset', 4 | 'minx/src/elements', 5 | './grid'; 6 | 7 | 8 | html { 9 | overflow-y: scroll; 10 | } 11 | 12 | body { 13 | padding-bottom: 120px; 14 | } 15 | 16 | a { 17 | color: inherit; 18 | text-decoration: none; 19 | } 20 | 21 | ::selection { 22 | background: rgba(200,200,255,.1); 23 | } 24 | 25 | .hide { 26 | display: none !important; 27 | } 28 | --------------------------------------------------------------------------------