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 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", 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; diff --git a/src/routes/settings/index.js b/src/routes/settings/index.js index 93f0745..1700d79 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 = { @@ -24,10 +26,14 @@ 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 }); + }; + + updateStorageAdapter = event => { + const adapter = event.target.value; + this.props.updateSetting({ key: 'storageAdapter', value: adapter }); + storage.setAdapter(adapter); }; updateQuestion = (slug, value, attribute = 'text') => { @@ -93,95 +99,48 @@ 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 => { - return 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 }) { const theme = settings.theme || getDefaultTheme(settings); + const storageAdapter = settings.storageAdapter || storage.getAdapter(); const animation = settings.animation || prefersAnimation(settings); return ( @@ -199,41 +158,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; 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: [], + }; + } + } } 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;