diff --git a/app/components/toasts.js b/app/components/toasts.js new file mode 100644 index 0000000..8b37c56 --- /dev/null +++ b/app/components/toasts.js @@ -0,0 +1,17 @@ +import {Toast} from "cx/widgets"; + +export function toast(options) { + Toast + .create({ + timeout: 3000, + ...options, + }) + .open(); +} + +export function showErrorToast(err) { + toast({ + message: String(err), + mod: 'error' + }) +} \ No newline at end of file diff --git a/app/data/db/auth.js b/app/data/db/auth.js new file mode 100644 index 0000000..c18bc66 --- /dev/null +++ b/app/data/db/auth.js @@ -0,0 +1,4 @@ +import "firebase/auth"; +import { firebase } from "./firebase"; + +export const auth = firebase.auth(); \ No newline at end of file diff --git a/app/data/db/config.js b/app/data/db/config.js new file mode 100644 index 0000000..8269740 --- /dev/null +++ b/app/data/db/config.js @@ -0,0 +1,8 @@ +export default { + apiKey: "AIzaSyCx3Mhxvx49iE9DafqLfVrM-D3ZnvmqaPk", + authDomain: "tdo-tasks.firebaseapp.com", + databaseURL: "https://tdo-tasks.firebaseio.com", + projectId: "tdo-tasks", + storageBucket: "tdo-tasks.appspot.com", + messagingSenderId: "923290013261" +}; diff --git a/app/data/db/firebase.js b/app/data/db/firebase.js new file mode 100644 index 0000000..49e775e --- /dev/null +++ b/app/data/db/firebase.js @@ -0,0 +1,11 @@ +import * as firebase from "firebase"; + +import config from "./config"; + +firebase.initializeApp(config); + +export { + firebase +} + + diff --git a/app/data/db/firestore.js b/app/data/db/firestore.js new file mode 100644 index 0000000..e08d8a6 --- /dev/null +++ b/app/data/db/firestore.js @@ -0,0 +1,13 @@ +import "firebase/firestore"; + +import { firebase } from "./firebase"; + +const settings = { + timestampsInSnapshots: true +}; + +export const firestore = firebase.firestore(); + +firestore.settings(settings); + + diff --git a/app/data/middleware/boardNavigation.js b/app/data/middleware/boardNavigation.js index 072a0a2..f9a3889 100644 --- a/app/data/middleware/boardNavigation.js +++ b/app/data/middleware/boardNavigation.js @@ -1,17 +1,18 @@ import { GOTO_BOARD, GOTO_ANY_BOARD } from '../actions'; +import {History} from "cx/ui"; export default store => next => action => { switch (action.type) { case GOTO_BOARD: - window.location.hash = '#' + action.id; + History.pushState({}, null, `~/b/${action.id}`); return; case GOTO_ANY_BOARD: if (action.forced || (window.location.hash || '#') == '#') { - var {tdo} = store.getState(); + const {tdo} = store.getState(); if (tdo.boards.length > 0) - window.location.hash = '#' + tdo.boards[0].id; + History.pushState({}, null, `~/b/${tdo.boards[0].id}`); } return; diff --git a/app/data/reducers/index.js b/app/data/reducers/index.js index c55391b..928ab1c 100644 --- a/app/data/reducers/index.js +++ b/app/data/reducers/index.js @@ -5,10 +5,11 @@ import dummy from './dummyReducer'; export default combineReducers({ tdo, - hash: dummy('#'), + url: dummy(''), search: dummy({}), pages: dummy({}), - layout: dummy({}) + layout: dummy({}), + user: dummy({}), }); diff --git a/app/data/reducers/tdo/boards/index.js b/app/data/reducers/tdo/boards/index.js index a29ebdf..8e6cff0 100644 --- a/app/data/reducers/tdo/boards/index.js +++ b/app/data/reducers/tdo/boards/index.js @@ -1,7 +1,7 @@ import { append, updateArray } from 'cx/data'; import { ADD_BOARD, REMOVE_BOARD } from '../../../actions'; -export default function(state = [], action) { +export default function (state = [], action) { switch (action.type) { case 'ADD_BOARD': return append(state, action.data); @@ -13,7 +13,7 @@ export default function(state = [], action) { deleted: true, deletedDate: new Date().toISOString() }), - b=>b.id == action.id); + b => b.id == action.id); default: return state; diff --git a/app/index.js b/app/index.js index 0fdc231..c2c7277 100644 --- a/app/index.js +++ b/app/index.js @@ -1,103 +1,77 @@ -import { startAppLoop, Widget, FocusManager } from 'cx/ui'; -import { Debug } from 'cx/util'; -import {createStore, ReduxStoreView} from 'cx-redux'; -import { applyMiddleware } from 'redux'; -import Routes from './routes'; -import './index.scss'; -import reducer from './data//reducers'; -import middleware from './data/middleware'; +import { startHotAppLoop, History, FocusManager } from "cx/ui"; +import { Debug } from "cx/util"; +import { createStore, ReduxStoreView } from "cx-redux"; +import { applyMiddleware } from "redux"; +import Routes from "./routes"; +import "./index.scss"; +import reducer from "./data/reducers"; +import middleware from "./data/middleware"; +import { Store } from "cx/data"; -const reduxStore = createStore( - reducer, - applyMiddleware(...middleware) -); - -const store = new ReduxStoreView(reduxStore); - -var stop; -if (module.hot) { - // accept itself - module.hot.accept(); - - // remember data on dispose - module.hot.dispose(function (data) { - data.state = store.getData(); - if (stop) - stop(); - }); +const reduxStore = createStore(reducer, applyMiddleware(...middleware)); - // apply data on hot replace - if (module.hot.data) - store.load(module.hot.data.state); -} +const store = new Store(); -function updateHash() { - store.set('hash', window.location.hash || '#') -} - -updateHash(); -setInterval(updateHash, 100); +History.connect( + store, + "url" +); -Widget.resetCounter(); //preserve React keys -Debug.enable('app-data'); +Debug.enable("app-data"); -stop = startAppLoop(document.getElementById('app'), store, Routes); +startHotAppLoop(module, document.getElementById("app"), store, Routes); // is there a better way to do this -document.body.addEventListener('keyup', e => { +document.body.addEventListener("keyup", e => { + switch (e.key) { + case "?": + if (e.target.tagName != "INPUT" && e.target.tagName != "TEXTAREA") { + e.preventDefault(); + e.stopPropagation(); + window.location.hash = "#help"; + } + break; - switch (e.key) { - case '?': - if (e.target.tagName != 'INPUT' && e.target.tagName != 'TEXTAREA') { - e.preventDefault(); - e.stopPropagation(); - window.location.hash = '#help'; - } - break; + case "Escape": + if (!document.activeElement.classList.contains("cxb-task")) { + e.preventDefault(); + e.stopPropagation(); + var els = document.getElementsByClassName("cxb-task"); + if (els && els.length > 0) FocusManager.focusFirst(els[0]); + } + break; - case 'Escape': - if (!document.activeElement.classList.contains('cxb-task')) { - e.preventDefault(); - e.stopPropagation(); - var els = document.getElementsByClassName('cxb-task'); - if (els && els.length > 0) - FocusManager.focusFirst(els[0]); - } - break; + case "/": + if (e.target.tagName != "INPUT" && e.target.tagName != "TEXTAREA") { + e.preventDefault(); + e.stopPropagation(); + var el = document.getElementById("search"); + FocusManager.focusFirst(el); + } + break; - case '/': - if (e.target.tagName != 'INPUT' && e.target.tagName != 'TEXTAREA') { - e.preventDefault(); - e.stopPropagation(); - var el = document.getElementById('search'); - FocusManager.focusFirst(el); - } - break; - - case '{': - { - if(!e.ctrlKey) break; - let {hash, tdo} = store.getData(); - hash = hash.split('#').join(''); - var boardInd = tdo.boards.findIndex(a=>a.id == hash); - if(boardInd == -1) break; - var prevInd = (boardInd - 1); - if(prevInd < 0) prevInd = tdo.boards.length - 1; - var nextBoard = tdo.boards[prevInd]; - window.location = '#' + nextBoard.id; - break; - } + case "{": { + if (!e.ctrlKey) break; + let { hash, tdo } = store.getData(); + hash = hash.split("#").join(""); + var boardInd = tdo.boards.findIndex(a => a.id == hash); + if (boardInd == -1) break; + var prevInd = boardInd - 1; + if (prevInd < 0) prevInd = tdo.boards.length - 1; + var nextBoard = tdo.boards[prevInd]; + window.location = "#" + nextBoard.id; + break; + } - case '}': - { - if(!e.ctrlKey) break; - let {hash, tdo} = store.getData(); - hash = hash.split('#').join(''); - var boardInd = tdo.boards.findIndex(a=>a.id == hash); - if(boardInd == -1) break; - var nextBoard = tdo.boards[(boardInd + 1) % tdo.boards.length]; - window.location = '#' + nextBoard.id; - break; - } + case "}": { + if (!e.ctrlKey) break; + let { hash, tdo } = store.getData(); + hash = hash.split("#").join(""); + var boardInd = tdo.boards.findIndex(a => a.id == hash); + if (boardInd == -1) break; + var nextBoard = tdo.boards[(boardInd + 1) % tdo.boards.length]; + window.location = "#" + nextBoard.id; + break; } + } }); diff --git a/app/routes/Controller.js b/app/routes/Controller.js index 83ef672..e661753 100644 --- a/app/routes/Controller.js +++ b/app/routes/Controller.js @@ -1,17 +1,101 @@ -import { Controller } from 'cx/ui'; -import { append } from 'cx/data'; +import { Controller, History } from 'cx/ui'; import uid from 'uid'; -import { loadData, addBoard, gotoBoard } from '../data/actions'; +import { firestore } from "../data/db/firestore"; +import { auth } from "../data/db/auth"; +import { isNonEmptyArray } from "cx/util"; + +//TODO: For anonymous users save to local storage export default class extends Controller { - init() { - super.init(); + onInit() { + this.store.set('layout.mode', this.getLayoutMode()); + + auth.onAuthStateChanged(user => { + if (user) { + this.store.set( + "user", + { + email: user.email, + displayName: user.displayName, + photoURL: user.photoURL, + id: user.uid + } + ); + } + else { + let userId = localStorage.getItem('anonymousUserId'); + if (!userId) { + userId = uid(); + localStorage.setItem('anonymousUserId', userId); + console.warn('Creating anonymous user', userId); + } + this.store.set('user', { + id: userId, + name: 'Anonymous', + anonymous: true + }) + } + }); + + this.store.init('settings', { + completedTasksRetentionDays: 1, + deleteCompletedTasks: true, + deleteCompletedTasksAfterDays: 7, + purgeDeletedObjectsAfterDays: 3, + taskStyles: [{ + regex: '!important', + style: 'color: orange' + }, { + regex: '#idea', + style: 'color: yellow' + }] + }); + + this.addTrigger('boardLoader', ['user.id'], userId => { + + if (!userId) + return; + + //clean up + this.onDestroy(); + + this.unsubscribeBoards = firestore + .collection('users') + .doc(userId) + .collection('boards') + .onSnapshot(snapshot => { + let boards = []; - this.store.set('layout.mode', this.getLayoutMode()) + snapshot.forEach(doc => { + boards.push(doc.data()); + }); - this.store.dispatch( - loadData() - ); + this.store.set('boards', boards); + + if (!isNonEmptyArray(boards)) { + //TODO: Ask the user to create the Welcome board + } + else if (this.store.get('url') == "~/") + History.pushState({}, null, "~/b/" + boards[0].id); + }); + + this.unsubscribeSettings = firestore + .collection('users') + .doc(userId) + .onSnapshot(doc => { + let data = doc.exists ? doc.data() : {}; + this.store.update('settings', settings => ({ + ...settings, + ...data + })); + this.store.set('settingsLoaded', true); + }); + }, true); + } + + onDestroy() { + this.unsubscribeBoards && this.unsubscribeBoards(); + this.unsubscribeSettings && this.unsubscribeSettings(); } getLayoutMode() { @@ -24,21 +108,41 @@ export default class extends Controller { return 'phone'; } - addBoard(e) { + async addBoard(e) { e.preventDefault(); - var id = uid(); + let id = uid(); + let boards = this.store.get("boards"); + let maxValue = 0; + boards.filter(e => !e.deleted).map(e => e.order).forEach(e => { + if (e > maxValue) maxValue = e; + }); - this.store.dispatch( - addBoard({ + let p1 = firestore + .collection('boards') + .doc('id') + .set({ id: id, name: 'New Board', - edit: true - }) - ); + edit: true, + order: maxValue + 1 + }); + + let userId = this.store.get('user.id'); + let p2 = firestore + .collection('users') + .doc(userId) + .collection('boards') + .doc(id) + .set({ + id, + name: 'New Board', + edit: true, + order: maxValue + 1 + }); + + await Promise.all([p1, p2]); - this.store.dispatch( - gotoBoard(id) - ); + History.pushState({}, null, "~/b/" + id); } } diff --git a/app/routes/default/BoardEditor.js b/app/routes/board/BoardEditor.js similarity index 83% rename from app/routes/default/BoardEditor.js rename to app/routes/board/BoardEditor.js index 8fe10b6..35d632e 100644 --- a/app/routes/default/BoardEditor.js +++ b/app/routes/board/BoardEditor.js @@ -5,14 +5,14 @@ export default
-
+
@@ -21,7 +21,7 @@ export default