├── burger.png ├── .firebaserc ├── public ├── static │ ├── offline-sprite.png │ ├── favicons │ │ ├── favicon.ico │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── apple-touch-icon.png │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-512x512.png │ │ └── safari-pinned-tab.svg │ ├── print.css │ ├── burger.svg │ ├── addtohomescreen.css │ └── addtohomescreen.min.js ├── manifest.json └── index.html ├── bin ├── deploy.sh ├── appcache.sh └── build.sh ├── src ├── index.css ├── App.test.js ├── index.js ├── heart.svg ├── Schema.js ├── Settings.js ├── App.css ├── Common.js ├── Store.js ├── Favorites.js ├── Days.js ├── Search.js ├── User.js ├── SignIn.js ├── Nav.js ├── Group.js ├── Day.js └── App.js ├── .gitignore ├── sw-precache-config.js ├── .editorconfig ├── README.md ├── database-rules.json ├── Makefile ├── package.json ├── firebase.json └── TODO /burger.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterbe/dinnerd/master/burger.png -------------------------------------------------------------------------------- /.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "dev": "dinnerd-dev", 4 | "prod": "dinnerd-45b97" 5 | } 6 | } -------------------------------------------------------------------------------- /public/static/offline-sprite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterbe/dinnerd/master/public/static/offline-sprite.png -------------------------------------------------------------------------------- /public/static/favicons/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterbe/dinnerd/master/public/static/favicons/favicon.ico -------------------------------------------------------------------------------- /public/static/favicons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterbe/dinnerd/master/public/static/favicons/favicon-16x16.png -------------------------------------------------------------------------------- /public/static/favicons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterbe/dinnerd/master/public/static/favicons/favicon-32x32.png -------------------------------------------------------------------------------- /public/static/favicons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterbe/dinnerd/master/public/static/favicons/apple-touch-icon.png -------------------------------------------------------------------------------- /bin/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -eo pipefail 3 | 4 | 5 | #firebase deploy 6 | firebase deploy --only database 7 | firebase deploy --only hosting 8 | -------------------------------------------------------------------------------- /public/static/favicons/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterbe/dinnerd/master/public/static/favicons/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/static/favicons/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterbe/dinnerd/master/public/static/favicons/android-chrome-512x512.png -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | /*body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: sans-serif; 5 | }*/ 6 | 7 | 8 | 9 | body, .smooth-container { scroll-behavior: smooth; } 10 | -------------------------------------------------------------------------------- /src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | 5 | it('renders without crashing', () => { 6 | const div = document.createElement('div'); 7 | ReactDOM.render(, div); 8 | }); 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | 6 | # testing 7 | coverage 8 | 9 | # production 10 | build 11 | 12 | # misc 13 | .DS_Store 14 | .env 15 | npm-debug.log 16 | firebase-debug.log 17 | -------------------------------------------------------------------------------- /sw-precache-config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | stripPrefix: 'build/', 3 | staticFileGlobs: [ 4 | 'build/*.html', 5 | 'build/manifest.json', 6 | 'build/static/**/!(*map|*~)', 7 | ], 8 | dontCacheBustUrlsMatching: /\.\w{8}\./, 9 | swFilePath: 'build/service-worker.js' 10 | }; 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.py] 12 | indent_style = space 13 | indent_size = 4 14 | 15 | [Makefile] 16 | indent_style = tab 17 | indent_size = 4 18 | -------------------------------------------------------------------------------- /bin/appcache.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -eo pipefail 3 | 4 | 5 | # "build/**/*.png" \ 6 | appcache-manifest \ 7 | "build/index.{html,css,js}" \ 8 | "build/**/*.{css,js}" \ 9 | "build/static/*.png" \ 10 | --network-star \ 11 | -o build/index.appcache 12 | 13 | echo "APPCACHE BUILT:" 14 | cat build/index.appcache 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Experimental App for planning and logging what you have for dinner. 2 | 3 | ### To run the first time: 4 | 5 | yarn install 6 | 7 | ### To start: 8 | 9 | yarn start 10 | 11 | 12 | ### Artwork 13 | 14 | Burger picture comes from 15 | https://www.iconfinder.com/icons/1760342/big_mac_burger_icon#size=512 16 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | import './index.css'; 5 | 6 | // if (process.env.NODE_ENV !== 'production') { 7 | // const {whyDidYouUpdate} = require('why-did-you-update') 8 | // whyDidYouUpdate(React) 9 | // } 10 | 11 | ReactDOM.render( 12 | , 13 | document.getElementById('root') 14 | ); 15 | -------------------------------------------------------------------------------- /database-rules.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "groups": { 4 | ".read": "auth != null", 5 | ".write": "auth != null", 6 | "$groupId": { 7 | "days": { 8 | ".indexOn": ["datetime"] 9 | } 10 | } 11 | }, 12 | "group-codes": { 13 | ".read": "auth != null", 14 | ".write": "auth != null" 15 | }, 16 | "user-groups": { 17 | ".read": "auth != null", 18 | ".write": "auth != null" 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build appcache deploy release 2 | 3 | help: 4 | @echo "The list of commands for local development:\n" 5 | @echo " build Build the static files" 6 | @echo " appcache Generate the appcache manifest" 7 | @echo " deploy Send build to Firebase" 8 | @echo " release All of the above\n" 9 | 10 | build: 11 | ./bin/build.sh 12 | 13 | appcache: 14 | ./bin/appcache.sh 15 | 16 | deploy: 17 | ./bin/deploy.sh 18 | 19 | 20 | release: build appcache deploy 21 | -------------------------------------------------------------------------------- /src/heart.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/static/print.css: -------------------------------------------------------------------------------- 1 | body, h2, h3, h4, h5 { 2 | font-family: cursive !important; 3 | } 4 | 5 | .day p.text, 6 | .day p.notes { 7 | text-align: center; 8 | } 9 | 10 | 11 | div.day, 12 | h3.week-head { 13 | margin-top: 0 !important; 14 | } 15 | 16 | .App-header, 17 | div.options, 18 | span.weekday-head-date, 19 | nav { 20 | display: none !important; 21 | } 22 | 23 | .day { 24 | padding-top: 30px !important; 25 | border-top: none !important; 26 | } 27 | 28 | .page-container { 29 | margin-top: 0 !important; 30 | } 31 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Dinnerd", 3 | "name": "Dinnerd", 4 | "icons": [ 5 | { 6 | "src": "/static/favicons/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/static/favicons/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#ffffff", 17 | "background_color": "#ffffff", 18 | "start_url": "./", 19 | "display": "standalone" 20 | } 21 | -------------------------------------------------------------------------------- /src/Schema.js: -------------------------------------------------------------------------------- 1 | import lf from 'lovefield' 2 | 3 | const getSchema = () => { 4 | let schema = lf.schema.create('dinnerd', 1) 5 | 6 | schema.createTable('Days') 7 | .addColumn('date', lf.Type.STRING) 8 | .addColumn('datetime', lf.Type.DATE_TIME) 9 | .addColumn('text', lf.Type.STRING) 10 | .addColumn('notes', lf.Type.STRING) 11 | .addColumn('starred', lf.Type.BOOLEAN) 12 | // .addColumn('modified', lf.Type.DATE_TIME) 13 | // .addColumn('userCreated', lf.Type.STRING) 14 | // .addColumn('userModified', lf.Type.STRING) 15 | .addPrimaryKey(['date']) 16 | 17 | return schema 18 | } 19 | 20 | export default getSchema 21 | -------------------------------------------------------------------------------- /bin/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -eo pipefail 3 | 4 | export REACT_APP_DEV=false 5 | 6 | export REACT_APP_FIREBASE_LOGGING=false 7 | 8 | # export REACT_APP_FIREBASE_API_KEY="AIzaSyAG0xEHwP1TrGSe6Jw0JmVWkSyhEDp-svw" 9 | # export REACT_APP_FIREBASE_AUTH_DOMAIN="dinnerd-dev.firebaseapp.com" 10 | # export REACT_APP_FIREBASE_DATABASE_URL="https://dinnerd-dev.firebaseio.com" 11 | # export REACT_APP_FIREBASE_STORAGE_BUCKET="dinnerd-dev.appspot.com" 12 | # export REACT_APP_FIREBASE_MESSAGING_SENDER_ID="378499526474" 13 | 14 | export REACT_APP_FIREBASE_API_KEY="AIzaSyAOs63MOvdKYRg7wJ6pTFxO6v96SrYHhhs" 15 | export REACT_APP_FIREBASE_AUTH_DOMAIN="dinnerd-45b97.firebaseapp.com" 16 | export REACT_APP_FIREBASE_DATABASE_URL="https://dinnerd-45b97.firebaseio.com" 17 | export REACT_APP_FIREBASE_STORAGE_BUCKET="dinnerd-45b97.appspot.com" 18 | export REACT_APP_FIREBASE_MESSAGING_SENDER_ID="501356720142" 19 | 20 | yarn run build 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dinnerd", 3 | "version": "0.1.0", 4 | "private": true, 5 | "devDependencies": { 6 | "appcache-manifest": "^2.1.0", 7 | "firebase-admin": "^5.0.0", 8 | "react-addons-perf": "^15.4.2", 9 | "react-scripts": "^1.0.10", 10 | "sw-precache": "^5.2.0", 11 | "why-did-you-update": "^0.0.8" 12 | }, 13 | "dependencies": { 14 | "date-fns": "^2.0.0-alpha.1", 15 | "elasticlunr": "^0.9.5", 16 | "firebase": "^4.1.3", 17 | "mobx": "^3.1.16", 18 | "mobx-react": "^4.2.2", 19 | "react": "^15.6.1", 20 | "react-addons-css-transition-group": "^15.6.0", 21 | "react-dom": "^15.6.1", 22 | "react-highlight-words": "^0.8.0", 23 | "react-linkify": "^0.2.1", 24 | "recompose": "^0.23.5", 25 | "zenscroll": "^4.0.0" 26 | }, 27 | "scripts": { 28 | "start": "react-scripts start", 29 | "build": "react-scripts build && sw-precache --config=sw-precache-config.js", 30 | "test": "react-scripts test --env=jsdom", 31 | "eject": "react-scripts eject" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "database": { 3 | "rules": "database-rules.json" 4 | }, 5 | "storage": { 6 | "rules": "storage.rules" 7 | }, 8 | "hosting": { 9 | "public": "./build", 10 | "ignore": [ 11 | "firebase.json", 12 | "database-rules.json", 13 | "storage.rules" 14 | ], 15 | "headers": [ 16 | { 17 | "source": "/index.appcache", 18 | "headers": [ 19 | { 20 | "key": "Cache-Control", 21 | "value": "max-age=0" 22 | }, 23 | { 24 | "key": "Content-Type", 25 | "value": "text/cache-manifest" 26 | } 27 | ] 28 | }, 29 | { 30 | "source": "/", 31 | "headers": [ 32 | { 33 | "key": "Cache-Control", 34 | "value": "max-age=864000" 35 | } 36 | ] 37 | }, 38 | { 39 | "source": "static/*.@(jpg|jpeg|gif|png|svg|css)", 40 | "headers": [ 41 | { 42 | "key": "Cache-Control", 43 | "value": "max-age=8640000" 44 | } 45 | ] 46 | }, 47 | { 48 | "source": "static/favicons/*.*", 49 | "headers": [ 50 | { 51 | "key": "Cache-Control", 52 | "value": "max-age=8640000" 53 | } 54 | ] 55 | }, 56 | { 57 | "source": "static/**/main*.@(css|js)", 58 | "headers": [ 59 | { 60 | "key": "Cache-Control", 61 | "value": "max-age=31536000" 62 | } 63 | ] 64 | } 65 | ] 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Settings.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { observer } from 'mobx-react' 3 | 4 | import store from './Store' 5 | 6 | 7 | const Settings = observer(class Settings extends Component { 8 | 9 | render() { 10 | return ( 11 |
12 |

Settings

13 |
14 |
15 | 26 |
27 |
28 | 29 |
30 |

31 | During development it can happen that the local cache 32 | optimizations get stuck and causes confusion.
33 | Pressing this button is harmless but will cause a full reload. 34 |

35 | 42 |
43 | 44 | 45 | 52 |
53 | ) 54 | } 55 | }) 56 | 57 | export default Settings 58 | -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | TODO 2 | ==== 3 | 4 | * Consider using mobx-state-tree 5 | https://codesandbox.io/s/nZ26kGMD 6 | 7 | * Import addtohomescreen as a module 8 | https://github.com/cubiq/add-to-homescreen/issues/257 9 | 10 | * Is there a way to import much less of recompose since we only need 11 | pure and onlyUpdateForKeys 12 | 13 | * Convert URLs to titles using 14 | http://tools.buzzstream.com/meta-tag-extractor 15 | or http://embed.ly/docs/explore/extract?url=https%3A%2F%2Fdeveloper.mozilla.org%2Fen-US%2Fdocs%2FWeb%2FAPI%2FPage_Visibility_API 16 | 17 | 18 | * Try https://github.com/BafS/Gutenberg 19 | for printing. 20 | 21 | * Ability to upload and store pictures. 22 | http://stackoverflow.com/a/31501426/205832 23 | 24 | * Consider using runInAction() when updating the store with multiple things. 25 | 26 | * Figure how a way 27 | 1) encourage saving to Home screen on first run 28 | 2) if someone uses IndexedDB in safari and THEN later decide to add 29 | to Home screen, all data is lost. 30 | 31 | * Ideas for how to style print.css 32 | https://www.tinyprints.com/shop/custom-menus.htm 33 | 34 | * Consider js-search since it supports incrementally adding documents 35 | apparently 36 | https://github.com/bvaughn/js-search/issues/20#issuecomment-277922936 37 | 38 | DONE 39 | ==== 40 | 41 | * Upload burger logo on http://realfavicongenerator.net/ 42 | 43 | * https://zengabor.github.io/zenscroll/ seems to work on iOS safari 44 | 45 | * Experiment with: 46 | appcache-manifest "build/index.{html,css,js}" "build/**/*.{css,js}" 47 | "build/burger.png" --network-star -o build/index.appcache 48 | 49 | * Install that react-perf addon and see if some components really do 50 | over render. 51 | 52 | * Experiment with https://www.npmjs.com/package/autobind-decorator 53 | to see if we can avoid having to type any 54 | `this.someMethod=this.someMethod.bind(this)` 55 | 56 | * Avoid DOM query selectors and use refs 57 | https://facebook.github.io/react/docs/refs-and-the-dom.html 58 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | .navbar-brand .week-head-dates { 2 | font-size: 70%; 3 | opacity: 0.7; 4 | } 5 | 6 | .options.top { 7 | margin-top: 40px; 8 | } 9 | 10 | .options.bottom { 11 | margin-top: 40px; 12 | margin-bottom: 100px; 13 | } 14 | 15 | .day .actions .action { 16 | padding-top: 10px; 17 | } 18 | 19 | .action.buttons { 20 | text-align: right; 21 | } 22 | 23 | .day .week-head { 24 | text-align: center; 25 | margin-top: 40px; 26 | padding-top: 10px; 27 | margin-bottom: 20px; 28 | } 29 | 30 | .day { 31 | margin-top: 20px; 32 | padding-top: 10px; 33 | border-top: 1px solid #efefef; 34 | } 35 | 36 | /*.weekday-head { 37 | margin-bottom: 0.3rem; 38 | }*/ 39 | 40 | .day .weekday-head-date { 41 | font-size: 70%; 42 | opacity: 0.7; 43 | } 44 | .day textarea { 45 | font-size: 1em; 46 | } 47 | 48 | .display-day p { 49 | margin: 3px; 50 | line-height: 1.3; 51 | } 52 | 53 | .search-results .search-result p.last-used { 54 | margin: 3px; 55 | font-size: 0.8em; 56 | opacity: 0.7; 57 | 58 | } 59 | 60 | .display-day p.text, 61 | textarea.text { 62 | font-weight: bold; 63 | } 64 | 65 | 66 | .search-results .search-result { 67 | margin: 20px 0; 68 | } 69 | 70 | .fadein-enter { 71 | opacity: 0.01; 72 | } 73 | .fadein-enter.fadein-enter-active { 74 | opacity: 1; 75 | transition: opacity 500ms ease-in; 76 | } 77 | .fadein-leave { 78 | opacity: 1; 79 | } 80 | .fadein-leave.fadein-leave-active { 81 | opacity: 0.01; 82 | transition: opacity 300ms ease-in; 83 | } 84 | 85 | 86 | .fadein-appear { 87 | opacity: 0.01; 88 | } 89 | .fadein-appear.fadein-appear-active { 90 | opacity: 1; 91 | transition: opacity .5s ease-in; 92 | } 93 | 94 | .btn.close-button { 95 | margin-top: 50px; 96 | margin-bottom: 50px; 97 | } 98 | 99 | 100 | .offline-indicator { 101 | margin-left: 20px; 102 | text-transform: uppercase; 103 | font-size: 0.5em; 104 | } 105 | .offline-indicator .online { 106 | color: #8BAB57; 107 | } 108 | .offline-indicator .offline { 109 | color: #CF441F; 110 | } 111 | 112 | .navbar-light .navbar-brand { 113 | font-size: 1.25em; 114 | } 115 | @media (max-width:320px) { 116 | .navbar-light .navbar-brand { 117 | font-size: 1.05em; 118 | } 119 | .offline-indicator { 120 | margin-left: 5px; 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/Common.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { pure } from 'recompose' 3 | import { format } from 'date-fns/esm' 4 | 5 | const dateFns = { 6 | format: format, 7 | } 8 | 9 | export const makeWeekId = (datetime) => { 10 | return 'week-head-' + dateFns.format(datetime, 'YYYYW') 11 | } 12 | 13 | export const makeDayId = (datetime) => { 14 | return 'day-' + dateFns.format(datetime, 'YYYYMMDD') 15 | } 16 | 17 | export const ShowWeekHeaderDates = pure( 18 | ({ start, end }) => { 19 | return ( 20 | 21 | { dateFns.format(start, 'D MMM') } 22 | {' '} 23 | ... 24 | {' '} 25 | { dateFns.format(end, 'D MMM') } 26 | 27 | ) 28 | }) 29 | 30 | export const Heart = pure( 31 | ({ filled, bubble, size = 18 }) => { 32 | return ( 33 | 34 | 35 | 38 | 39 | 40 | ) 41 | }) 42 | 43 | 44 | export function debounce(callback, wait, context = this) { 45 | let timeout = null 46 | let callbackArgs = null 47 | 48 | const later = () => callback.apply(context, callbackArgs) 49 | 50 | return function() { 51 | callbackArgs = arguments 52 | clearTimeout(timeout) 53 | timeout = setTimeout(later, wait) 54 | } 55 | } 56 | 57 | 58 | export const ShowFirebaseError = ({ heading, error }) => { 59 | if (!error) { 60 | return null 61 | } 62 | return ( 63 |
64 |

{ heading }

65 |

66 | 67 | { error.code } 68 | 69 |

70 |

71 | { error.message } 72 |

73 |
74 | ) 75 | } 76 | 77 | 78 | const pagifyRegex = /\b(page |p\.?|p\.? )(\d+)$/ 79 | 80 | export const pagifyScrubText = (text) => { 81 | // Any string that ends with... 82 | // page 123 83 | // p. 123 84 | // p123 85 | // p.123 86 | // gets the number part replaced with a ? character. 87 | return text.replace(pagifyRegex, 'page ?') 88 | } 89 | 90 | export const pagifyPromptText = (text) => { 91 | if (pagifyRegex.test(text)) { 92 | const defaultPage = text.match(pagifyRegex)[2] 93 | const page = prompt('What page this time?', defaultPage) 94 | if (page) { 95 | text = text.replace(pagifyRegex, `page ${page}`) 96 | } 97 | } 98 | return text 99 | } 100 | -------------------------------------------------------------------------------- /src/Store.js: -------------------------------------------------------------------------------- 1 | import { action, extendObservable, ObservableMap } from 'mobx' 2 | // import { autorun } from 'mobx' 3 | import { isWithinInterval } from 'date-fns/esm' 4 | 5 | const dateFns = { 6 | isWithinInterval: isWithinInterval, 7 | } 8 | 9 | class Day { 10 | constructor(date, datetime, text = '', notes = '', starred = false) { 11 | this.date = date 12 | this.datetime = datetime 13 | extendObservable(this, { 14 | text: text, 15 | notes: notes, 16 | starred: starred, 17 | }) 18 | } 19 | } 20 | 21 | class Store { 22 | constructor() { 23 | extendObservable(this, { 24 | noFirebase: false, 25 | offline: null, // null == "Don't really know" 26 | // wasOffline: false, 27 | currentUser: null, 28 | days: new ObservableMap(), 29 | copied: null, 30 | firstDateThisWeek: null, 31 | currentGroup: null, 32 | dateRangeStart: null, 33 | dateRangeEnd: null, 34 | extendDateRange: action((firstDate, lastDate) => { 35 | if (this.dateRangeStart) { 36 | if (firstDate < this.dateRangeStart) { 37 | this.dateRangeStart = firstDate 38 | } 39 | if (lastDate > this.dateRangeEnd) { 40 | this.dateRangeEnd = lastDate 41 | } 42 | } else { 43 | this.dateRangeStart = firstDate 44 | this.dateRangeEnd = lastDate 45 | } 46 | }), 47 | get dateRangeLength() { 48 | if (this.dateRangeStart) { 49 | // If dateRangeStart and dateRangeEnd spans two different 50 | // day-light savings events, this might not add up to a multiple 51 | // of 7 as expected. 52 | // That's why it needs to be rounded to the nearest integer. 53 | return Math.round((this.dateRangeEnd - this.dateRangeStart) / (1000 * 24 * 3600)) 54 | } else { 55 | return 7 56 | } 57 | }, 58 | get filteredDays() { 59 | return this.days.values().filter(day => { 60 | if (this.dateRangeStart) { 61 | const withinRange = dateFns.isWithinInterval( 62 | day.datetime, 63 | {start: this.dateRangeStart, end:this.dateRangeEnd} 64 | ) 65 | if (withinRange) { 66 | return true 67 | } else { 68 | } 69 | } else { 70 | return true 71 | } 72 | return false 73 | }).sort((a, b) => a.datetime - b.datetime) 74 | }, 75 | addDay: action((date, datetime, text = '', notes = '', starred = false) => { 76 | this.days.set(date, new Day(date, datetime, text, notes, starred)) 77 | }), 78 | recentFavorites: null, 79 | settings: JSON.parse(localStorage.getItem('settings') || '{}'), 80 | setSetting: action((key, value) => { 81 | this.settings[key] = value 82 | localStorage.setItem('settings', JSON.stringify(this.settings)) 83 | }), 84 | }) 85 | } 86 | } 87 | 88 | const store = window.store = new Store() 89 | 90 | export default store 91 | 92 | // autorun(() => { 93 | // // console.log('Store changed DAYS:', store.days, store.settings); 94 | // console.log('Store changed DAYS:', store.days.length) 95 | // }) 96 | -------------------------------------------------------------------------------- /src/Favorites.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { observer } from 'mobx-react' 3 | import { 4 | format, 5 | formatDistanceStrict, 6 | } from 'date-fns/esm' 7 | 8 | import { DisplayDay } from './Day' 9 | import store from './Store' 10 | 11 | const dateFns = { 12 | format: format, 13 | formatDistanceStrict: formatDistanceStrict, 14 | } 15 | 16 | 17 | const Favorites = observer(class Favorites extends Component { 18 | 19 | render() { 20 | 21 | // console.log('Rendering Favorites, store.recentFavorites:', store.recentFavorites); 22 | 23 | return ( 24 |
25 |

26 | Favorites 27 | {' '} 28 | (most recent first) 29 |

30 | { !store.recentFavorites ? Loading... : null } 31 | 32 | { 33 | store.recentFavorites && !store.recentFavorites.length ? 34 | No favorites saved. Heart more! 35 | : null 36 | } 37 | { 38 | store.recentFavorites && store.recentFavorites.length ? 39 | 43 | : null 44 | } 45 | 46 | 53 |
54 | ) 55 | } 56 | }) 57 | 58 | export default Favorites 59 | 60 | 61 | // Consider importing this from Search instead (or Common) 62 | const ShowSearchResults = ({ results, onClosePage }) => { 63 | return ( 64 |
65 | { 66 | results.map(result => { 67 | return ( 68 |
70 |
71 | 76 |

77 | Last used { dateFns.format(result.datetime, 'D MMM') }, 78 | {' '} 79 | { dateFns.formatDistanceStrict( 80 | new Date(), result.datetime, {addSuffix: true} 81 | ) } 82 |

83 |
84 |
86 | 100 |
101 |
102 | ) 103 | }) 104 | } 105 |
106 | ) 107 | } 108 | -------------------------------------------------------------------------------- /public/static/favicons/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.11, written by Peter Selinger 2001-2013 9 | 10 | 12 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /src/Days.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { observer } from 'mobx-react' 3 | import zenscroll from 'zenscroll' 4 | import { 5 | startOfWeek, 6 | subDays, 7 | isEqual, 8 | } from 'date-fns/esm' 9 | 10 | import store from './Store' 11 | import { makeDayId } from './Common' 12 | import Day from './Day' 13 | 14 | const dateFns = { 15 | startOfWeek: startOfWeek, 16 | subDays: subDays, 17 | isEqual: isEqual, 18 | } 19 | 20 | const Days = observer(class Days extends Component { 21 | 22 | constructor(props) { 23 | super(props) 24 | this.state = { 25 | loadingPreviousWeek: false, 26 | loadingNextWeek: false, 27 | } 28 | } 29 | 30 | loadPreviousWeek = (event) => { 31 | const firstDatetime = store.dateRangeStart 32 | const firstDatePreviousWeek = dateFns.subDays(firstDatetime, 7) 33 | this.props.loadWeek(firstDatePreviousWeek) 34 | this.setState({loadingPreviousWeek: false}, () => { 35 | const id = makeDayId(firstDatePreviousWeek) 36 | const element = document.querySelector('#' + id) 37 | if (element) { 38 | zenscroll.to(element) 39 | } 40 | }) 41 | } 42 | 43 | loadNextWeek = (event) => { 44 | const firstDateNextWeek = store.dateRangeEnd 45 | this.props.loadWeek(firstDateNextWeek) 46 | this.setState({loadingNextWeek: false}, () => { 47 | const id = makeDayId(firstDateNextWeek) 48 | const element = document.querySelector('#' + id) 49 | if (element) { 50 | zenscroll.to(element) 51 | } 52 | }) 53 | } 54 | 55 | render() { 56 | const weekStartsOn = store.settings.weekStartsOnAMonday ? 1 : 0 57 | return ( 58 |
59 | {/* 60 | By default currentUser===null, but if firebase 61 | has successfully run onAuthStateChanged at least once 62 | then it gets set to 'false'. 63 | Only then show the unauthorized warning. 64 | */} 65 | { 66 | store.currentUser === false && !store.offline ? 67 | 70 | : null 71 | } 72 | 73 | { 74 | store.days.size ? 75 |
76 | 88 |
89 | : null 90 | } 91 | 92 | { !store.days.size ? Loading... : null } 93 | 94 | { 95 | store.filteredDays.map(day => { 96 | let firstDateThisWeek = dateFns.isEqual( 97 | day.datetime, 98 | dateFns.startOfWeek(day.datetime, {weekStartsOn: weekStartsOn}) 99 | ) 100 | return 107 | }) 108 | } 109 | { 110 | store.days.size ? 111 |
112 | 124 |
125 | : null 126 | } 127 |
128 | ) 129 | } 130 | }) 131 | 132 | 133 | export default Days 134 | 135 | 136 | const ShowUnauthorizedWarning = ({ gotoSignInPage }) => { 137 | return ( 138 |
139 |

Not Signed In

140 |

141 | It's OK but nothing you enter will be backed up. 142 |

143 |

144 | 153 |

154 |
155 | ) 156 | } 157 | -------------------------------------------------------------------------------- /src/Search.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { observer } from 'mobx-react' 3 | import { 4 | format, 5 | formatDistanceStrict, 6 | } from 'date-fns/esm' 7 | 8 | import { DisplayDay } from './Day' 9 | import { debounce } from './Common' 10 | import store from './Store' 11 | 12 | const dateFns = { 13 | format: format, 14 | formatDistanceStrict: formatDistanceStrict, 15 | 16 | } 17 | 18 | const Search = observer(class Search extends Component { 19 | 20 | constructor() { 21 | super() 22 | this.state = { 23 | search: '', 24 | searching: false, 25 | searchResults: null, 26 | } 27 | this.autoCompleteSearch = debounce(this.autoCompleteSearch.bind(this), 300) 28 | } 29 | 30 | componentDidMount = () => { 31 | this.refs.search.focus() 32 | } 33 | 34 | autoCompleteSearch = () => { 35 | if (!this.state.search.trim().length) { 36 | this.setState({ 37 | searchResults: [], 38 | searching: false, 39 | }) 40 | return 41 | } 42 | this.setState({searching: true}) 43 | const results = this.props.searcher(this.state.search, { 44 | fields: { 45 | text: {boost: 3}, 46 | notes: {boost: 1}, 47 | }, 48 | bool: 'OR', 49 | expand: true, 50 | }) 51 | let hashes = new Set() 52 | let searchResults = [] 53 | results.forEach(result => { 54 | let hash = result.text + result.notes 55 | hash = hash.toLowerCase() 56 | if (!hashes.has(hash) && searchResults.length < 50) { 57 | searchResults.push(result) 58 | hashes.add(hash) 59 | } 60 | }) 61 | this.setState({ 62 | searchResults: searchResults, 63 | searching: false, 64 | }) 65 | } 66 | 67 | render() { 68 | 69 | return ( 70 |
71 |

Search

72 |
{ 73 | e.preventDefault() 74 | console.log('Form submitted!'); 75 | }}> 76 | { 81 | this.setState({search: e.target.value}, this.autoCompleteSearch) 82 | }} 83 | value={this.state.search}/> 84 | {' '} 85 | 92 |
93 | 94 | { this.state.searching ? Searching... : null } 95 | 96 | { 97 | !this.state.searching && this.state.searchResults && !this.state.searchResults.length ? 98 |

Nothing found.

99 | : null 100 | } 101 | 102 | { 103 | !this.state.searching && this.state.searchResults && this.state.searchResults.length ? 104 | 108 | : null 109 | } 110 | 111 | 118 |
119 | ) 120 | } 121 | }) 122 | 123 | export default Search 124 | 125 | 126 | const ShowSearchResults = ({ results, onClosePage }) => { 127 | return ( 128 |
129 | { 130 | results.map(result => { 131 | return ( 132 |
134 |
135 | 141 |

142 | Last used { dateFns.format(result.datetime, 'D MMM') }, 143 | {' '} 144 | { dateFns.formatDistanceStrict( 145 | new Date(), result.datetime, {addSuffix: true} 146 | ) } 147 |

148 |
149 |
151 | 165 |
166 |
167 | ) 168 | }) 169 | } 170 |
171 | ) 172 | } 173 | -------------------------------------------------------------------------------- /src/User.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { observer } from 'mobx-react' 3 | 4 | import store from './Store' 5 | import { ShowFirebaseError } from './Common' 6 | 7 | const User = observer(class User extends Component { 8 | 9 | constructor(props) { 10 | super(props) 11 | this.state = {changePassword: false} 12 | } 13 | 14 | render() { 15 | return ( 16 |
17 |

User

18 | 19 |
20 | {/*
Name
*/} 21 | {/*
{ store.currentUser.displayName }
*/} 22 |
Email
23 |
{ store.currentUser.email }
24 |
Email Verified?
25 |
{ store.currentUser.emailVerified ? 'Yes' : 'Not yet' }
26 |
27 | 28 | { 29 | this.state.changePassword ? 30 | { 33 | this.setState({changePassword: false}) 34 | }} 35 | /> 36 | : null 37 | } 38 | 39 | 46 | 55 | 67 | 68 | 75 |
76 | ) 77 | } 78 | }) 79 | 80 | export default User 81 | 82 | 83 | class ChangePassword extends Component { 84 | 85 | constructor(props) { 86 | super(props) 87 | this.state = {misMatched: false} 88 | } 89 | 90 | render() { 91 | return ( 92 |
{ 95 | e.preventDefault() 96 | let pv = this.refs.password.value 97 | let pv2 = this.refs.password2.value 98 | if (pv !== pv2) { 99 | this.setState({misMatched: true}) 100 | } else { 101 | if (this.state.misMatched) { 102 | this.setState({misMatched: false}) 103 | } 104 | if (this.state.error) { 105 | this.setState({error: null}) 106 | } 107 | this.props.user.updatePassword(pv).then(() => { 108 | // XXX flash message? 109 | this.props.onClose() 110 | // Update successful. 111 | // this.setState({changePassword: false}) 112 | }, error => { 113 | // An error happened. 114 | console.error(error); 115 | this.setState({error: error}) 116 | }) 117 | } 118 | }}> 119 |

Change Your Password

120 | 123 | 124 |
125 | 126 | 131 |
132 |
134 | 135 | 140 | { 141 | this.state.misMatched ? 142 |
Mismatched
143 | : null 144 | } 145 |
146 | 152 | 161 | 162 | ) 163 | } 164 | 165 | } 166 | -------------------------------------------------------------------------------- /src/SignIn.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { observer } from 'mobx-react' 3 | 4 | import { ShowFirebaseError } from './Common' 5 | 6 | const SignIn = observer(class SignIn extends Component { 7 | 8 | constructor(props) { 9 | super(props) 10 | this.state = { 11 | resetPassword: false, 12 | } 13 | } 14 | 15 | render() { 16 | 17 | return ( 18 |
19 |

Sign In

20 | 21 | { 22 | this.state.resetPassword ? 23 | { 25 | this.setState({resetPassword: false}) 26 | }} 27 | auth={this.props.auth} 28 | /> : 29 | 34 | } 35 | 36 | { 37 | !this.state.resetPassword ? 38 | 48 | : null 49 | } 50 | 51 | 59 |
60 | ) 61 | } 62 | }) 63 | 64 | export default SignIn 65 | 66 | 67 | class Login extends Component { 68 | 69 | constructor(props) { 70 | super(props) 71 | this.state = { 72 | error: null, 73 | } 74 | } 75 | 76 | render() { 77 | return ( 78 |
{ 79 | e.preventDefault() 80 | let email = this.refs.email.value.trim() 81 | let password = this.refs.password.value.trim() 82 | if (!email || !password) { 83 | return 84 | } 85 | this.props.auth.signInWithEmailAndPassword(email, password) 86 | .then(() => { 87 | this.props.onClosePage() 88 | }) 89 | .catch((error) => { 90 | if (error.code === 'auth/user-not-found') { 91 | this.props.auth.createUserWithEmailAndPassword(email, password) 92 | .then(() => { 93 | // console.log("SUCCESSFULLY created a user account"); 94 | this.props.onUserCreated() 95 | this.props.onClosePage() 96 | }) 97 | .catch(error => { 98 | // Handle Errors here. 99 | this.setState({error: error}) 100 | }) 101 | } else { 102 | this.setState({error: error}) 103 | // console.log(error); 104 | // var errorCode = error.code; 105 | // console.log('errorCode', errorCode); 106 | // var errorMessage = error.message; 107 | // console.log('errorMessage', errorMessage); 108 | } 109 | }) 110 | }}> 111 |
112 | {/* */} 113 | 120 | {/* We'll never share your email with anyone else. */} 121 |
122 |
123 | {/* */} 124 | 129 |
130 | 133 | 134 | 137 | 138 | 139 | 140 | 141 | ) 142 | } 143 | } 144 | 145 | 146 | class ResetPassword extends Component { 147 | 148 | constructor(props) { 149 | super(props) 150 | this.state = { 151 | error: null, 152 | } 153 | } 154 | 155 | render() { 156 | return ( 157 |
{ 158 | e.preventDefault() 159 | let email = this.refs.email.value.trim() 160 | if (!email) { 161 | return 162 | } 163 | this.props.auth.sendPasswordResetEmail(email).then(() => { 164 | // XXX Flash message?! 165 | this.props.onClose() 166 | }) 167 | .catch((error) => { 168 | this.setState({error: error}) 169 | }) 170 | }}> 171 |
172 | 173 | 180 |
181 | 184 | 189 | 190 | 193 | 194 | ) 195 | } 196 | 197 | } 198 | -------------------------------------------------------------------------------- /public/static/burger.svg: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /src/Nav.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { observer } from 'mobx-react' 3 | import { pure } from 'recompose' 4 | import { addDays } from 'date-fns/esm' 5 | 6 | import store from './Store' 7 | import { ShowWeekHeaderDates, Heart } from './Common' 8 | 9 | // Because I love namespaces 10 | const dateFns = {addDays: addDays} 11 | 12 | 13 | const Nav = observer(class Nav extends Component { 14 | constructor(props) { 15 | super(props) 16 | this.state = { 17 | collapsed: true, 18 | collapsing: false, 19 | } 20 | } 21 | render() { 22 | let burgerClassname = 'navbar-toggler navbar-toggler-right' 23 | let navlinksClassname = 'navbar-collapse' 24 | if (this.state.collapsing) { 25 | navlinksClassname += ' collapsing' 26 | } else if (this.state.collapsed) { 27 | navlinksClassname += ' collapse ' 28 | } else { 29 | navlinksClassname += ' collapse show' 30 | } 31 | 32 | return ( 33 | 206 | ) 207 | } 208 | }) 209 | 210 | export default Nav 211 | 212 | 213 | export const ShowOfflineIndicator = pure( 214 | ({ offline }) => { 215 | let style = { 216 | backgroundImage: `url(${process.env.PUBLIC_URL}/static/offline-sprite.png)`, 217 | backgroundRepeat: 'no-repeat', 218 | display: 'inline-block', 219 | width: 16, 220 | height: 16, 221 | } 222 | if (offline) { 223 | style.backgroundPosition = '-16px 0' 224 | } else { 225 | style.backgroundPosition = '0 0' 226 | } 227 | return ( 228 | 229 | { 230 | offline ? 231 | 235 | 236 | Offline 237 | 238 | : 239 | 243 | 244 | Online 245 | 246 | } 247 | 248 | ) 249 | }) 250 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | Dinnerd 24 | 25 | 26 | 27 | 28 | 29 | 30 | 39 | 40 | 41 | 42 | 43 | 44 |
45 |
46 |

Loading...

47 | 48 | 49 |

50 | Taking forever?
51 | Try 54 |

55 |
56 |
57 | 58 | 59 | 75 | 76 | 83 | 84 | 85 | -------------------------------------------------------------------------------- /public/static/addtohomescreen.css: -------------------------------------------------------------------------------- 1 | .ath-viewport * { 2 | -webkit-box-sizing: border-box; 3 | -moz-box-sizing: border-box; 4 | box-sizing: border-box; 5 | } 6 | 7 | .ath-viewport { 8 | position: relative; 9 | z-index: 2147483641; 10 | pointer-events: none; 11 | 12 | -webkit-tap-highlight-color: rgba(0,0,0,0); 13 | -webkit-touch-callout: none; 14 | -webkit-user-select: none; 15 | -moz-user-select: none; 16 | -ms-user-select: none; 17 | user-select: none; 18 | -webkit-text-size-adjust: none; 19 | -moz-text-size-adjust: none; 20 | -ms-text-size-adjust: none; 21 | -o-text-size-adjust: none; 22 | text-size-adjust: none; 23 | } 24 | 25 | .ath-modal { 26 | pointer-events: auto !important; 27 | background: rgba(0,0,0,0.6); 28 | } 29 | 30 | .ath-mandatory { 31 | background: #000; 32 | } 33 | 34 | .ath-container { 35 | pointer-events: auto !important; 36 | position: absolute; 37 | z-index: 2147483641; 38 | padding: 0.7em 0.6em; 39 | width: 18em; 40 | 41 | background: #eee; 42 | background-size: 100% auto; 43 | 44 | box-shadow: 0 0.2em 0 #d1d1d1; 45 | 46 | font-family: sans-serif; 47 | font-size: 15px; 48 | line-height: 1.5em; 49 | text-align: center; 50 | } 51 | 52 | .ath-container small { 53 | font-size: 0.8em; 54 | line-height: 1.3em; 55 | display: block; 56 | margin-top: 0.5em; 57 | } 58 | 59 | .ath-ios.ath-phone { 60 | bottom: 1.8em; 61 | left: 50%; 62 | margin-left: -9em; 63 | } 64 | 65 | .ath-ios6.ath-tablet { 66 | left: 5em; 67 | top: 1.8em; 68 | } 69 | 70 | .ath-ios7.ath-tablet { 71 | left: 0.7em; 72 | top: 1.8em; 73 | } 74 | 75 | .ath-ios8.ath-tablet { 76 | right: 0.4em; 77 | top: 1.8em; 78 | } 79 | 80 | .ath-android { 81 | bottom: 1.8em; 82 | left: 50%; 83 | margin-left: -9em; 84 | } 85 | 86 | /* close icon */ 87 | .ath-container:before { 88 | content: ''; 89 | position: relative; 90 | display: block; 91 | float: right; 92 | margin: -0.7em -0.6em 0 0.5em; 93 | background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIQAAACECAMAAABmmnOVAAAAdVBMVEUAAAA5OTkzMzM7Ozs3NzdBQUFAQEA/Pz8+Pj5BQUFAQEA/Pz8+Pj5BQUFAQEA/Pz9BQUE+Pj4/Pz8/Pz8+Pj4/Pz8/Pz8/Pz8+Pj4/Pz8+Pj4/Pz8/Pz8/Pz8/Pz8/Pz8+Pj4/Pz8/Pz8/Pz8/Pz9AQEA/Pz+fdCaPAAAAJnRSTlMACQoNDjM4OTo7PEFCQ0RFS6ytsbS1tru8vcTFxu7x8vX19vf4+C5yomAAAAJESURBVHgBvdzLTsJAGEfxr4C2KBcVkQsIDsK8/yPaqIsPzVlyzrKrX/5p0kkXEz81L23otc9NpIbbWia2YVLqdnhlqFlhGWpSDHe1aopsSIpRb8gK0dC3G30b9rVmhWZIimTICsvQtx/FsuYOrWHoDjX3Gu31gzJxdki934WrAIOsAIOsAIOiAMPhPsJTgKGN0BVsYIVsYIVpYIVpYIVpYIVpYIVpYIVpYIVpYIVlAIVgEBRs8BRs8BRs8BRs8BRs8BRs8BRs8BRTNmgKNngKNngKNngKNngKNhiKGxgiOlZoBlaYBlaYBlaYBlaYBlaYBlaYBlaYBlZIBlBMfQMrVAMr2KAqBENSHFHhGEABhi5CV6gGUKgGUKgGUKgGUFwuqgEUvoEVsoEVpoEUpgEUggF+gKTKY+h1fxSlC7/Z+RrxOQ3fcEoAPPHZBlaYBlaYBlaYBlZYBlYIhvLBCstw7PgM7hkiWOEZWGEaWGEaWGEaIsakEAysmHkGVpxmvoEVqoEVpoEVpoEVpoEVpoEVpoEVkoEVgkFQsEFSsEFQsGEcoSvY4CnY4CnY4CnY4CnY4CnY4CnY4CnY4CnY4CnY4CnY4CnY4CnY4CnY4CnY4CnY4CnY4CnY4CnmbNAUT2c2WAo2eAo2eAo2eAo2eAo2eArNEPFACjZ4CjZ4CjZ4CjaIird/rBvFH6llNCvewdli1URWCIakSIZesUaDoFg36dKFWk9zCZDei3TtwmCj7pC22AwikiIZPEU29IpFNliKxa/hC9DFITjQPYhcAAAAAElFTkSuQmCC); 94 | background-color: rgba(255,255,255,0.8); 95 | background-size: 50%; 96 | background-repeat: no-repeat; 97 | background-position: 50%; 98 | width: 2.7em; 99 | height: 2.7em; 100 | text-align: center; 101 | overflow: hidden; 102 | color: #a33; 103 | z-index: 2147483642; 104 | } 105 | 106 | .ath-container.ath-icon:before { 107 | position: absolute; 108 | top: 0; 109 | right: 0; 110 | margin: 0; 111 | float: none; 112 | } 113 | 114 | .ath-mandatory .ath-container:before { 115 | display: none; 116 | } 117 | 118 | .ath-container.ath-android:before { 119 | float: left; 120 | margin: -0.7em 0.5em 0 -0.6em; 121 | } 122 | 123 | .ath-container.ath-android.ath-icon:before { 124 | position: absolute; 125 | right: auto; 126 | left: 0; 127 | margin: 0; 128 | float: none; 129 | } 130 | 131 | 132 | /* applied only if the application icon is shown */ 133 | .ath-container.ath-icon { 134 | 135 | } 136 | 137 | .ath-action-icon { 138 | display: inline-block; 139 | vertical-align: middle; 140 | background-position: 50%; 141 | background-repeat: no-repeat; 142 | text-indent: -9999em; 143 | overflow: hidden; 144 | } 145 | 146 | .ath-ios7 .ath-action-icon, 147 | .ath-ios8 .ath-action-icon { 148 | width: 1.6em; 149 | height: 1.6em; 150 | background-image:url(data:image/png;base64,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); 151 | margin-top: -0.3em; 152 | background-size: auto 100%; 153 | } 154 | 155 | .ath-ios6 .ath-action-icon { 156 | width: 1.8em; 157 | height: 1.8em; 158 | background-image:url(data:image/png;base64,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); 159 | margin-bottom: 0.4em; 160 | background-size: 100% auto; 161 | } 162 | 163 | .ath-android .ath-action-icon { 164 | width: 1.4em; 165 | height: 1.5em; 166 | background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAMAAABg3Am1AAAANlBMVEVmZmb///9mZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZW6fJrAAAAEXRSTlMAAAYHG21ub8fLz9DR8/T4+RrZ9owAAAB3SURBVHja7dNLDoAgDATQWv4gKve/rEajJOJiWLgg6WzpSyB0aHqHiNj6nL1lovb4C+hYzkSNAT7mryQFAVOeGAj4CjwEtgrWXpD/uZKtwEJApXt+Vn0flzRhgNiFZQkOXY0aADQZCOCPlsZJ46Rx0jhp3IiN2wGDHhxtldrlwQAAAABJRU5ErkJggg==); 167 | background-size: 100% auto; 168 | } 169 | 170 | .ath-container p { 171 | margin: 0; 172 | padding: 0; 173 | position: relative; 174 | z-index: 2147483642; 175 | text-shadow: 0 0.1em 0 #fff; 176 | font-size: 1.1em; 177 | } 178 | 179 | .ath-ios.ath-phone:after { 180 | content: ''; 181 | background: #eee; 182 | position: absolute; 183 | width: 2em; 184 | height: 2em; 185 | bottom: -0.9em; 186 | left: 50%; 187 | margin-left: -1em; 188 | -webkit-transform: scaleX(0.9) rotate(45deg); 189 | transform: scaleX(0.9) rotate(45deg); 190 | box-shadow: 0.2em 0.2em 0 #d1d1d1; 191 | } 192 | 193 | .ath-ios.ath-tablet:after { 194 | content: ''; 195 | background: #eee; 196 | position: absolute; 197 | width: 2em; 198 | height: 2em; 199 | top: -0.9em; 200 | left: 50%; 201 | margin-left: -1em; 202 | -webkit-transform: scaleX(0.9) rotate(45deg); 203 | transform: scaleX(0.9) rotate(45deg); 204 | z-index: 2147483641; 205 | } 206 | 207 | .ath-application-icon { 208 | position: relative; 209 | padding: 0; 210 | border: 0; 211 | margin: 0 auto 0.2em auto; 212 | height: 6em; 213 | width: 6em; 214 | z-index: 2147483642; 215 | } 216 | 217 | .ath-container.ath-ios .ath-application-icon { 218 | border-radius: 1em; 219 | box-shadow: 0 0.2em 0.4em rgba(0,0,0,0.3), 220 | inset 0 0.07em 0 rgba(255,255,255,0.5); 221 | margin: 0 auto 0.4em auto; 222 | } 223 | 224 | @media only screen and (orientation: landscape) { 225 | .ath-container.ath-phone { 226 | width: 24em; 227 | } 228 | 229 | .ath-android.ath-phone { 230 | margin-left: -12em; 231 | } 232 | 233 | .ath-ios.ath-phone { 234 | margin-left: -12em; 235 | } 236 | 237 | .ath-ios6:after { 238 | left: 39%; 239 | } 240 | 241 | .ath-ios8.ath-phone { 242 | left: auto; 243 | bottom: auto; 244 | right: 0.4em; 245 | top: 1.8em; 246 | } 247 | 248 | .ath-ios8.ath-phone:after { 249 | bottom: auto; 250 | top: -0.9em; 251 | left: 68%; 252 | z-index: 2147483641; 253 | box-shadow: none; 254 | } 255 | } 256 | -------------------------------------------------------------------------------- /src/Group.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { observer } from 'mobx-react' 3 | 4 | import store from './Store' 5 | import { ShowFirebaseError } from './Common' 6 | 7 | const randomGroupCode = function(noNumbers = 4, noLetters = 2) { 8 | // skipping I and O since they look like 1 and 0 9 | const letters = 'ABCDEFGHJKLMNPQRSTUVWXYZ' 10 | // skipping 1 and 0 since they look like I and O 11 | const numbers = '23456789' 12 | let code = '' 13 | for (var i = 0; i < noLetters; i++) { 14 | code += letters[Math.floor(Math.random() * letters.length)] 15 | } 16 | for (i = 0; i < noNumbers; i++) { 17 | code += numbers[Math.floor(Math.random() * numbers.length)] 18 | } 19 | return code 20 | } 21 | 22 | 23 | const Group = observer(class Group extends Component { 24 | 25 | constructor(props) { 26 | super(props) 27 | this.state = { 28 | changeGroup: false, 29 | otherGroups: null, 30 | codes: null, 31 | } 32 | } 33 | 34 | componentDidMount() { 35 | this.fetchCurrentGroupCodes() 36 | this.fetchOtherGroups() 37 | } 38 | 39 | fetchCurrentGroupCodes = () => { 40 | if (store.currentGroup && store.settings.defaultGroupId) { 41 | const { database } = this.props 42 | database.ref('/group-codes') 43 | .once('value') 44 | .then(snapshot => { 45 | let codes = [] 46 | snapshot.forEach(child => { 47 | let childData = child.val() 48 | if (childData.group === store.settings.defaultGroupId) { 49 | codes.push(childData.code) 50 | } 51 | }) 52 | if (codes.length) { 53 | this.setState({codes: codes}) 54 | } 55 | }) 56 | } 57 | } 58 | 59 | fetchOtherGroups = () => { 60 | const { database } = this.props 61 | // if (store.currentGroup && store.settings.defaultGroupId) { 62 | database.ref('/user-groups/' + store.currentUser.uid) 63 | .once('value') 64 | .then(snapshot => { 65 | let otherGroups = [] 66 | snapshot.forEach(function(child) { 67 | var childData = child.val() 68 | if ( 69 | !store.settings.defaultGroupId || 70 | childData.group !== store.settings.defaultGroupId 71 | ) { 72 | otherGroups.push({ 73 | id: childData.group, 74 | name: childData.name, 75 | membership: childData.membership, 76 | }) 77 | } 78 | }) 79 | if (otherGroups.length) { 80 | if (!store.currentGroup && otherGroups.length === 1) { 81 | store.currentGroup = otherGroups[0] 82 | store.setSetting('defaultGroupId', otherGroups[0].id) 83 | this.fetchOtherGroups() 84 | } else { 85 | this.setState({otherGroups: otherGroups}) 86 | } 87 | } 88 | }) 89 | } 90 | 91 | render() { 92 | return ( 93 |
94 |

Group

95 | 96 |

97 | Everything you save has to belong to a group.
98 | It can be just you in the group. 99 |

100 | 101 | { 102 | store.currentGroup ? 103 |
You're currently in the { store.currentGroup.name } group.
104 | : null 105 | } 106 | 107 | { 108 | this.state.codes ? 109 | 110 | : null 111 | } 112 | 113 | { 114 | this.state.otherGroups ? 115 | { 117 | store.currentGroup = group 118 | store.setSetting('defaultGroupId', group.id) 119 | this.fetchOtherGroups() 120 | this.fetchCurrentGroupCodes() 121 | this.props.onDefaultGroupChanged() 122 | }} 123 | otherGroups={this.state.otherGroups}/> 124 | : null 125 | } 126 | 127 | { 128 | this.state.changeGroup || !store.currentGroup ? 129 | { 132 | let codes = this.state.codes || [] 133 | codes.push(code) 134 | this.setState({codes: codes}) 135 | }} 136 | onClose={() => { 137 | this.setState({changeGroup: false}) 138 | }} 139 | /> : 140 | 141 | } 142 | 143 | { 144 | this.state.joinOtherGroup ? 145 | { 148 | this.setState({joinOtherGroup: false}) 149 | }} 150 | /> : 151 | 160 | } 161 | 162 | 169 |
170 | ) 171 | } 172 | }) 173 | 174 | export default Group 175 | 176 | 177 | class JoinCreateGroup extends Component { 178 | 179 | constructor(props) { 180 | super(props) 181 | this.state = { 182 | createError: null, 183 | joinError: null, 184 | } 185 | } 186 | 187 | render() { 188 | 189 | const { database } = this.props 190 | 191 | return ( 192 |
193 |
{ 196 | e.preventDefault() 197 | let name = this.refs.name.value.trim() 198 | 199 | if (!name) { 200 | return 201 | } 202 | const newGroupCode = randomGroupCode() 203 | const newPostKey = database.ref('groups').push().key 204 | database.ref('groups/' + newPostKey).set({ 205 | name: name, 206 | members: {}, // will be filled in soon 207 | // codes: [newGroupCode], 208 | days: [], 209 | }).then(() => { 210 | // add yourself to the group 211 | let updates = {} 212 | updates[store.currentUser.uid] = 'owner' 213 | database.ref('groups/' + newPostKey + '/members') 214 | .update(updates) 215 | .then(() => { 216 | database.ref('user-groups/' + store.currentUser.uid).push({ 217 | group: newPostKey, 218 | name: name, 219 | }) 220 | store.currentGroup = { 221 | id: newPostKey, 222 | name: name, 223 | membership: 'owner', 224 | } 225 | store.setSetting('defaultGroupId', newPostKey) 226 | this.props.updateCurrentGroupCode(newGroupCode) 227 | database.ref('group-codes').push({ 228 | code: newGroupCode, 229 | group: newPostKey, 230 | name: name, 231 | }) 232 | this.props.onClose(true) 233 | }) 234 | .catch(error => { 235 | this.createError = error 236 | }) 237 | }) 238 | .catch(error => { 239 | this.createError = error 240 | }) 241 | }}> 242 |
Create New Group
243 | 244 |
246 | 251 |
252 | 258 | 259 | 262 | 263 | 264 | 265 |
{ 268 | e.preventDefault() 269 | let code = this.refs.code.value.trim() 270 | 271 | if (!code) { 272 | return 273 | } 274 | 275 | 276 | }}> 277 |
Join Group
278 |
280 | 285 |
286 | 292 |
293 | 294 | {/* */} 303 |
304 | ) 305 | } 306 | } 307 | 308 | class JoinOtherGroup extends Component { 309 | 310 | constructor(props) { 311 | super(props) 312 | this.state = { 313 | joinError: null, 314 | notFound: false, 315 | } 316 | } 317 | 318 | render() { 319 | 320 | const { database } = this.props 321 | 322 | return ( 323 |
324 |
{ 327 | e.preventDefault() 328 | let code = this.refs.code.value.trim() 329 | 330 | if (!code) { 331 | return 332 | } 333 | code = code.toUpperCase() 334 | database.ref('group-codes') 335 | .once('value') 336 | .then(snapshot => { 337 | let found = false 338 | snapshot.forEach(child => { 339 | // var childKey = child.key; 340 | var childData = child.val(); 341 | if (childData.code === code) { 342 | found = true 343 | if (this.state.notFound) { 344 | this.setState({notFound: false}) 345 | } 346 | // We've found the group we want to join 347 | // Append to its list of members 348 | let updates = {} 349 | updates[store.currentUser.uid] = 'member' 350 | database.ref('groups/' + childData.group + '/members') 351 | .update(updates) 352 | .then(() => { 353 | // remember this as the current group 354 | store.currentGroup = { 355 | id: childData.group, 356 | name: childData.name, 357 | } 358 | store.setSetting('defaultGroupId', childData.group) 359 | // Update user-groups 360 | database.ref('user-groups/' + store.currentUser.uid) 361 | .push({ 362 | group: childData.group, 363 | name: childData.name, 364 | }) 365 | }) 366 | this.props.onClose() 367 | } 368 | }) 369 | if (!found) { 370 | this.setState({notFound: true}) 371 | } 372 | }, error => { 373 | console.error(error); 374 | }) 375 | // const newGroupCode = randomGroupCode() 376 | // const newPostKey = database.ref('groups').push().key 377 | // database.ref('groups/' + newPostKey).set({ 378 | // name: name, 379 | // members: {}, // will be filled in soon 380 | // codes: [newGroupCode], 381 | // days: [], 382 | // }).then(() => { 383 | // // add yourself to the group 384 | // let updates = {} 385 | // updates[store.currentUser.uid] = 'owner' 386 | // database.ref('groups/' + newPostKey + '/members') 387 | // .update(updates) 388 | // .then(() => { 389 | // database.ref('user-groups/' + store.currentUser.uid).push(newPostKey) 390 | // store.currentGroup = { 391 | // id: newPostKey, 392 | // name: name 393 | // } 394 | // store.setSetting('defaultGroupId', newPostKey) 395 | // this.props.updateCurrentGroupCode(newGroupCode) 396 | // this.props.onClose(true) 397 | // }) 398 | // .catch(error => { 399 | // this.createError = error 400 | // }) 401 | // }) 402 | // .catch(error => { 403 | // this.createError = error 404 | // }) 405 | }}> 406 |

Join Other Group

407 |
409 | 414 | { 415 | this.state.notFound ? 416 |
417 | Group not found 418 |
419 | : null 420 | } 421 |
422 | 423 | 429 | 438 |
439 |
440 | ) 441 | } 442 | } 443 | 444 | 445 | const ManageGroupCodes = ({ codes }) => { 446 | return ( 447 |
448 |

To invite others into this group, give them one of these codes:

449 |
    450 | { 451 | codes.map(code => { 452 | return
  • { code }
  • 453 | }) 454 | } 455 |
456 |
457 | ) 458 | } 459 | 460 | const ListOtherGroups = ({ otherGroups, joinOtherGroup }) => { 461 | return ( 462 |
463 |
Your Groups
464 | { 465 | otherGroups.map(group => { 466 | return ( 467 | 475 | ) 476 | }) 477 | } 478 |
479 | ) 480 | } 481 | -------------------------------------------------------------------------------- /src/Day.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import Highlighter from 'react-highlight-words' 3 | import { observer } from 'mobx-react' 4 | import ReactCSSTransitionGroup from 'react-addons-css-transition-group' 5 | import zenscroll from 'zenscroll' 6 | import Linkify from 'react-linkify' 7 | import { pure } from 'recompose' 8 | import shallowEqual from 'fbjs/lib/shallowEqual' 9 | 10 | import { 11 | format, 12 | formatDistanceStrict, 13 | isSameDay, 14 | startOfDay, 15 | addDays, 16 | subDays, 17 | } from 'date-fns/esm' 18 | 19 | import { 20 | makeDayId, 21 | makeWeekId, 22 | Heart, 23 | ShowWeekHeaderDates, 24 | debounce, 25 | pagifyPromptText, 26 | } from './Common' 27 | import store from './Store' 28 | 29 | const dateFns = { 30 | format: format, 31 | formatDistanceStrict: formatDistanceStrict, 32 | isSameDay: isSameDay, 33 | startOfDay: startOfDay, 34 | addDays: addDays, 35 | subDays: subDays, 36 | } 37 | 38 | 39 | const Day = observer(class Day extends Component { 40 | 41 | constructor(props) { 42 | super(props) 43 | const { day } = this.props 44 | this.state = { 45 | text: day.text, 46 | notes: day.notes, 47 | starred: day.starred, 48 | searchResults: {}, 49 | edit: false, 50 | saving: false, 51 | saved: true, 52 | hadText: false, 53 | } 54 | this.saveChanges = this.saveChanges.bind(this) 55 | this.autoCompleteSearch = debounce(this.autoCompleteSearch.bind(this), 300) 56 | this.inputBlurred = this.inputBlurred.bind(this) 57 | this.inputFocused = this.inputFocused.bind(this) 58 | this.fieldClicked = this.fieldClicked.bind(this) 59 | } 60 | 61 | fieldClicked(field) { 62 | this.startEdit(field) 63 | } 64 | 65 | saveChanges() { 66 | this.props.updateDay(this.props.day, { 67 | text: this.state.text.trim(), 68 | notes: this.state.notes.trim(), 69 | starred: this.state.starred, 70 | }).then(r => { 71 | this.setState({ 72 | text: this.state.text.trim(), 73 | notes: this.state.notes.trim(), 74 | saved: true, 75 | searchResults: {}, 76 | }) 77 | }) 78 | } 79 | 80 | inputBlurred(event) { 81 | this.closeEditSoon = window.setTimeout(() => { 82 | this.setState({edit: false}) 83 | }, 300) 84 | this.saveSoon = window.setTimeout(() => { 85 | this.saveChanges() 86 | }, 1000) 87 | } 88 | 89 | inputFocused(event) { 90 | if (event.target.setSelectionRange) { 91 | const inputLength = event.target.value.length 92 | event.target.setSelectionRange(inputLength, inputLength) 93 | } 94 | if (this.closeEditSoon) { 95 | window.clearTimeout(this.closeEditSoon) 96 | } 97 | if (this.saveSoon) { 98 | window.clearTimeout(this.saveSoon) 99 | } 100 | } 101 | 102 | autoCompleteSearch(text, field) { 103 | if (text.length < 2) { 104 | if (this.state.searchResults[field]) { 105 | this.setState({searchResults: {}}) 106 | } 107 | return 108 | } 109 | let searchResults = {} 110 | const searchConfig = { 111 | fields: { 112 | text: field === 'text' ? 1 : 0, 113 | notes: field === 'notes' ? 1 : 0, 114 | }, 115 | // Important otherwise the suggestions won't go away when 116 | // start typing a lot more. 117 | bool: 'AND', 118 | expand: true, 119 | } 120 | const results = this.props.searcher(text, searchConfig) 121 | let filteredResults = [] 122 | results.forEach(r => { 123 | if (r.date !== this.props.day.date) { 124 | filteredResults.push(r) 125 | } 126 | }) 127 | searchResults[field] = filteredResults 128 | this.setState({searchResults: searchResults}) 129 | } 130 | 131 | startEdit(focusOn = 'text') { 132 | const { day } = this.props 133 | // const hadText = 134 | this.setState({ 135 | text: day.text, 136 | notes: day.notes, 137 | starred: day.starred, 138 | edit: true, 139 | hadText: !!this.state.text, 140 | }, () => { 141 | const parentElement = document.querySelector( 142 | '#' + makeDayId(this.props.day.datetime) 143 | ) 144 | if (parentElement) { 145 | const element = parentElement.querySelector( 146 | `textarea.${focusOn}` 147 | ) 148 | if (element) { 149 | element.focus() 150 | } 151 | } 152 | }) 153 | } 154 | 155 | shouldComponentUpdate(nextProps, nextState) { 156 | let a = { 157 | text: this.props.day.text, 158 | notes: this.props.day.notes, 159 | starred: this.props.day.starred, 160 | firstDateThisWeek: this.props.firstDateThisWeek, 161 | } 162 | let b = { 163 | text: nextProps.day.text, 164 | notes: nextProps.day.notes, 165 | starred: nextProps.day.starred, 166 | firstDateThisWeek: nextProps.firstDateThisWeek, 167 | } 168 | return !shallowEqual(a, b) || !shallowEqual(this.state, nextState) 169 | // // let b = nextProps 170 | // // console.log('Props?', shallowEqual(a, b), 'State?', shallowEqual(this.state, nextState)); 171 | // 172 | // // return !shallowEqual(a, b) || !shallowEqual(this.state, nextState) 173 | // let r= !shallowEqual(a, b) || !shallowEqual(this.state, nextState) 174 | // if (r) { 175 | // if (!shallowEqual(a, b)) { 176 | // console.log('DIFFERENT this.props:', a, 'nextProps:', nextProps); 177 | // } 178 | // if (!shallowEqual(this.state, nextState)) { 179 | // console.log('DIFFERENT this.state:', this.state, 'nextState:', nextState); 180 | // } 181 | // } 182 | // return r 183 | } 184 | 185 | fieldClicked = (field) => { 186 | this.startEdit(field) 187 | } 188 | 189 | render() { 190 | let { day, firstDateThisWeek } = this.props 191 | let display 192 | if (this.state.edit) { 193 | display = ( 194 |
195 |
{ 196 | // This will probably never trigger unless textareas are replaced with inputs 197 | e.preventDefault() 198 | }}> 199 |
200 |
201 | 212 | { 217 | if (this.closeEditSoon) { 218 | window.clearTimeout(this.closeEditSoon) 219 | } 220 | this.setState({text: text, saved: false, searchResults: {}}) 221 | }} 222 | /> 223 | 224 |
225 |
226 | 237 | { 242 | if (this.closeEditSoon) { 243 | window.clearTimeout(this.closeEditSoon) 244 | } 245 | text = pagifyPromptText(text) 246 | this.setState({notes: text, saved: false, searchResults: {}}) 247 | }} 248 | /> 249 |
250 |
251 |
252 |
253 |
254 | { 257 | this.setState({starred: !this.state.starred}, this.saveChanges) 258 | }} 259 | /> 260 |
261 |
262 | { 263 | !this.state.saved || this.state.saving ? 264 | 285 | : null 286 | } 287 | {' '} 288 | 298 | {' '} 299 | { 300 | this.state.text && this.state.hadText ? 301 | 325 | : null 326 | } 327 | {' '} 328 | { 329 | store.copied && store.copied.date !== day.date && store.copied.text !== this.state.text ? 330 | 350 | : null 351 | } 352 |
353 |
354 |
355 | ) 356 | } else { 357 | // Regular display mode 358 | display = 363 | } 364 | 365 | return ( 366 | 372 |
373 | { firstDateThisWeek ? : null } 374 |
375 |
{dateFns.format(day.datetime, 'dddd')}
376 |
377 | 378 |
379 |
380 | { display } 381 |
382 |
383 | ) 384 | } 385 | }) 386 | 387 | export default Day 388 | 389 | 390 | const ShowWeekdayHeadDate = pure( 391 | ({ datetime }) => { 392 | const now = new Date() 393 | let text 394 | if (dateFns.isSameDay(now, datetime)) { 395 | text = 'Today' 396 | } else if (dateFns.isSameDay(datetime, dateFns.subDays(now, 1))) { 397 | text = 'Yesterday' 398 | } else if (dateFns.isSameDay(datetime, dateFns.addDays(now, 1))) { 399 | text = 'Tomorrow' 400 | } else { 401 | text = dateFns.formatDistanceStrict( 402 | dateFns.startOfDay(now), 403 | datetime, 404 | {addSuffix: true} 405 | ) 406 | 407 | } 408 | 409 | return ( 410 | 411 | { text } 412 | 413 | ) 414 | 415 | }) 416 | 417 | export const DisplayDay = pure( 418 | ({ text, notes, starred, fieldClicked }) => { 419 | return ( 420 |
421 | { 422 | !text.trim() && !notes.trim() ? 423 |

fieldClicked && fieldClicked('text')}>empty

424 | : null 425 | } 426 |

fieldClicked && fieldClicked('text')}> 429 | { 430 | text.split('\n').map((item, key) => ( 431 | {item}
432 | )) 433 | } 434 |

435 |

fieldClicked && fieldClicked('notes')}> 438 | 440 | { notes } 441 | 442 |

443 | { starred ? 444 |

{}} 448 | />

: null 449 | } 450 |
451 | ) 452 | }) 453 | 454 | 455 | const ShowWeekHeader = pure( 456 | ({ datetime }) => { 457 | const id = makeWeekId(datetime) 458 | return ( 459 |

{ 460 | const id = makeDayId(datetime) 461 | // XXX Consider using refs instead. See TODO 462 | const element = document.querySelector('#' + id) 463 | zenscroll.to(element) 464 | }}> 465 | 468 |

469 | ) 470 | }) 471 | 472 | 473 | const ShowTextAutocomplete = pure( 474 | ({ text, results, picked, field = 'text' }) => { 475 | if (!results) { 476 | return null 477 | } 478 | if (!results.length) { 479 | return null 480 | } 481 | if (!text.trim()) { 482 | return null 483 | } 484 | const searchWords = text.match(/\b(\w+)\b/g).map(word => { 485 | // return '\b' + word 486 | return word 487 | }) 488 | return ( 489 |
490 |
    491 | { 492 | results.map(result => { 493 | return ( 494 |
  • picked(result[field])} 497 | > 498 | 503 |
  • 504 | ) 505 | }) 506 | } 507 |
508 |
509 | ) 510 | }) 511 | -------------------------------------------------------------------------------- /public/static/addtohomescreen.min.js: -------------------------------------------------------------------------------- 1 | !function(a,b){"function"==typeof define&&define.amd?define([],b):"object"==typeof exports?module.exports=b():a.returnExports=b()}(this,function(){function a(){window.removeEventListener("load",a,!1),f=!0}function b(a){return g=g||new b.Class(a)}function c(a,b){for(var c in b)a[c]=b[c];return a}function d(){"#ath"==document.location.hash&&history.replaceState("",window.document.title,document.location.href.split("#")[0]),h.test(document.location.href)&&history.replaceState("",window.document.title,document.location.href.replace(h,"$1")),i.test(document.location.search)&&history.replaceState("",window.document.title,document.location.href.replace(i,"$2"))}var e="addEventListener"in window,f=!1;"complete"===document.readyState?f=!0:e&&window.addEventListener("load",a,!1);var g,h=/\/ath(\/)?$/,i=/([\?&]ath=[^&]*$|&ath=[^&]*(&))/;b.intl={de_de:{ios:"Um diese Web-App zum Home-Bildschirm hinzuzufügen, tippen Sie auf %icon und dann Zum Home-Bildschirm.",android:'Um diese Web-App zum Home-Bildschirm hinzuzufügen, öffnen Sie das Menü und tippen dann auf Zum Startbildschirm hinzufügen. Wenn Ihr Gerät eine Menütaste hat, lässt sich das Browsermenü über diese öffnen. Ansonsten tippen Sie auf icon.'},da_dk:{ios:"For at tilføje denne web app til hjemmeskærmen: Tryk %icon og derefter Føj til hjemmeskærm.",android:'For at tilføje denne web app til hjemmeskærmen, åbn browser egenskaber menuen og tryk på Føj til hjemmeskærm. Denne menu kan tilgås ved at trykke på menu knappen, hvis din enhed har en, eller ved at trykke på det øverste højre menu ikon icon.'},en_us:{ios:"To add this web app to the home screen: tap %icon and then Add to Home Screen.",android:'To add this web app to the home screen open the browser option menu and tap on Add to homescreen. The menu can be accessed by pressing the menu hardware button if your device has one, or by tapping the top right menu icon icon.'},es_es:{ios:"Para añadir esta aplicación web a la pantalla de inicio: pulsa %icon y selecciona Añadir a pantalla de inicio.",android:'Para añadir esta aplicación web a la pantalla de inicio, abre las opciones y pulsa Añadir a pantalla inicio. El menú se puede acceder pulsando el botón táctil en caso de tenerlo, o bien el icono de la parte superior derecha de la pantalla icon.'},fi_fi:{ios:"Liitä tämä sovellus kotivalikkoon: klikkaa %icon ja tämän jälkeen Lisää kotivalikkoon.",android:'Lisätäksesi tämän sovelluksen aloitusnäytölle, avaa selaimen valikko ja klikkaa tähti -ikonia tai Lisää aloitusnäytölle tekstiä. Valikkoon pääsee myös painamalla menuvalikkoa, jos laitteessasi on sellainen tai koskettamalla oikealla yläkulmassa menu ikonia icon.'},fr_fr:{ios:"Pour ajouter cette application web sur l'écran d'accueil : Appuyez %icon et sélectionnez Ajouter sur l'écran d'accueil.",android:'Pour ajouter cette application web sur l\'écran d\'accueil : Appuyez sur le bouton "menu", puis sur Ajouter sur l\'écran d\'accueil. Le menu peut-être accessible en appyant sur le bouton "menu" du téléphone s\'il en possède un . Sinon, il se trouve probablement dans la coin supérieur droit du navigateur %icon.'},he_il:{ios:'להוספת האפליקציה למסך הבית: ללחוץ על %icon ואז הוסף למסך הבית.',android:'To add this web app to the home screen open the browser option menu and tap on Add to homescreen. The menu can be accessed by pressing the menu hardware button if your device has one, or by tapping the top right menu icon icon.'},it_it:{ios:"Per aggiungere questa web app alla schermata iniziale: premi %icon e poi Aggiungi a Home.",android:'Per aggiungere questa web app alla schermata iniziale, apri il menu opzioni del browser e premi su Aggiungi alla homescreen. Puoi accedere al menu premendo il pulsante hardware delle opzioni se la tua device ne ha uno, oppure premendo l\'icona icon in alto a destra.'},ja_jp:{ios:"このウェプアプリをホーム画面に追加するために%iconを押してホーム画面に追加。",android:'To add this web app to the home screen open the browser option menu and tap on Add to homescreen. The menu can be accessed by pressing the menu hardware button if your device has one, or by tapping the top right menu icon icon.'},ko_kr:{ios:"홈 화면에 바로가기 생성: %icon 을 클릭한 후 홈 화면에 추가.",android:'브라우저 옵션 메뉴의 홈 화면에 추가를 클릭하여 홈화면에 바로가기를 생성할 수 있습니다. 옵션 메뉴는 장치의 메뉴 버튼을 누르거나 오른쪽 상단의 메뉴 아이콘 icon을 클릭하여 접근할 수 있습니다.'},nb_no:{ios:"For å installere denne appen på hjem-skjermen: trykk på %icon og deretter Legg til på Hjem-skjerm.",android:'For å legge til denne webappen på startsiden åpner en nettlesermenyen og velger Legg til på startsiden. Menyen åpnes ved å trykke på den fysiske menyknappen hvis enheten har det, eller ved å trykke på menyikonet øverst til høyre icon.'},pt_br:{ios:"Para adicionar este app à tela de início: clique %icon e então Tela de início.",android:'To add this web app to the home screen open the browser option menu and tap on Add to homescreen. The menu can be accessed by pressing the menu hardware button if your device has one, or by tapping the top right menu icon icon.'},pt_pt:{ios:"Para adicionar esta app ao ecrã principal: clique %icon e depois Ecrã principal.",android:'To add this web app to the home screen open the browser option menu and tap on Add to homescreen. The menu can be accessed by pressing the menu hardware button if your device has one, or by tapping the top right menu icon icon.'},nl_nl:{ios:"Om deze webapp op je telefoon te installeren, klik op %icon en dan Zet in beginscherm.",android:'To add this web app to the home screen open the browser option menu and tap on Add to homescreen. The menu can be accessed by pressing the menu hardware button if your device has one, or by tapping the top right menu icon icon.'},ru_ru:{ios:'Чтобы добавить этот сайт на свой домашний экран, нажмите на иконку %icon и затем На экран "Домой".',android:'Чтобы добавить сайт на свой домашний экран, откройте меню браузера и нажмите на Добавить на главный экран. Меню можно вызвать, нажав на кнопку меню вашего телефона, если она есть. Или найдите иконку сверху справа иконка.'},sv_se:{ios:"För att lägga till denna webbapplikation på hemskärmen: tryck på %icon och därefter Lägg till på hemskärmen.",android:'För att lägga till den här webbappen på hemskärmen öppnar du webbläsarens alternativ-meny och väljer Lägg till på startskärmen. Man hittar menyn genom att trycka på hårdvaruknappen om din enhet har en sådan, eller genom att trycka på menyikonen högst upp till höger icon.'},zh_cn:{ios:"如要把应用程序加至主屏幕,请点击%icon, 然后添加到主屏幕",android:'To add this web app to the home screen open the browser option menu and tap on Add to homescreen. The menu can be accessed by pressing the menu hardware button if your device has one, or by tapping the top right menu icon icon.'},zh_tw:{ios:"如要把應用程式加至主屏幕, 請點擊%icon, 然後加至主屏幕.",android:'To add this web app to the home screen open the browser option menu and tap on Add to homescreen. The menu can be accessed by pressing the menu hardware button if your device has one, or by tapping the top right menu icon icon.'}};for(var j in b.intl)b.intl[j.substr(0,2)]=b.intl[j];b.defaults={appID:"org.cubiq.addtohome",fontSize:15,debug:!1,logging:!1,modal:!1,mandatory:!1,autostart:!0,skipFirstVisit:!1,startDelay:1,lifespan:15,displayPace:1440,maxDisplayCount:0,icon:!0,message:"",validLocation:[],onInit:null,onShow:null,onRemove:null,onAdd:null,onPrivate:null,privateModeOverride:!1,detectHomescreen:!1};var k=window.navigator.userAgent,l=window.navigator;c(b,{hasToken:"#ath"==document.location.hash||h.test(document.location.href)||i.test(document.location.search),isRetina:window.devicePixelRatio&&window.devicePixelRatio>1,isIDevice:/iphone|ipod|ipad/i.test(k),isMobileChrome:k.indexOf("Android")>-1&&/Chrome\/[.0-9]*/.test(k)&&-1==k.indexOf("Version"),isMobileIE:k.indexOf("Windows Phone")>-1,language:l.language&&l.language.toLowerCase().replace("-","_")||""}),b.language=b.language&&b.language in b.intl?b.language:"en_us",b.isMobileSafari=b.isIDevice&&k.indexOf("Safari")>-1&&k.indexOf("CriOS")<0,b.OS=b.isIDevice?"ios":b.isMobileChrome?"android":b.isMobileIE?"windows":"unsupported",b.OSVersion=k.match(/(OS|Android) (\d+[_\.]\d+)/),b.OSVersion=b.OSVersion&&b.OSVersion[2]?+b.OSVersion[2].replace("_","."):0,b.isStandalone="standalone"in window.navigator&&window.navigator.standalone,b.isTablet=b.isMobileSafari&&k.indexOf("iPad")>-1||b.isMobileChrome&&k.indexOf("Mobile")<0,b.isCompatible=b.isMobileSafari&&b.OSVersion>=6||b.isMobileChrome;var m={lastDisplayTime:0,returningVisitor:!1,displayCount:0,optedout:!1,added:!1};b.removeSession=function(a){try{if(!localStorage)throw new Error("localStorage is not defined");localStorage.removeItem(a||b.defaults.appID)}catch(c){}},b.doLog=function(a){this.options.logging&&console.log(a)},b.Class=function(a){if(this.doLog=b.doLog,this.options=c({},b.defaults),c(this.options,a),a&&a.debug&&"undefined"==typeof a.logging&&(this.options.logging=!0),e){if(this.options.mandatory=this.options.mandatory&&("standalone"in window.navigator||this.options.debug),this.options.modal=this.options.modal||this.options.mandatory,this.options.mandatory&&(this.options.startDelay=-.5),this.options.detectHomescreen=this.options.detectHomescreen===!0?"hash":this.options.detectHomescreen,this.options.debug&&(b.isCompatible=!0,b.OS="string"==typeof this.options.debug?this.options.debug:"unsupported"==b.OS?"android":b.OS,b.OSVersion="ios"==b.OS?"8":"4"),this.container=document.documentElement,this.session=this.getItem(this.options.appID),this.session=this.session?JSON.parse(this.session):void 0,!b.hasToken||b.isCompatible&&this.session||(b.hasToken=!1,d()),!b.isCompatible)return void this.doLog("Add to homescreen: not displaying callout because device not supported");this.session=this.session||m;try{if(!localStorage)throw new Error("localStorage is not defined");localStorage.setItem(this.options.appID,JSON.stringify(this.session)),b.hasLocalStorage=!0}catch(f){b.hasLocalStorage=!1,this.options.onPrivate&&this.options.onPrivate.call(this)}for(var g=!this.options.validLocation.length,h=this.options.validLocation.length;h--;)if(this.options.validLocation[h].test(document.location.href)){g=!0;break}if(this.getItem("addToHome")&&this.optOut(),this.session.optedout)return void this.doLog("Add to homescreen: not displaying callout because user opted out");if(this.session.added)return void this.doLog("Add to homescreen: not displaying callout because already added to the homescreen");if(!g)return void this.doLog("Add to homescreen: not displaying callout because not a valid location");if(b.isStandalone)return this.session.added||(this.session.added=!0,this.updateSession(),this.options.onAdd&&b.hasLocalStorage&&this.options.onAdd.call(this)),void this.doLog("Add to homescreen: not displaying callout because in standalone mode");if(this.options.detectHomescreen){if(b.hasToken)return d(),this.session.added||(this.session.added=!0,this.updateSession(),this.options.onAdd&&b.hasLocalStorage&&this.options.onAdd.call(this)),void this.doLog("Add to homescreen: not displaying callout because URL has token, so we are likely coming from homescreen");"hash"==this.options.detectHomescreen?history.replaceState("",window.document.title,document.location.href+"#ath"):"smartURL"==this.options.detectHomescreen?history.replaceState("",window.document.title,document.location.href.replace(/(\/)?$/,"/ath$1")):history.replaceState("",window.document.title,document.location.href+(document.location.search?"&":"?")+"ath=")}if(!this.session.returningVisitor&&(this.session.returningVisitor=!0,this.updateSession(),this.options.skipFirstVisit))return void this.doLog("Add to homescreen: not displaying callout because skipping first visit");if(!this.options.privateModeOverride&&!b.hasLocalStorage)return void this.doLog("Add to homescreen: not displaying callout because browser is in private mode");this.ready=!0,this.options.onInit&&this.options.onInit.call(this),this.options.autostart&&(this.doLog("Add to homescreen: autostart displaying callout"),this.show())}},b.Class.prototype={events:{load:"_delayedShow",error:"_delayedShow",orientationchange:"resize",resize:"resize",scroll:"resize",click:"remove",touchmove:"_preventDefault",transitionend:"_removeElements",webkitTransitionEnd:"_removeElements",MSTransitionEnd:"_removeElements"},handleEvent:function(a){var b=this.events[a.type];b&&this[b](a)},show:function(a){if(this.options.autostart&&!f)return void setTimeout(this.show.bind(this),50);if(this.shown)return void this.doLog("Add to homescreen: not displaying callout because already shown on screen");var c=Date.now(),d=this.session.lastDisplayTime;if(a!==!0){if(!this.ready)return void this.doLog("Add to homescreen: not displaying callout because not ready");if(c-d<6e4*this.options.displayPace)return void this.doLog("Add to homescreen: not displaying callout because displayed recently");if(this.options.maxDisplayCount&&this.session.displayCount>=this.options.maxDisplayCount)return void this.doLog("Add to homescreen: not displaying callout because displayed too many times already")}this.shown=!0,this.session.lastDisplayTime=c,this.session.displayCount++,this.updateSession(),this.applicationIcon||("ios"==b.OS?this.applicationIcon=document.querySelector('head link[rel^=apple-touch-icon][sizes="152x152"],head link[rel^=apple-touch-icon][sizes="144x144"],head link[rel^=apple-touch-icon][sizes="120x120"],head link[rel^=apple-touch-icon][sizes="114x114"],head link[rel^=apple-touch-icon]'):this.applicationIcon=document.querySelector('head link[rel^="shortcut icon"][sizes="196x196"],head link[rel^=apple-touch-icon]'));var e="";"object"==typeof this.options.message&&b.language in this.options.message?e=this.options.message[b.language][b.OS]:"object"==typeof this.options.message&&b.OS in this.options.message?e=this.options.message[b.OS]:this.options.message in b.intl?e=b.intl[this.options.message][b.OS]:""!==this.options.message?e=this.options.message:b.OS in b.intl[b.language]&&(e=b.intl[b.language][b.OS]),e="

"+e.replace("%icon",'icon')+"

",this.viewport=document.createElement("div"),this.viewport.className="ath-viewport",this.options.modal&&(this.viewport.className+=" ath-modal"),this.options.mandatory&&(this.viewport.className+=" ath-mandatory"),this.viewport.style.position="absolute",this.element=document.createElement("div"),this.element.className="ath-container ath-"+b.OS+" ath-"+b.OS+(b.OSVersion+"").substr(0,1)+" ath-"+(b.isTablet?"tablet":"phone"),this.element.style.cssText="-webkit-transition-property:-webkit-transform,opacity;-webkit-transition-duration:0s;-webkit-transition-timing-function:ease-out;transition-property:transform,opacity;transition-duration:0s;transition-timing-function:ease-out;",this.element.style.webkitTransform="translate3d(0,-"+window.innerHeight+"px,0)",this.element.style.transform="translate3d(0,-"+window.innerHeight+"px,0)",this.options.icon&&this.applicationIcon&&(this.element.className+=" ath-icon",this.img=document.createElement("img"),this.img.className="ath-application-icon",this.img.addEventListener("load",this,!1),this.img.addEventListener("error",this,!1),this.img.src=this.applicationIcon.href,this.element.appendChild(this.img)),this.element.innerHTML+=e,this.viewport.style.left="-99999em",this.viewport.appendChild(this.element),this.container.appendChild(this.viewport),this.img?this.doLog("Add to homescreen: not displaying callout because waiting for img to load"):this._delayedShow()},_delayedShow:function(a){setTimeout(this._show.bind(this),1e3*this.options.startDelay+500)},_show:function(){var a=this;this.updateViewport(),window.addEventListener("resize",this,!1),window.addEventListener("scroll",this,!1),window.addEventListener("orientationchange",this,!1),this.options.modal&&document.addEventListener("touchmove",this,!0),this.options.mandatory||setTimeout(function(){a.element.addEventListener("click",a,!0)},1e3),setTimeout(function(){a.element.style.webkitTransitionDuration="0.5s ease-in",a.element.style.transitionDuration="0.5s ease-in",a.element.style.webkitTransform="translate3d(0,0,0)",a.element.style.transform="translate3d(0,0,0)"},0),this.options.lifespan&&(this.removeTimer=setTimeout(this.remove.bind(this),1e3*this.options.lifespan)),this.options.onShow&&this.options.onShow.call(this)},remove:function(){clearTimeout(this.removeTimer),this.img&&(this.img.removeEventListener("load",this,!1),this.img.removeEventListener("error",this,!1)),window.removeEventListener("resize",this,!1),window.removeEventListener("scroll",this,!1),window.removeEventListener("orientationchange",this,!1),document.removeEventListener("touchmove",this,!0),this.element.removeEventListener("click",this,!0),this.element.addEventListener("transitionend",this,!1),this.element.addEventListener("webkitTransitionEnd",this,!1),this.element.addEventListener("MSTransitionEnd",this,!1),this.element.style.webkitTransitionDuration="0.3s",this.element.style.opacity="0"},_removeElements:function(){this.element.removeEventListener("transitionend",this,!1),this.element.removeEventListener("webkitTransitionEnd",this,!1),this.element.removeEventListener("MSTransitionEnd",this,!1),this.container.removeChild(this.viewport),this.shown=!1,this.options.onRemove&&this.options.onRemove.call(this)},updateViewport:function(){if(this.shown){this.viewport.style.width=window.innerWidth+"px",this.viewport.style.height=window.innerHeight+"px",this.viewport.style.left=window.scrollX+"px",this.viewport.style.top=window.scrollY+"px";var a=document.documentElement.clientWidth;this.orientation=a>document.documentElement.clientHeight?"landscape":"portrait";var c="ios"==b.OS?"portrait"==this.orientation?screen.width:screen.height:screen.width;this.scale=screen.width>a?1:c/window.innerWidth,this.element.style.fontSize=this.options.fontSize/this.scale+"px"}},resize:function(){clearTimeout(this.resizeTimer),this.resizeTimer=setTimeout(this.updateViewport.bind(this),100)},updateSession:function(){b.hasLocalStorage!==!1&&localStorage&&localStorage.setItem(this.options.appID,JSON.stringify(this.session))},clearSession:function(){this.session=m,this.updateSession()},getItem:function(a){try{if(!localStorage)throw new Error("localStorage is not defined");return localStorage.getItem(a)}catch(c){b.hasLocalStorage=!1}},optOut:function(){this.session.optedout=!0,this.updateSession()},optIn:function(){this.session.optedout=!1,this.updateSession()},clearDisplayCount:function(){this.session.displayCount=0,this.updateSession()},_preventDefault:function(a){a.preventDefault(),a.stopPropagation()}},window.addToHomescreen=b}); -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import elasticlunr from 'elasticlunr' 3 | import { observer } from 'mobx-react' 4 | import zenscroll from 'zenscroll' 5 | import * as firebase from 'firebase/app' 6 | import 'firebase/auth' 7 | import 'firebase/database' 8 | import { pure } from 'recompose' 9 | 10 | import { 11 | format, 12 | getTime, 13 | startOfWeek, 14 | addDays, 15 | toDate, 16 | } from 'date-fns/esm' 17 | 18 | // import Perf from 'react-addons-perf' 19 | // window.Perf = Perf 20 | 21 | import './App.css' 22 | import Nav from './Nav' 23 | import Days from './Days' 24 | import Settings from './Settings' 25 | import Favorites from './Favorites' 26 | import User from './User' 27 | import SignIn from './SignIn' 28 | import Search from './Search' 29 | import Group from './Group' 30 | import store from './Store' 31 | import { makeDayId, pagifyScrubText } from './Common' 32 | 33 | // Because I love namespaces 34 | const dateFns = { 35 | toDate: toDate, 36 | getTime: getTime, 37 | startOfWeek: startOfWeek, 38 | addDays: addDays, 39 | format: format, 40 | } 41 | 42 | const DATE_FORMAT = 'YYYY-MM-DD' 43 | 44 | // string to Date object 45 | const decodeDatetime = dateStr => dateFns.toDate(dateStr) 46 | // Date object to string 47 | const encodeDatetime = dateObj => dateFns.getTime(dateObj) 48 | 49 | if (process.env.REACT_APP_DEV === 'true') { 50 | store.dev = true 51 | document.title = 'Dev ' + document.title 52 | } 53 | 54 | const App = observer(class App extends Component { 55 | constructor() { 56 | super() 57 | 58 | this.state = { 59 | page: 'days', 60 | } 61 | 62 | // Initialize Firebase 63 | const config = { 64 | apiKey: process.env.REACT_APP_FIREBASE_API_KEY, 65 | authDomain: process.env.REACT_APP_FIREBASE_AUTH_DOMAIN, 66 | databaseURL: process.env.REACT_APP_FIREBASE_DATABASE_URL, 67 | storageBucket: process.env.REACT_APP_FIREBASE_STORAGE_BUCKET, 68 | messagingSenderId: process.env.REACT_APP_FIREBASE_MESSAGING_SENDER_ID, 69 | } 70 | const firebaseApp = firebase.initializeApp(config) 71 | this.auth = firebaseApp.auth() 72 | this.auth.onAuthStateChanged(this.onAuthStateChanged.bind(this)) 73 | if (process.env.REACT_APP_FIREBASE_LOGGING === 'true') { 74 | firebaseApp.database.enableLogging(true) 75 | } 76 | this.database = firebaseApp.database() 77 | } 78 | 79 | onAuthStateChanged = (user) => { 80 | 81 | // Perf.start() 82 | 83 | if (user) { 84 | // User is signed in! 85 | // let profilePicUrl = user.photoURL 86 | // let userName = user.displayName 87 | store.currentUser = user 88 | // If you're signed in, let's set what you're current group is 89 | if (!store.currentGroup && store.settings.defaultGroupId) { 90 | this.database.ref('/groups/' + store.settings.defaultGroupId) 91 | .once('value') 92 | .then(snapshot => { 93 | if (snapshot.val()) { 94 | store.currentGroup = { 95 | id: store.settings.defaultGroupId, 96 | name: snapshot.val().name 97 | } 98 | } 99 | }, error => { 100 | console.warn('Unable to look up group'); 101 | console.error(error); 102 | }) 103 | } else { 104 | // console.warn('Figure out which is your default group!'); 105 | this.setState({page: 'group'}) 106 | } 107 | 108 | // Only bother for people who bother to sign in. 109 | if (window.addToHomescreen) { 110 | setTimeout(() => { 111 | // window.addToHomescreen({debug: true}) 112 | window.addToHomescreen() 113 | }, 3000) 114 | } 115 | 116 | } else { 117 | // User is signed out! 118 | console.log('No user', 'Signed out?'); 119 | store.currentUser = false 120 | } 121 | } 122 | 123 | componentDidMount() { 124 | 125 | // setTimeout(() => { 126 | // Perf.stop() 127 | // Perf.printWasted() 128 | // }, 20000) 129 | 130 | this.searchData = {} 131 | 132 | if (this.database) { 133 | this.database.ref('.info/connected') 134 | .on('value', snapshot => { 135 | if (snapshot.val()) { // Yay! Connected! 136 | if (this.setOfflineTimeout) { 137 | window.clearTimeout(this.setOfflineTimeout) 138 | } 139 | this.triggerNotOffline() 140 | } else { 141 | console.log('navigator.onLine?', navigator.onLine); 142 | if (store.offline === null) { 143 | // It it hasn't been set yet, give it some time to do so 144 | this.setOfflineTimeout = window.setTimeout(() => { 145 | this.triggerOffline() 146 | }, 2000); 147 | } else { 148 | this.triggerOffline() 149 | } 150 | } 151 | }) 152 | } 153 | 154 | this.loadInitialWeek() 155 | 156 | if (store.settings.defaultGroupId) { 157 | // this.listenOnDayRefs() 158 | 159 | const searchIndexAsJson = localStorage.getItem('searchIndex') 160 | const searchDataAsJson = localStorage.getItem('searchData') 161 | if (searchIndexAsJson && searchDataAsJson) { 162 | // console.log('this.searchIndex created from JSON'); 163 | this.searchIndex = elasticlunr.Index.load( 164 | JSON.parse(searchIndexAsJson) 165 | ) 166 | this.searchData = JSON.parse(searchDataAsJson) 167 | 168 | // Offline or not, fill in as many days as we can from 169 | // the localStorage snapshot. 170 | const dayDates = Object.keys(this.searchData) 171 | if (dayDates.length) { 172 | dayDates.forEach(date => { 173 | let data = this.searchData[date] 174 | store.addDay( 175 | date, 176 | decodeDatetime(data.datetime), 177 | data.text, 178 | data.notes, 179 | data.starred 180 | ) 181 | }) 182 | } 183 | 184 | } else { 185 | // console.log('this.searchIndex created from, elasticlunr', elasticlunr); 186 | // this.searchIndex = elasticlunr(function () { 187 | // this.addField('text') 188 | // this.addField('notes') 189 | // this.setRef('date') 190 | // // not store the original JSON document to reduce the index size 191 | // this.saveDocument(false) 192 | // }) 193 | // this.searchData = {} 194 | this.loadSearchIndex() 195 | } 196 | } 197 | } 198 | 199 | triggerNotOffline = () => { 200 | store.offline = false 201 | } 202 | 203 | triggerOffline = () => { 204 | store.offline = true 205 | } 206 | 207 | listenOnDayRefs = () => { 208 | if (!store.dateRangeStart) { 209 | throw new Error('store.dateRangeStart not set') 210 | } 211 | if (!store.dateRangeEnd) { 212 | throw new Error('store.dateRangeEnd not set') 213 | } 214 | if (this.daysRef) { 215 | this.daysRef.off() 216 | } 217 | this.daysRef = this.database.ref( 218 | 'groups/' + store.settings.defaultGroupId + '/days' 219 | ) 220 | .orderByChild('datetime') 221 | .startAt(encodeDatetime(store.dateRangeStart)) 222 | .endAt(encodeDatetime(store.dateRangeEnd)) 223 | 224 | this.daysRef.on('child_added', child => { 225 | const data = child.val() 226 | // console.log('DAY ADDED!', child.key, data); 227 | store.addDay( 228 | child.key, 229 | decodeDatetime(data.datetime), 230 | data.text, 231 | data.notes, 232 | data.starred 233 | ) 234 | if (this.differentSearchData(child.key, data)) { 235 | this.searchData[child.key] = { 236 | date: child.key, 237 | datetime: data.datetime, 238 | text: data.text, 239 | notes: data.notes, 240 | starred: data.starred, 241 | } 242 | localStorage.setItem('searchData', JSON.stringify(this.searchData)) 243 | } 244 | }) 245 | this.daysRef.on('child_changed', child => { 246 | // console.log('DAY CHANGED!', child.key, child.val()); 247 | const data = child.val() 248 | store.addDay( 249 | child.key, 250 | decodeDatetime(data.datetime), 251 | data.text, 252 | data.notes, 253 | data.starred 254 | ) 255 | if (this.differentSearchData(child.key, data)) { 256 | 257 | this.searchData[child.key] = { 258 | date: child.key, 259 | datetime: data.datetime, 260 | text: data.text, 261 | notes: data.notes, 262 | starred: data.starred, 263 | } 264 | localStorage.setItem('searchData', JSON.stringify(this.searchData)) 265 | } 266 | }) 267 | } 268 | 269 | differentSearchData = (date, data) => { 270 | // compare 'data' with 'this.searchData[date]' and if any of them is 271 | // different return true. 272 | if (!this.searchData[date]) { 273 | return true 274 | } 275 | let differentKeys = Object.keys(data).filter(key => { 276 | return this.searchData[date] && data[key] !== this.searchData[date][key] 277 | }) 278 | return !!differentKeys.length 279 | } 280 | 281 | loadSearchIndex = () => { 282 | // console.log('this.searchIndex created from, elasticlunr', elasticlunr); 283 | this.searchIndex = elasticlunr(function () { 284 | this.addField('text') 285 | this.addField('notes') 286 | this.setRef('date') 287 | // not store the original JSON document to reduce the index size 288 | this.saveDocument(false) 289 | }) 290 | this.searchData = {} 291 | if (this._searchCache) { 292 | this._searchCache = {} 293 | } 294 | this.database.ref( 295 | 'groups/' + store.settings.defaultGroupId + '/days' 296 | ) 297 | .once('value', snapshot => { 298 | snapshot.forEach(child => { 299 | const data = child.val() 300 | if (data.text || data.notes) { 301 | this.searchIndex.addDoc({ 302 | date: child.key, 303 | text: data.text, 304 | notes: data.notes, 305 | }) 306 | this.searchData[child.key] = { 307 | date: child.key, 308 | datetime: data.datetime, 309 | text: data.text, 310 | notes: data.notes, 311 | starred: data.starred, 312 | } 313 | } 314 | }) 315 | localStorage.setItem( 316 | 'searchIndex', 317 | JSON.stringify(this.searchIndex) 318 | ) 319 | localStorage.setItem( 320 | 'searchData', 321 | JSON.stringify(this.searchData) 322 | ) 323 | }) 324 | } 325 | 326 | loadInitialWeek = () => { 327 | const weekStartsOnAMonday = store.settings.weekStartsOnAMonday || false 328 | store.firstDateThisWeek = dateFns.startOfWeek( 329 | new Date(), {weekStartsOn: weekStartsOnAMonday ? 1 : 0} 330 | ) 331 | return this.loadWeek(store.firstDateThisWeek) 332 | } 333 | 334 | loadWeek = (firstDate) => { // should maybe be called loadBlankWeekDays 335 | if (!firstDate || typeof firstDate !== 'object') { 336 | throw new Error("Expect 'firstDate' to be a date object") 337 | } 338 | let lastDate = dateFns.addDays(firstDate, 7) 339 | store.extendDateRange(firstDate, lastDate) 340 | let dayNumbers = [0, 1, 2, 3, 4, 5, 6] 341 | dayNumbers.forEach(d => { 342 | let datetime = dateFns.addDays(firstDate, d) 343 | let date = dateFns.format(datetime, DATE_FORMAT) 344 | if (!store.days.has(date)) { 345 | // put a blank one in lieu 346 | // console.log('ADDING', date, datetime); 347 | store.addDay(date, datetime) 348 | } else { 349 | // console.log('SKIPPING', date, datetime); 350 | } 351 | }) 352 | this.listenOnDayRefs() 353 | } 354 | 355 | updateDay = (day, data) => { 356 | // don't forget to update the big mutable 357 | day.text = data.text 358 | day.notes = data.notes 359 | day.starred = data.starred 360 | 361 | // update the search cache 362 | if (this._searchCache && this._searchCache[day.date]) { 363 | delete this._searchCache[day.date] 364 | } 365 | 366 | const dayRef = this.database.ref( 367 | 'groups/' + store.settings.defaultGroupId + '/days/' + day.date 368 | ) 369 | this.searchIndex.addDoc({ 370 | date: day.date, 371 | text: data.text, 372 | notes: data.notes, 373 | }) 374 | localStorage.setItem('searchIndex', JSON.stringify(this.searchIndex)) 375 | this.searchData[day.date] = { 376 | date: day.date, 377 | datetime: encodeDatetime(day.datetime), 378 | text: data.text, 379 | notes: data.notes, 380 | starred: data.starred, 381 | } 382 | localStorage.setItem('searchData', JSON.stringify(this.searchData)) 383 | if (store.currentUser) { 384 | return dayRef.set({ 385 | date: day.date, // XXX is this necessary 386 | datetime: encodeDatetime(day.datetime), 387 | text: data.text, 388 | notes: data.notes, 389 | starred: data.starred, 390 | }) 391 | } else { 392 | return Promise.resolve(null) 393 | } 394 | 395 | } 396 | 397 | getFavorites = () => { 398 | return this.database.ref( 399 | 'groups/' + store.settings.defaultGroupId + '/days' 400 | ) 401 | .orderByChild('starred') 402 | .equalTo(true) 403 | .once('value') 404 | .then(snapshot => { 405 | let results = [] 406 | // Strange, snapshot has a .forEach but no .map or .filter 407 | snapshot.forEach(child => { 408 | results.push(child.val()) 409 | }) 410 | results.sort((a, b) => { 411 | return b.datetime - a.datetime 412 | }) 413 | let hashes = new Set() 414 | results = results.filter(result => { 415 | let hash = result.text + result.notes 416 | hash = hash.toLowerCase() 417 | if (!hashes.has(hash)) { 418 | hashes.add(hash) 419 | return true 420 | } 421 | return false 422 | }) 423 | return results 424 | }) 425 | } 426 | 427 | searcher = (text, searchConfig) => { 428 | if (!text.trim()) { 429 | throw new Error('Empty search') 430 | } 431 | let found = this.searchIndex.search( 432 | text, 433 | searchConfig, 434 | ) 435 | console.log('FOUND', found); 436 | if (!found.length) { 437 | return [] 438 | } 439 | if (!this._searchCache) { 440 | // If this starts getting too large and bloated, 441 | // consider a LRU cache like 442 | // https://github.com/rsms/js-lru 443 | this._searchCache = {} 444 | } 445 | let refs = {} 446 | let cached = [] 447 | found.forEach(f => { 448 | refs[f.ref] = f.score 449 | if (this._searchCache[f.ref]) { 450 | cached.push(this._searchCache[f.ref]) 451 | } 452 | }) 453 | if (found.length === cached.length) { 454 | // we had all of them cached! 455 | return cached 456 | } 457 | let uniqueTexts = new Set() 458 | let key = 'text' 459 | if (searchConfig.fields.notes) { 460 | key = 'notes' 461 | } 462 | return found.map(result => { 463 | return this.searchData[result.ref] 464 | }).filter(result => { 465 | let hash = result[key].toLowerCase() 466 | if (key === 'notes') { 467 | // This will make "some cookbook page 123" and "some cookbook p.100" 468 | // both into "some cookbook page ?" 469 | hash = pagifyScrubText(hash) 470 | } 471 | if (!uniqueTexts.has(hash)) { 472 | uniqueTexts.add(hash) 473 | return true 474 | } 475 | return false 476 | }) 477 | } 478 | 479 | render() { 480 | let page =

Loading...

481 | 482 | if (this.state.page === 'settings') { 483 | page = { 485 | this.setState({page: 'days'}) 486 | }} 487 | onChangeWeekStart={() => { 488 | // if the user has changed start day of the week, re-load 489 | store.dateRangeStart = null 490 | store.dateRangeEnd = null 491 | this.loadInitialWeek() 492 | }} 493 | onClearCache={() => { 494 | localStorage.removeItem('searchData') 495 | localStorage.removeItem('searchIndex') 496 | window.location.reload(true) 497 | }} 498 | /> 499 | } else if (this.state.page === 'search') { 500 | page = { 503 | this.setState({page: 'days'}) 504 | // this.loadInitialWeek() 505 | }} 506 | /> 507 | } else if (this.state.page === 'starred') { 508 | page = { 511 | this.setState({page: 'days'}) 512 | if (!store.days.length) { 513 | this.loadInitialWeek() 514 | } 515 | }} 516 | /> 517 | } else if (this.state.page === 'user') { 518 | page = { 521 | this.auth.signOut().then(() => { 522 | // Sign-out successful. 523 | this.setState({page: 'days'}, () => { 524 | store.currentUser = null 525 | if (!store.days.length) { 526 | this.loadInitialWeek() 527 | } 528 | }) 529 | }, (error) => { 530 | // An error happened. 531 | console.warn('Unable to sign out'); 532 | console.error(error); 533 | }) 534 | }} 535 | onClosePage={e => { 536 | this.setState({page: 'days'}) 537 | if (!store.days.length) { 538 | this.loadInitialWeek() 539 | } 540 | }} 541 | /> 542 | } else if (this.state.page === 'signin') { 543 | page = { 546 | store.currentUser.sendEmailVerification().then(() => { 547 | // XXX flash message? 548 | console.log("Email verification email sent. Check your inbox."); 549 | }, error => { 550 | console.error(error); 551 | }) 552 | }} 553 | onClosePage={e => { 554 | // XXX if there is already a group in store.settings 555 | // or something, then no need to redirect to the group page. 556 | this.setState({page: 'group'}) 557 | }} 558 | /> 559 | } else if (this.state.page === 'group' && store.currentUser) { 560 | page = { 564 | store.days.clear() 565 | this.loadInitialWeek() 566 | this.daysRef.off() 567 | this.listenOnDayRefs() 568 | 569 | this.loadSearchIndex() 570 | }} 571 | onClosePage={e => { 572 | this.setState({page: 'days'}) 573 | if (!store.days.length) { 574 | this.loadInitialWeek() 575 | } 576 | }} 577 | /> 578 | } else { 579 | if (this.state.page !== 'days' && store.currentUser) { 580 | // throw new Error(`Unsure about page '${page}'`) 581 | console.warn(`Unsure about page '${this.state.page}'`) 582 | } 583 | page = { 588 | this.setState({page: 'signin'}) 589 | }} 590 | // firstDateThisWeek={store.firstDateThisWeek} 591 | /> 592 | } 593 | 594 | return ( 595 |
596 |
638 | ); 639 | } 640 | }) 641 | 642 | export default App 643 | 644 | 645 | const ShowOfflineWarning = pure( 646 | ({ offline }) => { 647 | if (offline !== true) { 648 | return null 649 | } 650 | return ( 651 |
652 |

Offline!

653 |

654 | Seems you are offline :( 655 |

656 |

657 | 666 |

667 |
668 | ) 669 | }) 670 | --------------------------------------------------------------------------------