├── .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 | [](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();
25 | const button = wrapper.find('button');
26 |
27 | expect(button.hasClass('btn')).toBe(true);
28 | });
29 |
30 | it('should add provided props.className', () => {
31 | const wrapper = render();
32 | const button = wrapper.find('button');
33 |
34 | expect(button.hasClass('btn foo bar')).toBe(true);
35 | });
36 |
37 | it('should set type=button by default', () => {
38 | const wrapper = render();
39 | const button = wrapper.find('button');
40 |
41 | expect(button.attr('type')).toBe('button');
42 | });
43 |
44 | it('should set type with provided props.type', () => {
45 | const wrapper = render();
46 | const button = wrapper.find('button');
47 |
48 | expect(button.attr('type')).toBe('submit');
49 | });
50 |
51 | it('should set onClick with provided props.onClick', () => {
52 | const handleClick = jasmine.createSpy('handleClick');
53 | const wrapper = shallow();
54 |
55 | wrapper.simulate('click');
56 |
57 | expect(handleClick).toHaveBeenCalledTimes(1);
58 | });
59 | });
60 |
--------------------------------------------------------------------------------
/src/views/components/button/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './button';
2 |
--------------------------------------------------------------------------------
/src/views/components/github-logo/github-logo.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 |
4 | export default function GitHubLogo() {
5 | return (
6 |
9 | );
10 | }
11 |
--------------------------------------------------------------------------------
/src/views/components/github-logo/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './github-logo';
2 |
--------------------------------------------------------------------------------
/src/views/components/header/header.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import Button from '../button';
4 | import GitHubLogo from '../github-logo';
5 |
6 | import './header.css';
7 |
8 |
9 | const Header = ({authenticated, signOut}) => (
10 |
11 |
12 |
13 |
Todo Redux Saga
14 |
15 |
16 | {authenticated ? : 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 |
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 |
--------------------------------------------------------------------------------