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' && (
+
)}
-
-
+ {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;