From d3611ff88888802c2271791e29c06085ed89e70a Mon Sep 17 00:00:00 2001 From: Julian Sudendorf Date: Sat, 2 Feb 2019 13:58:15 +0100 Subject: [PATCH 1/8] add env variables --- .env.sample | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.env.sample b/.env.sample index ac8658b..dda00a0 100644 --- a/.env.sample +++ b/.env.sample @@ -1 +1,4 @@ PREACT_APP_TRACKING= + +PREACT_APP_DROPBOX_CLIENT_ID= +PREACT_APP_DROPBOX_REDIRCT_URI= \ No newline at end of file From c3ba930fa7696ed6cf0056d09fbc0a53d6b93174 Mon Sep 17 00:00:00 2001 From: Julian Sudendorf Date: Sat, 2 Feb 2019 13:58:28 +0100 Subject: [PATCH 2/8] add storage --- src/utils/storage/DropboxAdapter.js | 114 ++++++++++++++++++++++++++++ src/utils/storage/FileAdapter.js | 44 +++++++++++ src/utils/storage/index.js | 65 ++++++++++++++++ 3 files changed, 223 insertions(+) create mode 100644 src/utils/storage/DropboxAdapter.js create mode 100644 src/utils/storage/FileAdapter.js create mode 100644 src/utils/storage/index.js diff --git a/src/utils/storage/DropboxAdapter.js b/src/utils/storage/DropboxAdapter.js new file mode 100644 index 0000000..f53c946 --- /dev/null +++ b/src/utils/storage/DropboxAdapter.js @@ -0,0 +1,114 @@ +import Dropbox from 'dropbox'; +import { DB } from '../db'; + +const ACCESS_TOKEN = 'journalbook_dropbox_token'; + +export default class DropboxAdapter { + constructor({ clientId = '', redirectUri = '' }) { + this.clientId = clientId; + this.redirectUri = redirectUri; + console.log(this.redirectUri, redirectUri); + } + + isAuthenticated = () => !!localStorage.getItem(ACCESS_TOKEN); + + requestAuth = () => { + const dbx = new Dropbox.Dropbox({ clientId: this.clientId }); + const authUrl = dbx.getAuthenticationUrl(this.redirectUri); + window.location.href = authUrl; + }; + + authCallback = async (query, callback) => { + console.log(query); + const queryParts = {}; + window.location.hash + .trim() + .replace(/^(\?|#|&)/, '') + .split('&') + .forEach(param => { + const parts = param.replace(/\+/g, ' ').split('='); + // Firefox (pre 40) decodes `%3D` to `=` + // https://github.com/sindresorhus/query-string/pull/37 + let key = parts.shift(); + let val = parts.length > 0 ? parts.join('=') : undefined; + + key = decodeURIComponent(key); + + // missing `=` should be `null`: + // http://w3.org/TR/2012/WD-url-20120524/#collect-url-parameters + val = val === undefined ? null : decodeURIComponent(val); + + if (queryParts[key] === undefined) { + queryParts[key] = val; + } else if (Array.isArray(queryParts[key])) { + queryParts[key].push(val); + } else { + queryParts[key] = [queryParts[key], val]; + } + }); + + console.log(queryParts); + this.setAccessToken(queryParts.access_token); + + setTimeout(() => callback(), 3000); + }; + + logout() { + localStorage.removeItem(ACCESS_TOKEN); + } + + import = async () => + new Promise(async (resolve, reject) => { + const db = new DB(); + const dbx = new Dropbox.Dropbox({ + accessToken: localStorage.getItem(ACCESS_TOKEN), + }); + + try { + const file = await dbx.filesDownload({ path: '/journalbook.json' }); + + const reader = new FileReader(); + // This fires after the blob has been read/loaded. + reader.addEventListener('loadend', async e => { + const content = e.srcElement.result; + const data = JSON.parse(content); + await db.import(data); + return resolve(); + }); + + // Start reading the blob as text. + reader.readAsText(file.fileBlob); + } catch (e) { + console.error(e); + } + setTimeout(() => resolve(), 3000); + }); + + export = async () => { + const db = new DB(); + const dbx = new Dropbox.Dropbox({ + accessToken: localStorage.getItem(ACCESS_TOKEN), + }); + const MIME_TYPE = 'text/json;charset=utf-8'; + + const data = await db.export(); + const blob = new Blob([JSON.stringify(data, null, 4)], { + type: MIME_TYPE, + }); + const name = `journalbook.json`; + await dbx.filesUpload({ + path: '/' + name, + contents: blob, + mode: { '.tag': 'overwrite' }, + }); + return []; + }; + + sync = (data, cb) => { + setTimeout(() => cb(), 3000); + }; + + setAccessToken = accessToken => { + localStorage.setItem(ACCESS_TOKEN, accessToken); + }; +} diff --git a/src/utils/storage/FileAdapter.js b/src/utils/storage/FileAdapter.js new file mode 100644 index 0000000..0b53fba --- /dev/null +++ b/src/utils/storage/FileAdapter.js @@ -0,0 +1,44 @@ +import { ymd } from '../date'; +import { DB } from '../db'; + +export default class FileAdapter { + import = async () => + new Promise((resolve, reject) => { + const db = new DB(); + const reader = new FileReader(); + const file = event.target.files[0]; + + reader.onload = (() => async e => { + try { + const data = JSON.parse(e.target.result); + await db.import(data); + return resolve(); + } catch (e) { + reject(e); + } + })(); + + reader.readAsText(file); + }); + + export = async () => { + const db = new DB(); + try { + const MIME_TYPE = 'text/json;charset=utf-8'; + + const data = await db.export(); + const blob = new Blob([JSON.stringify(data, null, 4)], { + type: MIME_TYPE, + }); + const name = `journalbook_${ymd()}.json`; + + const file = { + name, + data: window.URL.createObjectURL(blob), + }; + return [file]; + } catch (e) { + console.error(e); + } + }; +} diff --git a/src/utils/storage/index.js b/src/utils/storage/index.js new file mode 100644 index 0000000..e8bd424 --- /dev/null +++ b/src/utils/storage/index.js @@ -0,0 +1,65 @@ +import FileAdapter from './FileAdapter'; +import DropboxAdapter from './DropboxAdapter'; + +const JOURNALBOOK_STORAGE_ADAPTER = 'journalbook_storage_adapter'; + +class Storage { + constructor(adapters) { + this.selectedAdapter = localStorage.getItem(JOURNALBOOK_STORAGE_ADAPTER); + this.adapters = Object.assign({ file: new FileAdapter() }, adapters); + if (!this.selectedAdapter) { + this.setAdapter('file'); + } + } + + get adapter() { + return this.adapters[this.selectedAdapter]; + } + + requestAuth(provider) { + if (provider === 'file') { + return 'Provider not allowed'; + } + const adapter = this.adapters[provider]; + if (!adapter) { + return 'No Authentication Provider found'; + } + + adapter.requestAuth(); + return 'Redirecting to Auth provider'; + } + + authCallback(provider, query, cb) { + if (provider === 'file') { + return 'Provider not allowed'; + } + const adapter = this.adapters[provider]; + if (!adapter) { + return 'No Authentication Provider found'; + } + + adapter.authCallback(query, cb); + return 'Processing...'; + } + + getAdapter() { + return this.selectedAdapter; + } + + setAdapter = adapter => { + localStorage.setItem(JOURNALBOOK_STORAGE_ADAPTER, adapter); + this.selectedAdapter = adapter; + }; + + sync = (data, cb) => { + this.adapters[this.selectedAdapter].sync(data, cb); + }; +} +const storage = new Storage({ + dropbox: new DropboxAdapter({ + clientId: process.env.PREACT_APP_DROPBOX_CLIENT_ID, + redirectUri: process.env.PREACT_APP_DROPBOX_REDIRCT_URI, + }), +}); + +export default storage; From 83905a8aa4067c4f83e26d68f992ed2d52a8dded Mon Sep 17 00:00:00 2001 From: Julian Sudendorf Date: Sat, 2 Feb 2019 14:03:56 +0100 Subject: [PATCH 3/8] add dropbox dependency --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index ee15720..5fa3778 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "prettier": "1.15.3" }, "dependencies": { + "dropbox": "^4.0.15", "eslint-config-prettier": "^3.3.0", "idb": "^3.0.2", "preact": "^8.2.6", From a61eb84017489f9fb26ae585cde4ee2c5920710a Mon Sep 17 00:00:00 2001 From: Julian Sudendorf Date: Sat, 2 Feb 2019 14:04:15 +0100 Subject: [PATCH 4/8] setup routing for OAuth --- src/components/app.js | 4 ++++ src/routes/auth-callback/index.js | 21 +++++++++++++++++++++ src/routes/auth/index.js | 17 +++++++++++++++++ 3 files changed, 42 insertions(+) create mode 100644 src/routes/auth-callback/index.js create mode 100644 src/routes/auth/index.js diff --git a/src/components/app.js b/src/components/app.js index 644a158..2d58b63 100644 --- a/src/components/app.js +++ b/src/components/app.js @@ -12,6 +12,8 @@ import Settings from '../routes/settings'; import GetStarted from '../routes/get-started'; import Highlights from '../routes/highlights'; import About from '../routes/about'; +import Auth from '../routes/auth'; +import AuthCallback from '../routes/auth-callback'; import NotFound from '../routes/not-found'; import { getDefaultTheme, prefersAnimation } from '../utils/theme'; import { connect } from 'unistore/preact'; @@ -98,6 +100,8 @@ class App extends Component { + + diff --git a/src/routes/auth-callback/index.js b/src/routes/auth-callback/index.js new file mode 100644 index 0000000..48212c9 --- /dev/null +++ b/src/routes/auth-callback/index.js @@ -0,0 +1,21 @@ +import { h, Component } from 'preact'; +import { route } from 'preact-router'; +import storage from '../../utils/storage'; + +class AuthCallback extends Component { + constructor(props) { + super(); + const { provider, ...query } = props; + const msg = storage.authCallback(provider, query, () => + route('/settings', true) + ); + this.state = { + msg, + }; + } + render({}, { msg }) { + return
{msg}
; + } +} + +export default AuthCallback; diff --git a/src/routes/auth/index.js b/src/routes/auth/index.js new file mode 100644 index 0000000..c5b34c5 --- /dev/null +++ b/src/routes/auth/index.js @@ -0,0 +1,17 @@ +import { h, Component } from 'preact'; +import storage from '../../utils/storage'; + +class Auth extends Component { + constructor(props) { + super(); + const { provider } = props; + const msg = storage.requestAuth(provider); + this.state = { msg }; + } + + render({}, { msg }) { + return
{msg}
; + } +} + +export default Auth; From 4bf1abac06e652875d9b808ca325adf5c88335dd Mon Sep 17 00:00:00 2001 From: Julian Sudendorf Date: Sat, 2 Feb 2019 14:06:20 +0100 Subject: [PATCH 5/8] fix eslint warnings --- src/routes/settings/index.js | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/routes/settings/index.js b/src/routes/settings/index.js index 93f0745..2dea16e 100644 --- a/src/routes/settings/index.js +++ b/src/routes/settings/index.js @@ -24,10 +24,8 @@ class Settings extends Component { this.setState({ questions }); } - updateSetting = key => { - return event => { - this.props.updateSetting({ key, value: event.target.value }); - }; + updateSetting = key => event => { + this.props.updateSetting({ key, value: event.target.value }); }; updateQuestion = (slug, value, attribute = 'text') => { @@ -157,9 +155,7 @@ class Settings extends Component { ); await Promise.all( - highlights.map(async key => { - return this.props.db.set('highlights', key, true); - }) + highlights.map(async key => this.props.db.set('highlights', key, true)) ); localStorage.setItem('journalbook_onboarded', true); From 26da9fb02e431a296b50c8bca5a2203f22fff08a Mon Sep 17 00:00:00 2001 From: Julian Sudendorf Date: Sat, 2 Feb 2019 14:35:41 +0100 Subject: [PATCH 6/8] add storage options; add styles; refine store --- src/routes/settings/index.js | 134 ++++++++++++++++++++++++++--------- src/store/index.js | 1 + src/style/index.css | 22 ++++++ 3 files changed, 125 insertions(+), 32 deletions(-) diff --git a/src/routes/settings/index.js b/src/routes/settings/index.js index 2dea16e..dac8d9b 100644 --- a/src/routes/settings/index.js +++ b/src/routes/settings/index.js @@ -1,5 +1,6 @@ import { h, Component } from 'preact'; import { ymd } from '../../utils/date'; +import { Link } from 'preact-router/match'; import { slugify } from '../../utils/slugify'; import { QuestionList } from '../../components/QuestionList'; import { AddQuestion } from '../../components/AddQuestion'; @@ -7,6 +8,7 @@ import { ScaryButton } from '../../components/ScaryButton'; import { getDefaultTheme, prefersAnimation } from '../../utils/theme'; import { actions } from '../../store/actions'; import { connect } from 'unistore/preact'; +import storage from '../../utils/storage'; class Settings extends Component { state = { @@ -28,6 +30,12 @@ class Settings extends Component { this.props.updateSetting({ key, value: event.target.value }); }; + updateStorageAdapter = () => event => { + const adapter = event.target.value; + this.props.updateSetting({ key: 'storageAdapter', value: adapter }); + storage.setAdapter(adapter); + }; + updateQuestion = (slug, value, attribute = 'text') => { const questions = [...this.state.questions]; const question = questions.find(x => x.slug === slug); @@ -178,6 +186,7 @@ class Settings extends Component { render({ settings = {} }, { questions, exporting, files, importing }) { const theme = settings.theme || getDefaultTheme(settings); + const storageAdapter = settings.storageAdapter || storage.getAdapter(); const animation = settings.animation || prefersAnimation(settings); return ( @@ -195,41 +204,102 @@ class Settings extends Component {

Manage your data

- {exporting === 2 && files.length ? ( - { - setTimeout(() => { - this.clean(); - this.setState({ exporting: 0 }); - }, 1500); - }} - > - Click to Download - - ) : ( - + +
+ + +
+ + {storageAdapter === 'file' && ( +
+ + {exporting === 2 && files.length ? ( + { + setTimeout(() => { + this.clean(); + this.setState({ exporting: 0 }); + }, 1500); + }} + > + Click to Download + + ) : ( + + )} + + + +
)} - - + {storageAdapter === 'dropbox' && ( +
+ + {storage.adapters.dropbox.isAuthenticated() ? ( +
+ + + Sign Out +
+ ) : ( + + Login with Dropbox + + )} +
+ )} + Delete your data diff --git a/src/store/index.js b/src/store/index.js index 7dd92f3..c6de1bb 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -4,6 +4,7 @@ const store = { settings: { theme: '', animation: '', + storageAdapter: '', }, db: null, }; diff --git a/src/style/index.css b/src/style/index.css index f1abc72..0ed07a0 100644 --- a/src/style/index.css +++ b/src/style/index.css @@ -267,6 +267,28 @@ textarea { padding: 0; } +fieldset { + border: 0; + padding: 0; + margin: 0; +} + +#storage label { + display: inline; +} +label input { + display: none; +} + +[type='radio'] + span { + cursor: pointer; +} + +[type='radio']:checked + span { + background: var(--buttonBG) !important; + color: var(--buttonColor); +} + /* Header */ .header { position: fixed; From 2877283a254083b0a815123e5fa8c3fff133988a Mon Sep 17 00:00:00 2001 From: Julian Sudendorf Date: Sat, 2 Feb 2019 14:47:57 +0100 Subject: [PATCH 7/8] refactor import export logic to db to support generic methods --- src/routes/settings/index.js | 98 ++++++++++-------------------------- src/utils/db.js | 59 ++++++++++++++++++++++ 2 files changed, 85 insertions(+), 72 deletions(-) diff --git a/src/routes/settings/index.js b/src/routes/settings/index.js index dac8d9b..0bb78cc 100644 --- a/src/routes/settings/index.js +++ b/src/routes/settings/index.js @@ -99,89 +99,43 @@ class Settings extends Component { } }; - prepareExport = async () => { - try { - const MIME_TYPE = 'text/json;charset=utf-8'; - - this.clean(); - - this.setState({ exporting: 1, files: [] }); + deleteData = async () => { + await this.props.db.clear('entries'); + await this.props.db.clear('questions'); + await this.props.db.clear('highlights'); + localStorage.removeItem('journalbook_onboarded'); + window.location.href = '/'; + }; - const data = await this.getData(); - const blob = new Blob([JSON.stringify(data)], { type: MIME_TYPE }); + export = async () => { + this.clean(); - const file = { - name: `journalbook_${ymd()}.json`, - data: window.URL.createObjectURL(blob), - }; - this.setState({ files: [file], exporting: 2 }); + this.setState({ exporting: 1, files: [] }); + try { + const files = await storage.adapter.export(); + this.setState({ files, exporting: 2 }, () => + setTimeout(() => this.setState({ exporting: 0 }), 1500) + ); } catch (e) { console.error(e); this.setState({ files: [], exporting: 0 }); } }; - importData = async event => { - const reader = new FileReader(); - const file = event.target.files[0]; + import = async () => { this.setState({ importing: true }); - - reader.onload = (() => async e => { - const { entries, questions, highlights = [], settings = {} } = JSON.parse( - e.target.result - ); - if (!entries || !questions || !Array.isArray(highlights)) { - return; - } - - const questionKeys = Object.keys(questions); - questionKeys.map(async key => { - const current = await this.props.db.get('questions', key); - if (!current) { - await this.props.db.set('questions', key, questions[key]); - } - }); - - const entryKeys = Object.keys(entries); - await Promise.all( - entryKeys.map(async key => { - const current = await this.props.db.get('entries', key); - if (!current) { - return this.props.db.set('entries', key, entries[key]); - } - }) - ); - - const settingKeys = Object.keys(settings); - await Promise.all( - settingKeys.map(async key => { - const current = await this.props.db.get('settings', key); - if (!current) { - return this.props.db.set('settings', key, settings[key]); - } - }) - ); - - await Promise.all( - highlights.map(async key => this.props.db.set('highlights', key, true)) - ); - - localStorage.setItem('journalbook_onboarded', true); - localStorage.setItem('journalbook_dates_migrated', true); - - window.location.reload(); - })(); - - reader.readAsText(file); + await storage.adapter.import(); + localStorage.setItem('journalbook_onboarded', true); + localStorage.setItem('journalbook_dates_migrated', true); + this.setState({ importing: false }); + window.location.reload(); }; - deleteData = async () => { - await this.props.db.clear('entries'); - await this.props.db.clear('questions'); - await this.props.db.clear('highlights'); - await this.props.db.clear('highlights'); - localStorage.removeItem('journalbook_onboarded'); - window.location.href = '/'; + logout = async () => { + storage.adapter.logout(); + storage.setAdapter('file'); + this.updateStorageAdapter()({ target: { value: 'file' } }); + window.location.reload(); }; render({ settings = {} }, { questions, exporting, files, importing }) { diff --git a/src/utils/db.js b/src/utils/db.js index 2735a1a..860659a 100644 --- a/src/utils/db.js +++ b/src/utils/db.js @@ -77,4 +77,63 @@ export class DB { return current; }, {}); } + + async import({ entries, questions, highlights = [] }) { + if (!entries || !questions || !Array.isArray(highlights)) { + return false; + } + + const questionKeys = Object.keys(questions); + questionKeys.map(async key => { + const current = await this.get('questions', key); + if (!current) { + await this.set('questions', key, questions[key]); + } + }); + + const entryKeys = Object.keys(entries); + await Promise.all( + entryKeys.map(async key => { + const current = await this.get('entries', key); + if (!current) { + return this.set('entries', key, entries[key]); + } + }) + ); + + await Promise.all( + highlights.map(async key => this.set('highlights', key, true)) + ); + return true; + } + + async export() { + try { + const questionValues = await this.getAll('questions'); + const questions = questionValues.reduce((current, value, index) => { + current[value.slug] = value; + return current; + }, {}); + + const entryKeys = await this.keys('entries'); + const entryValues = await Promise.all( + entryKeys.map(key => this.get('entries', key)) + ); + + const entries = entryValues.reduce((current, entry, index) => { + current[entryKeys[index]] = entry; + return current; + }, {}); + + const highlights = await this.keys('highlights'); + + return { questions, entries, highlights }; + } catch (e) { + return { + questions: {}, + entries: {}, + highlights: [], + }; + } + } } From 8b0af563151841734b59e8f9a4cb119533ea946d Mon Sep 17 00:00:00 2001 From: Julian Sudendorf Date: Sat, 2 Feb 2019 15:07:21 +0100 Subject: [PATCH 8/8] remove redundant call --- src/routes/settings/index.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/routes/settings/index.js b/src/routes/settings/index.js index 0bb78cc..1700d79 100644 --- a/src/routes/settings/index.js +++ b/src/routes/settings/index.js @@ -30,7 +30,7 @@ class Settings extends Component { this.props.updateSetting({ key, value: event.target.value }); }; - updateStorageAdapter = () => event => { + updateStorageAdapter = event => { const adapter = event.target.value; this.props.updateSetting({ key: 'storageAdapter', value: adapter }); storage.setAdapter(adapter); @@ -134,7 +134,7 @@ class Settings extends Component { logout = async () => { storage.adapter.logout(); storage.setAdapter('file'); - this.updateStorageAdapter()({ target: { value: 'file' } }); + this.updateStorageAdapter({ target: { value: 'file' } }); window.location.reload(); }; @@ -167,7 +167,7 @@ class Settings extends Component { name="storage" value="file" checked={storageAdapter === 'file'} - onChange={this.updateStorageAdapter()} + onChange={this.updateStorageAdapter} /> Local File @@ -178,7 +178,7 @@ class Settings extends Component { name="storage" value="dropbox" checked={storageAdapter === 'dropbox'} - onChange={this.updateStorageAdapter()} + onChange={this.updateStorageAdapter} /> Dropbox