Added publish providers and templates

This commit is contained in:
Benoit Schweblin 2017-09-23 20:01:50 +01:00
parent 0b0bec15e2
commit 872f557d03
94 changed files with 3933 additions and 623 deletions

View File

@ -23,6 +23,9 @@ var proxyTable = config.dev.proxyTable
var app = express() var app = express()
var compiler = webpack(webpackConfig) var compiler = webpack(webpackConfig)
// StackEdit custom middlewares
require('./server')(app);
var devMiddleware = require('webpack-dev-middleware')(compiler, { var devMiddleware = require('webpack-dev-middleware')(compiler, {
publicPath: webpackConfig.output.publicPath, publicPath: webpackConfig.output.publicPath,
quiet: true quiet: true

38
build/server.js Normal file
View File

@ -0,0 +1,38 @@
var qs = require('qs');
var request = require('request');
function githubToken(clientId, code) {
return new Promise(function (resolve, reject) {
request({
method: 'POST',
url: 'https://github.com/login/oauth/access_token',
qs: {
client_id: clientId,
client_secret: process.env.GITHUB_SECRET,
code: code
},
}, function(err, res, body) {
if (err) {
reject(err);
}
console.log(body)
var token = qs.parse(body).access_token;
if (token) {
resolve(token);
} else {
reject(res.statusCode);
}
});
});
}
module.exports = function (app) {
app.get('/oauth2/githubToken', function (req, res) {
githubToken(req.query.clientId, req.query.code)
.then(function (token) {
res.send(token);
}, function (err) {
res.status(400).send(err ? err.message || err.toString() : 'bad_code');
});
});
};

View File

@ -32,6 +32,7 @@
"normalize-scss": "^7.0.0", "normalize-scss": "^7.0.0",
"prismjs": "^1.6.0", "prismjs": "^1.6.0",
"raw-loader": "^0.5.1", "raw-loader": "^0.5.1",
"request": "^2.82.0",
"vue": "^2.3.3", "vue": "^2.3.3",
"vuex": "^2.3.1" "vuex": "^2.3.1"
}, },

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 44 KiB

View File

@ -0,0 +1,9 @@
<svg
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 42.4 39.5">
<g fill="#007EE5">
<path d="M12.5 0L0 8.1l8.7 7 12.5-7.8"/>
<path d="M0 21.9l12.5 8.2 8.7-7.3-12.5-7.7m12.5 7.7l8.8 7.3L42.4 22l-8.6-6.9m8.6-7L30 0l-8.8 7.3 12.6 7.8"/>
<path d="M21.3 24.4l-8.8 7.3-3.7-2.5V32l12.5 7.5L33.8 32v-2.8L30 31.7"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 338 B

View File

@ -0,0 +1,7 @@
<svg
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 58">
<g fill="none" fill-rule="evenodd">
<path d="m1324.62 140c-16.355 0-29.616 13.219-29.616 29.527 0 13.04 8.485 24.11 20.256 28.01 1.482.27 2.02-.642 2.02-1.425 0-.7-.025-2.557-.04-5.02-8.238 1.784-9.976-3.958-9.976-3.958-1.347-3.411-3.289-4.317-3.289-4.317-2.689-1.832.204-1.796.204-1.796 2.973.21 4.536 3.043 4.536 3.043 2.642 4.511 6.931 3.208 8.62 2.454.269-1.909 1.033-3.21 1.88-3.948-6.576-.745-13.491-3.279-13.491-14.592 0-3.223 1.155-5.858 3.049-7.922-.305-.747-1.322-3.748.289-7.814 0 0 2.487-.794 8.145 3.03 2.362-.656 4.896-.982 7.415-.995 2.515.013 5.05.339 7.415.995 5.655-3.821 8.136-3.03 8.136-3.03 1.616 4.065.6 7.07.295 7.814 1.898 2.064 3.045 4.7 3.045 7.922 0 11.343-6.925 13.838-13.524 14.569 1.064.912 2.01 2.713 2.01 5.468 0 3.946-.036 7.13-.036 8.098 0 .79.533 1.709 2.036 1.421 11.758-3.913 20.238-14.971 20.238-28.01 0-16.309-13.262-29.527-29.62-29.527" transform="translate(-1295-140)" fill="#181616"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1011 B

View File

@ -0,0 +1,8 @@
<svg
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 133156 115341">
<g>
<polygon style="fill:#3777E3" points="22194,115341 44385,76894 133156,76894 110963,115341 "/>
<polygon style="fill:#FFCF63" points="88772,76894 133156,76894 88772,0 44385,0 "/>
<polygon style="fill:#11A861" points="0,76894 22194,115341 66578,38447 44385,0 "/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 363 B

View File

@ -0,0 +1,12 @@
<svg
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 511">
<path d="M255.912,0.08c1.4,0.8 2.6,2 3.7,3.2c41.3,41.5 82.7,83 123.899,124.6c-26,25.6 -51.6,51.6 -77.399,77.3c-9.7,9.8 -19.601,19.4 -29.2,29.4c-7.2,-17.4 -14.1,-34.9 -21,-52.4c0,-18.2 0.1,-36.4 0,-54.7c-0.1,-42.4 -0.2,-84.9 0,-127.4l0,0Z" style="fill:#dc4b3e;fill-rule:nonzero;stroke:#dd4b39;stroke-width:0.09px;"/>
<path d="M127.812,127.48l128.1,0c0.1,18.3 0,36.5 0,54.7c-7.1,17.2 -14,34.5 -20.8,51.9c-2.2,-1.2 -3.8,-3 -5.5,-4.8l-101.4,-101.4l-0.4,-0.4Z" style="fill:#ff9e0e;fill-rule:nonzero;stroke:#ef851c;stroke-width:0.09px;"/>
<path d="M383.511,127.88l0.4,-0.3c-0.1,42.6 -0.1,85.3 0,127.9l-55.1,0c-17.2,-7.2 -34.601,-13.8 -51.9,-20.9c9.6,-10 19.5,-19.6 29.2,-29.4c25.801,-25.7 51.4,-51.7 77.4,-77.3l0,0Z" style="fill:#af195a;fill-rule:nonzero;stroke:#7e3794;stroke-width:0.09px;"/>
<path d="M106.912,148.98c7.2,-6.9 13.9,-14.3 21.3,-21.1l101.4,101.4c1.7,1.8 3.3,3.6 5.5,4.8c-2.3,1.7 -5.2,2.3 -7.8,3.5c-14.801,6 -29.801,11.6 -44.5,18c-18.301,-0.2 -36.601,-0.1 -54.9,-0.1c-42.6,-0.1 -85.2,0.2 -127.8,-0.1c35.5,-35.6 71.2,-71 106.8,-106.4l0,0Z" style="fill:#ffc112;fill-rule:nonzero;stroke:#ffbb1b;stroke-width:0.09px;"/>
<path d="M127.912,255.48c18.3,0 36.6,-0.1 54.9,0.1c17.3,7.1 34.6,13.8 51.899,20.8c-28.399,28.8 -57.099,57.2 -85.599,85.9c-7.2,6.8 -13.7,14.3 -21.3,20.7c0,-42.5 -0.1,-85 0.1,-127.5Z" style="fill:#17a05e;fill-rule:nonzero;stroke:#1a8763;stroke-width:0.09px;"/>
<path d="M328.812,255.48l55.1,0c42.5,0.1 85.1,-0.1 127.6,0.1c-27.3,27.7 -55,55.1 -82.399,82.6c-15.2,15.1 -30.2,30.399 -45.4,45.3c-34,-34.4 -68.5,-68.4 -102.6,-102.8c-1.4,-1.5 -2.9,-2.8 -4.601,-3.8c2.9,-1.801 6.101,-2.7 9.2,-4c14.4,-5.8 28.799,-11.4 43.1,-17.4l0,0Z" style="fill:#4587f4;fill-rule:nonzero;stroke:#427fed;stroke-width:0.09px;"/>
<path d="M234.712,276.38c7.3,17.399 13.9,35 21.2,52.399c-0.1,18.2 0,36.5 -0.1,54.7l0,88c-0.2,13.1 0.3,26.2 -0.2,39.2c-2.101,-1 -3.4,-2.9 -5.101,-4.5c-40.899,-41.099 -81.699,-82.199 -122.699,-123.199c7.6,-6.4 14.1,-13.9 21.3,-20.7c28.5,-28.7 57.2,-57.1 85.6,-85.9Z" style="fill:#8dc44d;fill-rule:nonzero;stroke:#65b045;stroke-width:0.09px;"/>
<path d="M276.511,276.88c1.7,1 3.2,2.3 4.601,3.8c34.1,34.4 68.6,68.4 102.6,102.8c-42.7,-0.1 -85.3,0.1 -127.899,0c0.1,-18.2 0,-36.5 0.1,-54.7c6.699,-17.3 13.899,-34.5 20.598,-51.9l0,0Z" style="fill:#3569d6;fill-rule:nonzero;stroke:#43459d;stroke-width:0.09px;"/>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@ -0,0 +1,19 @@
<svg
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 126 126">
<path d="M103.5,0c3.762,0.003 7.395,0.971 10.725,2.716c0.966,0.506 1.808,1.221 2.712,1.831l-53.937,40.453l-53.937,-40.453c4.402,-3.289 8.02,-4.273 13.437,-4.547l81,0Z" style="fill:#ffe600;fill-rule:nonzero;"/>
<path d="M9.063,4.547l53.937,40.453l-58.04,72.55c-3.44,-4.417 -4.681,-8.528 -4.96,-14.05l0,-81c0.064,-5.221 1.801,-10.265 5.138,-14.312c0.914,-1.109 2.033,-2.033 3.05,-3.05l0.875,-0.591l0,0Z" style="fill:#bbd500;fill-rule:nonzero;"/>
<path d="M63,45l58.04,72.549l-0.178,0.263c-4.901,5.465 -10.068,7.82 -17.362,8.188l-81,0c-5.221,-0.065 -10.265,-1.801 -14.312,-5.138c-1.109,-0.915 -2.033,-2.034 -3.05,-3.05l-0.177,-0.262l58.039,-72.55l0,0Z" style="fill:#ff8a00;fill-rule:nonzero;"/>
<path d="M116.937,4.547c3.844,2.631 6.684,6.83 8.051,11.262c0.441,1.427 0.673,2.914 0.896,4.391c0.114,0.759 0.077,1.533 0.116,2.3l0,81c-0.023,3.748 -0.939,7.415 -2.716,10.725c-0.632,1.178 -1.496,2.216 -2.245,3.325l-58.039,-72.55l53.937,-40.453l0,0Z" style="fill:#75b7fd;fill-rule:nonzero;"/>
<path d="M32.063,12l61.874,0c7.767,0 14.063,6.296 14.063,14.063l0,61.874c0,7.767 -6.296,14.063 -14.063,14.063l-61.875,0c-7.766,0 -14.062,-6.296 -14.062,-14.063l0,-61.875c0,-7.766 6.296,-14.062 14.062,-14.062l0.001,0Z" style="fill:#fff;fill-rule:nonzero;"/>
<g>
<g>
<rect x="40.711" y="24.501" width="7.026" height="50.66" style="fill:#737373;"/>
<rect x="56.263" y="24.501" width="7.026" height="50.66" style="fill:#737373;"/>
</g>
<g>
<path d="M71.278,44.466l0,-8.841l-38.556,0l0,8.841l38.556,0Z" style="fill:#737373;fill-rule:nonzero;"/>
<rect x="32.722" y="55.195" width="38.556" height="8.842" style="fill:#737373;fill-rule:nonzero;"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -2,6 +2,7 @@
<div v-if="ready" class="app" :class="{'app--loading': loading}"> <div v-if="ready" class="app" :class="{'app--loading': loading}">
<layout></layout> <layout></layout>
<modal v-if="showModal"></modal> <modal v-if="showModal"></modal>
<notification></notification>
</div> </div>
<div v-else class="app__spash-screen"></div> <div v-else class="app__spash-screen"></div>
</template> </template>
@ -11,6 +12,7 @@ import Vue from 'vue';
import { mapState } from 'vuex'; import { mapState } from 'vuex';
import Layout from './Layout'; import Layout from './Layout';
import Modal from './Modal'; import Modal from './Modal';
import Notification from './Notification';
// Global directives // Global directives
Vue.directive('focus', { Vue.directive('focus', {
@ -23,6 +25,7 @@ export default {
components: { components: {
Layout, Layout,
Modal, Modal,
Notification,
}, },
computed: { computed: {
...mapState([ ...mapState([
@ -32,7 +35,7 @@ export default {
return !this.$store.getters['content/current'].id; return !this.$store.getters['content/current'].id;
}, },
showModal() { showModal() {
return !!this.$store.state.modal.config; return !!this.$store.getters['modal/config'];
}, },
}, },
}; };

View File

@ -1,10 +1,11 @@
<template> <template>
<div class="explorer-node" :class="{'explorer-node--selected': isSelected, 'explorer-node--open': isOpen, 'explorer-node--drag-target': isDragTargetFolder}" @dragover.prevent @dragenter.stop="setDragTarget(node.item.id)" @dragleave.stop="isDragTarget && setDragTargetId()" @drop.prevent.stop="onDrop"> <div class="explorer-node" :class="{'explorer-node--selected': isSelected, 'explorer-node--open': isOpen, 'explorer-node--drag-target': isDragTargetFolder}" @dragover.prevent @dragenter.stop="setDragTarget(node.item.id)" @dragleave.stop="isDragTarget && setDragTargetId()" @drop.prevent.stop="onDrop">
<div v-if="isEditing" class="explorer-node__item-editor" :class="['explorer-node__item-editor--' + node.item.type]" :style="{'padding-left': leftPadding}"> <div class="explorer-node__item-editor" v-if="isEditing" :class="['explorer-node__item-editor--' + node.item.type]" :style="{'padding-left': leftPadding}">
<input type="text" class="text-input" v-focus @blur="submitEdit()" @keyup.enter="submitEdit()" @keyup.esc="submitEdit(true)" v-model="editingNodeName"> <input type="text" class="text-input" v-focus @blur="submitEdit()" @keyup.enter="submitEdit()" @keyup.esc="submitEdit(true)" v-model="editingNodeName">
</div> </div>
<div v-else class="explorer-node__item" :class="['explorer-node__item--' + node.item.type]" :style="{'padding-left': leftPadding}" @click="select(node.item.id)" draggable="true" @dragstart.stop="setDragSourceId" @dragend.stop="setDragTargetId()"> <div class="explorer-node__item" v-else :class="['explorer-node__item--' + node.item.type]" :style="{'padding-left': leftPadding}" @click="select(node.item.id)" draggable="true" @dragstart.stop="setDragSourceId" @dragend.stop="setDragTargetId()">
{{node.item.name}} {{node.item.name}}
<icon-provider class="explorer-node__location" v-for="location in node.locations" :key="location.id" :provider-id="location.providerId"></icon-provider>
</div> </div>
<div class="explorer-node__children" v-if="node.isFolder && isOpen"> <div class="explorer-node__children" v-if="node.isFolder && isOpen">
<explorer-node v-for="node in node.folders" :key="node.item.id" :node="node" :depth="depth + 1"></explorer-node> <explorer-node v-for="node in node.folders" :key="node.item.id" :node="node" :depth="depth + 1"></explorer-node>
@ -112,10 +113,11 @@ export default {
this.$store.commit('explorer/setNewItem', null); this.$store.commit('explorer/setNewItem', null);
}, },
submitEdit(cancel) { submitEdit(cancel) {
const id = this.$store.getters['explorer/editingNode'].item.id; const editingNode = this.$store.getters['explorer/editingNode'];
const id = editingNode.item.id;
const value = this.editingValue; const value = this.editingValue;
if (!cancel && id && value) { if (!cancel && id && value) {
this.$store.commit('file/patchItem', { this.$store.commit(editingNode.isFolder ? 'folder/patchItem' : 'file/patchItem', {
id, id,
name: value.slice(0, 250), name: value.slice(0, 250),
}); });
@ -166,6 +168,7 @@ $item-font-size: 14px;
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
padding-right: 5px;
.explorer-node--selected > & { .explorer-node--selected > & {
background-color: rgba(0, 0, 0, 0.2); background-color: rgba(0, 0, 0, 0.2);
@ -179,6 +182,13 @@ $item-font-size: 14px;
.explorer__tree--new-item & { .explorer__tree--new-item & {
opacity: 0.33; opacity: 0.33;
} }
.explorer-node__location {
float: right;
width: 18px;
height: 18px;
margin: 2px 1px;
}
} }
.explorer-node__item--folder, .explorer-node__item--folder,

View File

@ -98,7 +98,6 @@ export default {
position: relative; position: relative;
width: 100%; width: 100%;
height: 100%; height: 100%;
-webkit-flex: none;
flex: none; flex: none;
} }

View File

@ -3,10 +3,22 @@
<file-properties-modal v-if="config.type === 'fileProperties'"></file-properties-modal> <file-properties-modal v-if="config.type === 'fileProperties'"></file-properties-modal>
<settings-modal v-else-if="config.type === 'settings'"></settings-modal> <settings-modal v-else-if="config.type === 'settings'"></settings-modal>
<templates-modal v-else-if="config.type === 'templates'"></templates-modal> <templates-modal v-else-if="config.type === 'templates'"></templates-modal>
<html-export-modal v-else-if="config.type === 'htmlExport'"></html-export-modal>
<link-modal v-else-if="config.type === 'link'"></link-modal> <link-modal v-else-if="config.type === 'link'"></link-modal>
<image-modal v-else-if="config.type === 'image'"></image-modal> <image-modal v-else-if="config.type === 'image'"></image-modal>
<google-photo-modal v-else-if="config.type === 'googlePhoto'"></google-photo-modal> <google-photo-modal v-else-if="config.type === 'googlePhoto'"></google-photo-modal>
<html-export-modal v-else-if="config.type === 'htmlExport'"></html-export-modal> <sync-management-modal v-else-if="config.type === 'syncManagement'"></sync-management-modal>
<publish-management-modal v-else-if="config.type === 'publishManagement'"></publish-management-modal>
<google-drive-sync-modal v-else-if="config.type === 'googleDriveSync'"></google-drive-sync-modal>
<google-drive-publish-modal v-else-if="config.type === 'googleDrivePublish'"></google-drive-publish-modal>
<dropbox-sync-modal v-else-if="config.type === 'dropboxSync'"></dropbox-sync-modal>
<dropbox-publish-modal v-else-if="config.type === 'dropboxPublish'"></dropbox-publish-modal>
<github-sync-modal v-else-if="config.type === 'githubSync'"></github-sync-modal>
<github-publish-modal v-else-if="config.type === 'githubPublish'"></github-publish-modal>
<gist-sync-modal v-else-if="config.type === 'gistSync'"></gist-sync-modal>
<gist-publish-modal v-else-if="config.type === 'gistPublish'"></gist-publish-modal>
<blogger-publish-modal v-else-if="config.type === 'bloggerPublish'"></blogger-publish-modal>
<blogger-page-publish-modal v-else-if="config.type === 'bloggerPagePublish'"></blogger-page-publish-modal>
<div v-else class="modal__inner-1"> <div v-else class="modal__inner-1">
<div class="modal__inner-2"> <div class="modal__inner-2">
<div class="modal__content" v-html="config.content"></div> <div class="modal__content" v-html="config.content"></div>
@ -20,35 +32,56 @@
</template> </template>
<script> <script>
import { mapState, mapMutations } from 'vuex'; import { mapGetters } from 'vuex';
import FilePropertiesModal from './FilePropertiesModal';
import SettingsModal from './SettingsModal';
import TemplatesModal from './TemplatesModal';
import LinkModal from './LinkModal';
import ImageModal from './ImageModal';
import GooglePhotoModal from './GooglePhotoModal';
import HtmlExportModal from './HtmlExportModal';
import editorEngineSvc from '../services/editorEngineSvc'; import editorEngineSvc from '../services/editorEngineSvc';
import FilePropertiesModal from './modals/FilePropertiesModal';
import SettingsModal from './modals/SettingsModal';
import TemplatesModal from './modals/TemplatesModal';
import HtmlExportModal from './modals/HtmlExportModal';
import LinkModal from './modals/LinkModal';
import ImageModal from './modals/ImageModal';
import GooglePhotoModal from './modals/GooglePhotoModal';
import SyncManagementModal from './modals/SyncManagementModal';
import PublishManagementModal from './modals/PublishManagementModal';
import GoogleDriveSyncModal from './modals/GoogleDriveSyncModal';
import GoogleDrivePublishModal from './modals/GoogleDrivePublishModal';
import DropboxSyncModal from './modals/DropboxSyncModal';
import DropboxPublishModal from './modals/DropboxPublishModal';
import GithubSyncModal from './modals/GithubSyncModal';
import GithubPublishModal from './modals/GithubPublishModal';
import GistSyncModal from './modals/GistSyncModal';
import GistPublishModal from './modals/GistPublishModal';
import BloggerPublishModal from './modals/BloggerPublishModal';
import BloggerPagePublishModal from './modals/BloggerPagePublishModal';
export default { export default {
components: { components: {
FilePropertiesModal, FilePropertiesModal,
SettingsModal, SettingsModal,
TemplatesModal, TemplatesModal,
HtmlExportModal,
LinkModal, LinkModal,
ImageModal, ImageModal,
GooglePhotoModal, GooglePhotoModal,
HtmlExportModal, SyncManagementModal,
PublishManagementModal,
GoogleDriveSyncModal,
GoogleDrivePublishModal,
DropboxSyncModal,
DropboxPublishModal,
GithubSyncModal,
GithubPublishModal,
GistSyncModal,
GistPublishModal,
BloggerPublishModal,
BloggerPagePublishModal,
}, },
computed: mapState('modal', [ computed: mapGetters('modal', [
'config', 'config',
]), ]),
methods: { methods: {
...mapMutations('modal', [
'setConfig',
]),
onEscape() { onEscape() {
this.setConfig(); this.config.reject();
editorEngineSvc.clEditor.focus(); editorEngineSvc.clEditor.focus();
}, },
onFocusInOut(evt) { onFocusInOut(evt) {
@ -69,7 +102,7 @@ export default {
} }
target = target.parentNode; target = target.parentNode;
} }
this.setConfig(); this.config.reject();
} }
}, },
}, },
@ -78,7 +111,7 @@ export default {
window.addEventListener('focusout', this.onFocusInOut); window.addEventListener('focusout', this.onFocusInOut);
const eltToFocus = this.$el.querySelector('input.text-input') const eltToFocus = this.$el.querySelector('input.text-input')
|| this.$el.querySelector('.textfield') || this.$el.querySelector('.textfield')
|| this.$el.querySelector('button.button'); || this.$el.querySelector('.button');
if (eltToFocus) { if (eltToFocus) {
eltToFocus.focus(); eltToFocus.focus();
} }
@ -103,7 +136,6 @@ export default {
.modal__inner-1 { .modal__inner-1 {
margin: 0 auto; margin: 0 auto;
display: table;
width: 100%; width: 100%;
min-width: 320px; min-width: 320px;
max-width: 500px; max-width: 500px;
@ -142,10 +174,30 @@ export default {
margin-top: 0; margin-top: 0;
} }
.modal__image {
float: left;
width: 64px;
height: 64px;
margin: 1.5em 1.5em 0.5em 0;
& + *::after {
content: '';
display: block;
clear: both;
}
}
.modal__error { .modal__error {
color: #de2c00; color: #de2c00;
} }
.modal__tip {
background-color: transparentize(#ffd600, 0.85);
border-radius: $border-radius-base;
margin: 1.2em 0;
padding: 0.75em 1.25em;
}
.modal__button-bar { .modal__button-bar {
margin-top: 1.75rem; margin-top: 1.75rem;
text-align: right; text-align: right;

View File

@ -2,27 +2,38 @@
<div class="navigation-bar" :class="{'navigation-bar--editor': styles.showEditor}"> <div class="navigation-bar" :class="{'navigation-bar--editor': styles.showEditor}">
<div class="navigation-bar__inner navigation-bar__inner--left navigation-bar__inner--button"> <div class="navigation-bar__inner navigation-bar__inner--left navigation-bar__inner--button">
<button class="navigation-bar__button button" @click="toggleExplorer()"> <button class="navigation-bar__button button" @click="toggleExplorer()">
<icon-folder-multiple></icon-folder-multiple> <icon-folder-open></icon-folder-open>
</button> </button>
</div> </div>
<div class="navigation-bar__inner navigation-bar__inner--right navigation-bar__inner--button"> <div class="navigation-bar__inner navigation-bar__inner--right navigation-bar__inner--button">
<button class="navigation-bar__button navigation-bar__button--stackedit button" @click="toggleSideBar()"> <button class="navigation-bar__button navigation-bar__button--stackedit button" @click="toggleSideBar()">
<icon-stackedit></icon-stackedit> <icon-provider provider-id="stackedit"></icon-provider>
</button> </button>
</div> </div>
<div class="navigation-bar__inner navigation-bar__inner--right flex flex--row"> <div class="navigation-bar__inner navigation-bar__inner--right navigation-bar__inner--title flex flex--row">
<div class="navigation-bar__spinner" v-show="showSpinner"> <div class="navigation-bar__spinner">
<div class="spinner"></div> <div v-show="showSpinner" class="spinner"></div>
</div> </div>
<div class="navigation-bar__title navigation-bar__title--fake text-input"></div> <div class="navigation-bar__title navigation-bar__title--fake text-input"></div>
<div class="navigation-bar__title navigation-bar__title--text text-input" v-bind:style="{maxWidth: styles.titleMaxWidth + 'px'}">{{title}}</div> <div class="navigation-bar__title navigation-bar__title--text text-input" :style="{width: titleWidth + 'px'}">{{title}}</div>
<input class="navigation-bar__title navigation-bar__title--input text-input" :class="{'navigation-bar__title--focus': titleFocus, 'navigation-bar__title--scrolling': titleScrolling}" v-bind:style="{width: titleWidth + 'px'}" @focus="editTitle(true)" @blur="editTitle(false)" @keyup.enter="submitTitle()" @keyup.esc="submitTitle(true)" v-on:mouseenter="titleHover = true" v-on:mouseleave="titleHover = false" v-model="title"> <input class="navigation-bar__title navigation-bar__title--input text-input" :class="{'navigation-bar__title--focus': titleFocus, 'navigation-bar__title--scrolling': titleScrolling}" :style="{width: titleWidth + 'px'}" @focus="editTitle(true)" @blur="editTitle(false)" @keyup.enter="submitTitle()" @keyup.esc="submitTitle(true)" @mouseenter="titleHover = true" @mouseleave="titleHover = false" v-model="title">
<button v-if="!offline && isSyncPossible" class="navigation-bar__button button" :disabled="isSyncRequested" @click="requestSync"> <div class="flex flex--row" :class="{'navigation-bar__hidden': styles.hideLocations}">
<icon-sync></icon-sync> <a class="navigation-bar__button navigation-bar__button--location button" :class="{'navigation-bar__button--blink': location.id === currentLocation.id}" v-for="location in syncLocations" :key="location.id" :href="location.url" target="_blank">
</button> <icon-provider :provider-id="location.providerId"></icon-provider>
<button v-if="offline && isSyncPossible" class="navigation-bar__button navigation-bar__button--sync-off button" disabled="disabled"> </a>
<icon-sync-off></icon-sync-off> <button class="navigation-bar__button navigation-bar__button--sync button" v-if="!offline && isSyncPossible" :disabled="isSyncRequested" @click="requestSync">
</button> <icon-sync></icon-sync>
</button>
<button class="navigation-bar__button navigation-bar__button--sync-off button" v-if="offline && isSyncPossible" disabled="disabled">
<icon-sync-off></icon-sync-off>
</button>
<a class="navigation-bar__button navigation-bar__button--location button" :class="{'navigation-bar__button--blink': location.id === currentLocation.id}" v-for="location in publishLocations" :key="location.id" :href="location.url" target="_blank">
<icon-provider :provider-id="location.providerId"></icon-provider>
</a>
<button class="navigation-bar__button navigation-bar__button--publish button" v-if="publishLocations.length" :disabled="isPublishRequested || offline" @click="requestPublish">
<icon-upload></icon-upload>
</button>
</div>
</div> </div>
<div class="navigation-bar__inner navigation-bar__inner--edit-buttons"> <div class="navigation-bar__inner navigation-bar__inner--edit-buttons">
<button class="navigation-bar__button button" @click="pagedownClick('bold')"> <button class="navigation-bar__button button" @click="pagedownClick('bold')">
@ -69,6 +80,7 @@
import { mapState, mapGetters, mapActions } from 'vuex'; import { mapState, mapGetters, mapActions } from 'vuex';
import editorSvc from '../services/editorSvc'; import editorSvc from '../services/editorSvc';
import syncSvc from '../services/syncSvc'; import syncSvc from '../services/syncSvc';
import publishSvc from '../services/publishSvc';
import animationSvc from '../services/animationSvc'; import animationSvc from '../services/animationSvc';
export default { export default {
@ -84,10 +96,18 @@ export default {
]), ]),
...mapState('queue', [ ...mapState('queue', [
'isSyncRequested', 'isSyncRequested',
'isPublishRequested',
'currentLocation',
]), ]),
...mapGetters('layout', [ ...mapGetters('layout', [
'styles', 'styles',
]), ]),
...mapGetters('syncLocation', {
syncLocations: 'current',
}),
...mapGetters('publishLocation', {
publishLocations: 'current',
}),
isSyncPossible() { isSyncPossible() {
return this.$store.getters['data/loginToken'] || return this.$store.getters['data/loginToken'] ||
this.$store.getters['syncLocation/current'].length; this.$store.getters['syncLocation/current'].length;
@ -134,6 +154,11 @@ export default {
syncSvc.requestSync(); syncSvc.requestSync();
} }
}, },
requestPublish() {
if (!this.isPublishRequested) {
publishSvc.requestPublish();
}
},
pagedownClick(name) { pagedownClick(name) {
editorSvc.pagedownEditor.uiManager.doClick(name); editorSvc.pagedownEditor.uiManager.doClick(name);
}, },
@ -183,6 +208,10 @@ export default {
overflow: hidden; overflow: hidden;
} }
.navigation-bar__hidden {
display: none;
}
.navigation-bar__inner--left { .navigation-bar__inner--left {
float: left; float: left;
@ -201,23 +230,30 @@ export default {
.navigation-bar__inner--edit-buttons { .navigation-bar__inner--edit-buttons {
margin-left: 15px; margin-left: 15px;
.navigation-bar__button {
float: left;
}
}
.navigation-bar__inner--title * {
flex: none;
} }
.navigation-bar__button { .navigation-bar__button {
width: 34px; width: 36px;
height: 36px; height: 36px;
padding: 7px; padding: 0 8px;
/* prevent from seeing wrapped buttons */ /* prevent from seeing wrapped buttons */
margin-bottom: 20px; margin-bottom: 20px;
.navigation-bar__inner--button & { .navigation-bar__inner--button & {
padding: 6px; padding: 0 4px;
width: 38px; width: 38px;
&.navigation-bar__button--stackedit { &.navigation-bar__button--stackedit {
opacity: 0.8; opacity: 0.8;
padding: 4px;
&:active, &:active,
&:focus, &:focus,
@ -228,6 +264,10 @@ export default {
} }
} }
.navigation-bar__title {
margin: 0 4px;
}
.navigation-bar__title, .navigation-bar__title,
.navigation-bar__button { .navigation-bar__button {
display: inline-block; display: inline-block;
@ -236,6 +276,13 @@ export default {
font-size: 22px; font-size: 22px;
} }
.navigation-bar__button--sync,
.navigation-bar__button--sync-off,
.navigation-bar__button--publish {
padding: 0 6px;
margin: 0 5px;
}
.navigation-bar__button[disabled] { .navigation-bar__button[disabled] {
&, &,
&:active, &:active,
@ -250,7 +297,7 @@ export default {
&:active, &:active,
&:focus, &:focus,
&:hover { &:hover {
color: #f20; color: $error-color;
} }
} }
@ -264,6 +311,30 @@ export default {
} }
} }
.navigation-bar__button--location {
width: 20px;
padding: 0 2px 12px;
opacity: 0.5;
&:active,
&:focus,
&:hover {
background-color: transparent;
opacity: 1;
}
}
.navigation-bar__button--blink {
animation: blink 1s linear infinite;
}
@keyframes blink {
50% {
opacity: 1;
filter: contrast(0.8) brightness(1.25);
}
}
.navigation-bar__title--fake { .navigation-bar__title--fake {
position: absolute; position: absolute;
left: -9999px; left: -9999px;
@ -283,7 +354,8 @@ export default {
.navigation-bar__title--input, .navigation-bar__title--input,
.navigation-bar__inner--edit-buttons, .navigation-bar__inner--edit-buttons,
.navigation-bar__inner--button { .navigation-bar__inner--button,
.navigation-bar__spinner {
display: none; display: none;
.navigation-bar--editor & { .navigation-bar--editor & {
@ -291,6 +363,14 @@ export default {
} }
} }
.navigation-bar__button {
display: none;
.navigation-bar--editor & {
display: inline-block;
}
}
.navigation-bar__title--input { .navigation-bar__title--input {
cursor: pointer; cursor: pointer;
@ -299,16 +379,17 @@ export default {
} }
} }
.navigation-bar__spinner {
margin: 10px 5px 0 15px;
color: rgba(255, 255, 255, 0.33);
}
$r: 9px; $r: 9px;
$d: $r * 2; $d: $r * 2;
$b: $d/10; $b: $d/10;
$t: 1500ms; $t: 1500ms;
.navigation-bar__spinner {
width: $d;
margin: 10px 5px 0 10px;
color: rgba(255, 255, 255, 0.67);
}
.spinner { .spinner {
width: $d; width: $d;
height: $d; height: $d;

View File

@ -0,0 +1,52 @@
<template>
<div class="notification">
<div class="notification__item flex flex--row flex--align-center" v-for="(item, idx) in items" :key="idx">
<div class="notification__icon flex flex--column flex--center">
<icon-information v-if="item.type === 'info'"></icon-information>
<icon-alert v-else-if="item.type === 'error'"></icon-alert>
</div>
<div class="notification__content">
{{item.content}}
</div>
</div>
</div>
</template>
<script>
import { mapState } from 'vuex';
export default {
computed: mapState('notification', [
'items',
]),
};
</script>
<style lang="scss">
@import 'common/variables.scss';
.notification {
position: absolute;
bottom: 0;
right: 0;
width: 100%;
max-width: 340px;
}
.notification__item {
margin: 10px;
padding: 10px 15px;
line-height: 1.4;
background-color: #000;
color: #fff;
font-size: 0.9em;
border-radius: $border-radius-base;
}
.notification__icon {
height: 20px;
width: 20px;
margin-right: 12px;
flex: none;
}
</style>

View File

@ -12,110 +12,14 @@
</button> </button>
</div> </div>
<div class="side-bar__inner"> <div class="side-bar__inner">
<!-- Main menu --> <main-menu v-if="panel === 'menu'"></main-menu>
<div v-if="panel === 'menu'" class="side-bar__panel side-bar__panel--menu"> <sync-menu v-else-if="panel === 'sync'"></sync-menu>
<menu-entry v-if="!loginToken" @click.native="signin"> <publish-menu v-else-if="panel === 'publish'"></publish-menu>
<icon-login slot="icon"></icon-login> <export-menu v-else-if="panel === 'export'"></export-menu>
<div>Sign in with Google</div> <more-menu v-else-if="panel === 'more'"></more-menu>
<span>Back up and sync all your files, folders and settings.</span>
</menu-entry>
<hr v-if="!loginToken">
<menu-entry @click.native="setPanel('sync')">
<icon-sync slot="icon"></icon-sync>
<div>Synchronize</div>
<span>Open, save, collaborate in the Cloud.</span>
</menu-entry>
<menu-entry @click.native="setPanel('publish')">
<icon-upload slot="icon"></icon-upload>
<div>Publish</div>
<span>Export to the web.</span>
</menu-entry>
<hr>
<menu-entry @click.native="fileProperties">
<icon-view-list slot="icon"></icon-view-list>
<div>File properties</div>
<span>Add publication metadata and configure extensions.</span>
</menu-entry>
<menu-entry @click.native="setPanel('toc')">
<icon-toc slot="icon"></icon-toc>
Table of contents
</menu-entry>
<menu-entry @click.native="setPanel('help')">
<icon-help-circle slot="icon"></icon-help-circle>
Markdown cheat sheet
</menu-entry>
<hr>
<menu-entry @click.native="importFile">
<icon-hard-disk slot="icon"></icon-hard-disk>
Import from disk
</menu-entry>
<menu-entry @click.native="setPanel('export')">
<icon-hard-disk slot="icon"></icon-hard-disk>
Export to disk
</menu-entry>
<hr>
<menu-entry @click.native="setPanel('more')">
More...
</menu-entry>
</div>
<!-- Sync menu -->
<div v-else-if="panel === 'sync'" class="side-bar__panel side-bar__panel--menu">
<div v-for="token in googleDriveTokens" :key="token.sub">
<menu-entry @click.native="openGoogleDrive(token)">
<icon-google-drive slot="icon"></icon-google-drive>
<div>Open from Google Drive</div>
<span>{{token.name}}</span>
</menu-entry>
<menu-entry @click.native="saveGoogleDrive(token)">
<icon-google-drive slot="icon"></icon-google-drive>
<div>Save on Google Drive</div>
<span>{{token.name}}</span>
</menu-entry>
<hr>
</div>
<menu-entry @click.native="addGoogleDriveAccount">
<icon-google-drive slot="icon"></icon-google-drive>
<span>Add Google Drive account</span>
</menu-entry>
</div>
<!-- More menu -->
<div v-else-if="panel === 'more'" class="side-bar__panel side-bar__panel--menu">
<menu-entry @click.native="settings">
<icon-settings slot="icon"></icon-settings>
<div>Settings</div>
<span>Tweak application and keyboard shortcuts.</span>
</menu-entry>
<menu-entry @click.native="templates">
<icon-code-braces slot="icon"></icon-code-braces>
<div>Templates</div>
<span>Configure Handlebars templates for your exports.</span>
</menu-entry>
<menu-entry @click.native="reset">
<icon-logout slot="icon"></icon-logout>
<div>Reset application</div>
<span>Sign out and clean local data.</span>
</menu-entry>
</div>
<!-- Export menu -->
<div v-else-if="panel === 'export'" class="side-bar__panel side-bar__panel--menu">
<menu-entry @click.native="exportMarkdown">
<icon-download slot="icon"></icon-download>
Export as Markdown
</menu-entry>
<menu-entry @click.native="exportHtml">
<icon-download slot="icon"></icon-download>
Export as HTML
</menu-entry>
<menu-entry @click.native="exportPdf">
<icon-download slot="icon"></icon-download>
Export as PDF
</menu-entry>
</div>
<!-- Help -->
<div v-else-if="panel === 'help'" class="side-bar__panel side-bar__panel--help"> <div v-else-if="panel === 'help'" class="side-bar__panel side-bar__panel--help">
<pre class="markdown-highlighting" v-html="markdownSample"></pre> <pre class="markdown-highlighting" v-html="markdownSample"></pre>
</div> </div>
<!-- TOC -->
<div class="side-bar__panel side-bar__panel--toc" :class="{'side-bar__panel--hidden': panel !== 'toc'}"> <div class="side-bar__panel side-bar__panel--toc" :class="{'side-bar__panel--hidden': panel !== 'toc'}">
<toc> <toc>
</toc> </toc>
@ -125,16 +29,16 @@
</template> </template>
<script> <script>
import { mapGetters, mapActions } from 'vuex'; import { mapActions } from 'vuex';
import Toc from './Toc'; import Toc from './Toc';
import MenuEntry from './MenuEntry'; import MenuEntry from './menus/MenuEntry';
import MainMenu from './menus/MainMenu';
import SyncMenu from './menus/SyncMenu';
import PublishMenu from './menus/PublishMenu';
import ExportMenu from './menus/ExportMenu';
import MoreMenu from './menus/MoreMenu';
import markdownSample from '../data/markdownSample.md'; import markdownSample from '../data/markdownSample.md';
import markdownConversionSvc from '../services/markdownConversionSvc'; import markdownConversionSvc from '../services/markdownConversionSvc';
import googleHelper from '../services/providers/helpers/googleHelper';
import googleDriveProvider from '../services/providers/googleDriveProvider';
import syncSvc from '../services/syncSvc';
import localDbSvc from '../services/localDbSvc';
import exportSvc from '../services/exportSvc';
const panelNames = { const panelNames = {
menu: 'Menu', menu: 'Menu',
@ -150,27 +54,22 @@ export default {
components: { components: {
Toc, Toc,
MenuEntry, MenuEntry,
MainMenu,
SyncMenu,
PublishMenu,
ExportMenu,
MoreMenu,
}, },
data: () => ({ data: () => ({
markdownSample: markdownConversionSvc.highlight(markdownSample), markdownSample: markdownConversionSvc.highlight(markdownSample),
}), }),
computed: { computed: {
...mapGetters('data', [
'loginToken',
]),
panel() { panel() {
return this.$store.getters['data/localSettings'].sideBarPanel; return this.$store.getters['data/localSettings'].sideBarPanel;
}, },
panelName() { panelName() {
return panelNames[this.panel]; return panelNames[this.panel];
}, },
googleDriveTokens() {
const googleToken = this.$store.getters['data/googleTokens'];
return Object.keys(googleToken)
.map(sub => googleToken[sub])
.filter(token => token.isDrive)
.sort((token1, token2) => token1.name.localeCompare(token2.name));
},
}, },
methods: { methods: {
...mapActions('data', [ ...mapActions('data', [
@ -179,47 +78,6 @@ export default {
...mapActions('data', { ...mapActions('data', {
setPanel: 'setSideBarPanel', setPanel: 'setSideBarPanel',
}), }),
signin() {
return googleHelper.signin()
.then(() => syncSvc.requestSync());
},
importFile() {
return this.$store.dispatch('modal/notImplemented');
},
fileProperties() {
return this.$store.dispatch('modal/open', 'fileProperties')
.then(properties => this.$store.dispatch('content/patchCurrent', { properties }));
},
settings() {
return this.$store.dispatch('modal/open', 'settings')
.then(settings => this.$store.dispatch('data/setSettings', settings));
},
templates() {
return this.$store.dispatch('modal/open', 'templates')
.then(templates => this.$store.dispatch('data/setTemplates', templates));
},
reset() {
return this.$store.dispatch('modal/reset')
.then(() => localDbSvc.removeDb());
},
addGoogleDriveAccount() {
return googleHelper.addGoogleDriveAccount();
},
openGoogleDrive(token) {
return googleHelper.openPicker(token, 'doc')
.then(files => this.$store.dispatch('queue/enqueue',
() => googleDriveProvider.openFiles(token, files)));
},
exportMarkdown() {
const currentFile = this.$store.getters['file/current'];
return exportSvc.exportToDisk(currentFile.id, 'md');
},
exportHtml() {
return this.$store.dispatch('modal/open', 'htmlExport');
},
exportPdf() {
return this.$store.dispatch('modal/notImplemented');
},
}, },
}; };
</script> </script>
@ -233,6 +91,15 @@ export default {
hr { hr {
margin: 10px; margin: 10px;
display: none;
}
* + hr {
display: block;
}
hr + hr {
display: none;
} }
} }
@ -253,7 +120,7 @@ export default {
} }
.side-bar__panel--menu { .side-bar__panel--menu {
padding: 10px; padding: 10px 10px 50px;
} }
.side-bar__panel--help { .side-bar__panel--help {
@ -275,4 +142,16 @@ export default {
background-color: rgba(0, 0, 0, 0.05); background-color: rgba(0, 0, 0, 0.05);
} }
} }
.side-bar__warning {
padding: 10px;
margin: 0 -10px;
color: darken($error-color, 10);
background-color: transparentize($error-color, 0.925);
p {
margin: 10px;
line-height: 1.4;
}
}
</style> </style>

View File

@ -1,14 +1,24 @@
<template> <template>
<div class="toc"> <div class="toc">
<div class="toc__inner"> <div class="toc__mask" :style="{top: (maskY - 5) + 'px'}"></div>
</div> <div class="toc__inner"></div>
</div> </div>
</template> </template>
<script> <script>
import Vue from 'vue';
import { mapGetters } from 'vuex';
import editorSvc from '../services/editorSvc'; import editorSvc from '../services/editorSvc';
export default { export default {
data: () => ({
maskY: -999,
}),
computed: {
...mapGetters('layout', [
'styles',
]),
},
mounted() { mounted() {
const tocElt = this.$el.querySelector('.toc__inner'); const tocElt = this.$el.querySelector('.toc__inner');
@ -50,6 +60,27 @@ export default {
tocElt.addEventListener('mousemove', (e) => { tocElt.addEventListener('mousemove', (e) => {
onClick(e); onClick(e);
}); });
// Change mask postion on scroll
const updateMaskY = () => {
const scrollPosition = editorSvc.getScrollPosition();
const sectionDesc = editorSvc.sectionDescList[scrollPosition.sectionIdx];
this.maskY = sectionDesc.tocDimension.startOffset +
(scrollPosition.posInSection * sectionDesc.tocDimension.height);
};
Vue.nextTick(() => {
editorSvc.editorElt.parentNode.addEventListener('scroll', () => {
if (this.styles.showEditor) {
updateMaskY();
}
});
editorSvc.previewElt.parentNode.addEventListener('scroll', () => {
if (!this.styles.showEditor) {
updateMaskY();
}
});
});
}, },
}; };
</script> </script>
@ -99,4 +130,13 @@ export default {
} }
} }
} }
.toc__mask {
position: absolute;
left: 0;
width: 100%;
height: 35px;
background-color: rgba(0, 0, 0, 0.05);
pointer-events: none;
}
</style> </style>

View File

@ -143,6 +143,22 @@ textarea {
} }
} }
.form-entry__radio,
.form-entry__checkbox {
margin: 0.25em 1em;
input {
margin-right: 0.25em;
}
}
.form-entry__info {
font-size: 0.75em;
opacity: 0.5;
line-height: 1.4;
margin: 0.25em 0;
}
.textfield { .textfield {
background-color: transparent; background-color: transparent;
border: 0; border: 0;
@ -168,42 +184,30 @@ textarea {
} }
.flex { .flex {
display: -webkit-box;
display: -webkit-flex;
display: -moz-box;
display: -ms-flexbox;
display: flex; display: flex;
} }
.flex--row { .flex--row {
-webkit-box-orient: horizontal;
-webkit-flex-direction: row;
flex-direction: row; flex-direction: row;
} }
.flex--column { .flex--column {
-webkit-box-orient: vertical;
-webkit-flex-direction: column;
flex-direction: column; flex-direction: column;
} }
.flex--center { .flex--center {
-webkit-justify-content: center;
justify-content: center; justify-content: center;
} }
.flex--end { .flex--end {
-webkit-justify-content: flex-end;
justify-content: flex-end; justify-content: flex-end;
} }
.flex--space-between { .flex--space-between {
-webkit-justify-content: space-between;
justify-content: space-between; justify-content: space-between;
} }
.flex--align-center { .flex--align-center {
-webkit-align-items: center;
align-items: center; align-items: center;
} }
@ -212,7 +216,6 @@ textarea {
line-height: 36px; line-height: 36px;
padding: 4px 4px 0; padding: 4px 4px 0;
background-color: rgba(0, 0, 0, 0.1); background-color: rgba(0, 0, 0, 0.1);
-webkit-flex: none;
flex: none; flex: none;
} }
@ -223,7 +226,6 @@ textarea {
display: inline-block; display: inline-block;
background-color: transparent; background-color: transparent;
opacity: 0.75; opacity: 0.75;
-webkit-flex: none;
flex: none; flex: none;
/* prevent from seeing wrapped buttons */ /* prevent from seeing wrapped buttons */
@ -261,7 +263,8 @@ textarea {
width: 50%; width: 50%;
float: left; float: left;
text-align: center; text-align: center;
line-height: 2.5em; line-height: 1.4;
padding: 0.67em 0.33em;
cursor: pointer; cursor: pointer;
border-bottom: 2px solid transparent; border-bottom: 2px solid transparent;
border-top-left-radius: $border-radius-base; border-top-left-radius: $border-radius-base;

View File

@ -6,6 +6,7 @@ $font-size-monospace: 0.85em;
$code-bg: rgba(0, 0, 0, 0.05); $code-bg: rgba(0, 0, 0, 0.05);
$code-border-radius: 2px; $code-border-radius: 2px;
$link-color: #0c93e4; $link-color: #0c93e4;
$error-color: #f20;
$border-radius-base: 2px; $border-radius-base: 2px;
$hr-color: rgba(128, 128, 128, 0.2); $hr-color: rgba(128, 128, 128, 0.2);
$navbar-color: rgba(255, 255, 255, 0.67); $navbar-color: rgba(255, 255, 255, 0.67);

View File

@ -0,0 +1,39 @@
<template>
<div class="side-bar__panel side-bar__panel--menu">
<menu-entry @click.native="exportMarkdown">
<icon-download slot="icon"></icon-download>
Export as Markdown
</menu-entry>
<menu-entry @click.native="exportHtml">
<icon-download slot="icon"></icon-download>
Export as HTML
</menu-entry>
<menu-entry @click.native="exportPdf">
<icon-download slot="icon"></icon-download>
Export as PDF
</menu-entry>
</div>
</template>
<script>
import MenuEntry from './MenuEntry';
import exportSvc from '../../services/exportSvc';
export default {
components: {
MenuEntry,
},
methods: {
exportMarkdown() {
const currentFile = this.$store.getters['file/current'];
return exportSvc.exportToDisk(currentFile.id, 'md');
},
exportHtml() {
return this.$store.dispatch('modal/open', 'htmlExport');
},
exportPdf() {
return this.$store.dispatch('modal/notImplemented');
},
},
};
</script>

View File

@ -0,0 +1,81 @@
<template>
<div class="side-bar__panel side-bar__panel--menu">
<menu-entry v-if="!loginToken" @click.native="signin">
<icon-login slot="icon"></icon-login>
<div>Sign in with Google</div>
<span>Back up and sync all your files, folders and settings.</span>
</menu-entry>
<hr>
<menu-entry @click.native="setPanel('sync')">
<icon-sync slot="icon"></icon-sync>
<div>Synchronize</div>
<span>Open, save, collaborate in the Cloud.</span>
</menu-entry>
<menu-entry @click.native="setPanel('publish')">
<icon-upload slot="icon"></icon-upload>
<div>Publish</div>
<span>Export to the web.</span>
</menu-entry>
<hr>
<menu-entry @click.native="fileProperties">
<icon-view-list slot="icon"></icon-view-list>
<div>File properties</div>
<span>Add metadata and configure extensions.</span>
</menu-entry>
<menu-entry @click.native="setPanel('toc')">
<icon-toc slot="icon"></icon-toc>
Table of contents
</menu-entry>
<menu-entry @click.native="setPanel('help')">
<icon-help-circle slot="icon"></icon-help-circle>
Markdown cheat sheet
</menu-entry>
<hr>
<menu-entry @click.native="importFile">
<icon-hard-disk slot="icon"></icon-hard-disk>
Import from disk
</menu-entry>
<menu-entry @click.native="setPanel('export')">
<icon-hard-disk slot="icon"></icon-hard-disk>
Export to disk
</menu-entry>
<hr>
<menu-entry @click.native="setPanel('more')">
More...
</menu-entry>
</div>
</template>
<script>
import { mapGetters, mapActions } from 'vuex';
import MenuEntry from './MenuEntry';
import googleHelper from '../../services/providers/helpers/googleHelper';
import syncSvc from '../../services/syncSvc';
export default {
components: {
MenuEntry,
},
computed: {
...mapGetters('data', [
'loginToken',
]),
},
methods: {
...mapActions('data', {
setPanel: 'setSideBarPanel',
}),
signin() {
return googleHelper.signin()
.then(() => syncSvc.requestSync());
},
importFile() {
return this.$store.dispatch('modal/notImplemented');
},
fileProperties() {
return this.$store.dispatch('modal/open', 'fileProperties')
.then(properties => this.$store.dispatch('content/patchCurrent', { properties }));
},
},
};
</script>

View File

@ -28,7 +28,6 @@
height: 20px; height: 20px;
width: 20px; width: 20px;
margin-right: 12px; margin-right: 12px;
-webkit-flex: none;
flex: none; flex: none;
} }
</style> </style>

View File

@ -0,0 +1,44 @@
<template>
<div class="side-bar__panel side-bar__panel--menu">
<menu-entry @click.native="settings">
<icon-settings slot="icon"></icon-settings>
<div>Settings</div>
<span>Tweak application and keyboard shortcuts.</span>
</menu-entry>
<menu-entry @click.native="templates">
<icon-code-braces slot="icon"></icon-code-braces>
<div>Templates</div>
<span>Configure Handlebars templates for your exports.</span>
</menu-entry>
<menu-entry @click.native="reset">
<icon-logout slot="icon"></icon-logout>
<div>Reset application</div>
<span>Sign out and clean local data.</span>
</menu-entry>
</div>
</template>
<script>
import MenuEntry from './MenuEntry';
import localDbSvc from '../../services/localDbSvc';
export default {
components: {
MenuEntry,
},
methods: {
settings() {
return this.$store.dispatch('modal/open', 'settings')
.then(settings => this.$store.dispatch('data/setSettings', settings));
},
templates() {
return this.$store.dispatch('modal/open', 'templates')
.then(({ templates }) => this.$store.dispatch('data/setTemplates', templates));
},
reset() {
return this.$store.dispatch('modal/reset')
.then(() => localDbSvc.removeDb());
},
},
};
</script>

View File

@ -0,0 +1,165 @@
<template>
<div class="side-bar__panel side-bar__panel--menu">
<div class="side-bar__warning" v-if="publishLocations.length">
<p><b>{{currentFileName}}</b> is already published.</p>
<menu-entry v-if="!offline" @click.native="requestPublish">
<icon-upload slot="icon"></icon-upload>
<div>Publish now</div>
<span>Upload current file to its publication locations.</span>
</menu-entry>
<menu-entry @click.native="managePublish">
<icon-view-list slot="icon"></icon-view-list>
<div>File publication</div>
<span>Manage current file publication locations.</span>
</menu-entry>
</div>
<hr>
<div v-for="token in googleDriveTokens" :key="token.sub">
<menu-entry @click.native="publishGoogleDrive(token)">
<icon-provider slot="icon" provider-id="googleDrive"></icon-provider>
<div>Publish to Google Drive</div>
<span>{{token.name}}</span>
</menu-entry>
</div>
<div v-for="token in dropboxTokens" :key="token.sub">
<menu-entry @click.native="publishDropbox(token)">
<icon-provider slot="icon" provider-id="dropbox"></icon-provider>
<div>Publish to Dropbox</div>
<span>{{token.name}}</span>
</menu-entry>
</div>
<div v-for="token in githubTokens" :key="token.sub">
<menu-entry @click.native="publishGithub(token)">
<icon-provider slot="icon" provider-id="github"></icon-provider>
<div>Publish to GitHub</div>
<span>{{token.name}}</span>
</menu-entry>
<menu-entry @click.native="publishGist(token)">
<icon-provider slot="icon" provider-id="gist"></icon-provider>
<div>Publish to Gist</div>
<span>{{token.name}}</span>
</menu-entry>
</div>
<div v-for="token in bloggerTokens" :key="token.sub">
<menu-entry @click.native="publishBlogger(token)">
<icon-provider slot="icon" provider-id="blogger"></icon-provider>
<div>Publish to Blogger</div>
<span>{{token.name}}</span>
</menu-entry>
<menu-entry @click.native="publishBloggerPage(token)">
<icon-provider slot="icon" provider-id="bloggerPage"></icon-provider>
<div>Publish to Blogger Page</div>
<span>{{token.name}}</span>
</menu-entry>
</div>
<hr>
<menu-entry @click.native="addGoogleDriveAccount">
<icon-provider slot="icon" provider-id="googleDrive"></icon-provider>
<span>Add Google Drive account</span>
</menu-entry>
<menu-entry @click.native="addDropboxAccount">
<icon-provider slot="icon" provider-id="dropbox"></icon-provider>
<span>Add Dropbox account</span>
</menu-entry>
<menu-entry @click.native="addGithubAccount">
<icon-provider slot="icon" provider-id="github"></icon-provider>
<span>Add GitHub account</span>
</menu-entry>
<menu-entry @click.native="addBloggerAccount">
<icon-provider slot="icon" provider-id="blogger"></icon-provider>
<span>Add Blogger account</span>
</menu-entry>
</div>
</template>
<script>
import { mapState, mapGetters } from 'vuex';
import MenuEntry from './MenuEntry';
import googleHelper from '../../services/providers/helpers/googleHelper';
import dropboxHelper from '../../services/providers/helpers/dropboxHelper';
import githubHelper from '../../services/providers/helpers/githubHelper';
import publishSvc from '../../services/publishSvc';
import store from '../../store';
const tokensToArray = (tokens, filter = () => true) => Object.keys(tokens)
.map(sub => tokens[sub])
.filter(token => filter(token))
.sort((token1, token2) => token1.name.localeCompare(token2.name));
const openPublishModal = (token, type) => store.dispatch('modal/open', {
type,
token,
}).then(publishLocation => publishSvc.createPublishLocation(publishLocation));
export default {
components: {
MenuEntry,
},
computed: {
...mapState([
'offline',
]),
...mapState('queue', [
'isPublishRequested',
]),
...mapGetters('publishLocation', {
publishLocations: 'current',
}),
currentFileName() {
return this.$store.getters['file/current'].name;
},
googleDriveTokens() {
return tokensToArray(this.$store.getters['data/googleTokens'], token => token.isDrive);
},
dropboxTokens() {
return tokensToArray(this.$store.getters['data/dropboxTokens']);
},
githubTokens() {
return tokensToArray(this.$store.getters['data/githubTokens']);
},
bloggerTokens() {
return tokensToArray(this.$store.getters['data/googleTokens'], token => token.isBlogger);
},
},
methods: {
requestPublish() {
if (!this.isPublishRequested) {
publishSvc.requestPublish();
}
},
managePublish() {
return this.$store.dispatch('modal/open', 'publishManagement');
},
addGoogleDriveAccount() {
return googleHelper.addDriveAccount();
},
addDropboxAccount() {
return dropboxHelper.addAccount();
},
addGithubAccount() {
return githubHelper.addAccount();
},
addBloggerAccount() {
return googleHelper.addBloggerAccount();
},
publishGoogleDrive(token) {
return openPublishModal(token, 'googleDrivePublish');
},
publishDropbox(token) {
return openPublishModal(token, 'dropboxPublish');
},
publishGithub(token) {
return openPublishModal(token, 'githubPublish');
},
publishGist(token) {
return openPublishModal(token, 'gistPublish');
},
publishBlogger(token) {
return openPublishModal(token, 'bloggerPublish');
},
publishBloggerPage(token) {
return openPublishModal(token, 'bloggerPagePublish');
},
},
};
</script>

View File

@ -0,0 +1,179 @@
<template>
<div class="side-bar__panel side-bar__panel--menu">
<div class="side-bar__warning" v-if="syncLocations.length">
<p><b>{{currentFileName}}</b> is already synchronized.</p>
<menu-entry v-if="!offline && isSyncPossible" @click.native="requestSync">
<icon-sync slot="icon"></icon-sync>
<div>Synchronize now</div>
<span>Download, merge and upload file changes.</span>
</menu-entry>
<menu-entry @click.native="manageSync">
<icon-view-list slot="icon"></icon-view-list>
<div>File synchronization</div>
<span>Manage current file synchronized locations.</span>
</menu-entry>
</div>
<div v-else-if="!offline && isSyncPossible">
<menu-entry @click.native="requestSync">
<icon-sync slot="icon"></icon-sync>
<div>Synchronize now</div>
<span>Download, merge and upload file changes.</span>
</menu-entry>
</div>
<hr>
<div v-for="token in googleDriveTokens" :key="token.sub">
<menu-entry @click.native="openGoogleDrive(token)">
<icon-provider slot="icon" provider-id="googleDrive"></icon-provider>
<div>Open from Google Drive</div>
<span>{{token.name}}</span>
</menu-entry>
<menu-entry @click.native="saveGoogleDrive(token)">
<icon-provider slot="icon" provider-id="googleDrive"></icon-provider>
<div>Save on Google Drive</div>
<span>{{token.name}}</span>
</menu-entry>
</div>
<div v-for="token in dropboxTokens" :key="token.sub">
<menu-entry @click.native="openDropbox(token)">
<icon-provider slot="icon" provider-id="dropbox"></icon-provider>
<div>Open from Dropbox</div>
<span>{{token.name}}</span>
</menu-entry>
<menu-entry @click.native="saveDropbox(token)">
<icon-provider slot="icon" provider-id="dropbox"></icon-provider>
<div>Save on Dropbox</div>
<span>{{token.name}}</span>
</menu-entry>
</div>
<div v-for="token in githubTokens" :key="token.sub">
<menu-entry @click.native="saveGithub(token)">
<icon-provider slot="icon" provider-id="github"></icon-provider>
<div>Save on GitHub</div>
<span>{{token.name}}</span>
</menu-entry>
<menu-entry @click.native="saveGist(token)">
<icon-provider slot="icon" provider-id="gist"></icon-provider>
<div>Save on Gist</div>
<span>{{token.name}}</span>
</menu-entry>
</div>
<hr>
<menu-entry @click.native="addGoogleDriveAccount">
<icon-provider slot="icon" provider-id="googleDrive"></icon-provider>
<span>Add Google Drive account</span>
</menu-entry>
<menu-entry @click.native="addDropboxAccount">
<icon-provider slot="icon" provider-id="dropbox"></icon-provider>
<span>Add Dropbox account</span>
</menu-entry>
<menu-entry @click.native="addGithubAccount">
<icon-provider slot="icon" provider-id="github"></icon-provider>
<span>Add GitHub account</span>
</menu-entry>
</div>
</template>
<script>
import { mapState, mapGetters } from 'vuex';
import MenuEntry from './MenuEntry';
import googleHelper from '../../services/providers/helpers/googleHelper';
import dropboxHelper from '../../services/providers/helpers/dropboxHelper';
import githubHelper from '../../services/providers/helpers/githubHelper';
import googleDriveProvider from '../../services/providers/googleDriveProvider';
import dropboxProvider from '../../services/providers/dropboxProvider';
import dropboxRestrictedProvider from '../../services/providers/dropboxRestrictedProvider';
import syncSvc from '../../services/syncSvc';
import store from '../../store';
const tokensToArray = (tokens, filter = () => true) => Object.keys(tokens)
.map(sub => tokens[sub])
.filter(token => filter(token))
.sort((token1, token2) => token1.name.localeCompare(token2.name));
const openSyncModal = (token, type) => store.dispatch('modal/open', {
type,
token,
}).then(syncLocation => syncSvc.createSyncLocation(syncLocation));
export default {
components: {
MenuEntry,
},
computed: {
...mapState([
'offline',
]),
...mapState('queue', [
'isSyncRequested',
]),
...mapGetters('data', [
'loginToken',
]),
...mapGetters('syncLocation', {
syncLocations: 'current',
}),
currentFileName() {
return this.$store.getters['file/current'].name;
},
isSyncPossible() {
return this.$store.getters['data/loginToken'] ||
this.$store.getters['syncLocation/current'].length;
},
googleDriveTokens() {
return tokensToArray(this.$store.getters['data/googleTokens'], token => token.isDrive);
},
dropboxTokens() {
return tokensToArray(this.$store.getters['data/dropboxTokens']);
},
githubTokens() {
return tokensToArray(this.$store.getters['data/githubTokens']);
},
},
methods: {
requestSync() {
if (!this.isSyncRequested) {
syncSvc.requestSync();
}
},
manageSync() {
return this.$store.dispatch('modal/open', 'syncManagement');
},
addGoogleDriveAccount() {
return googleHelper.addDriveAccount();
},
addDropboxAccount() {
return dropboxHelper.addAccount();
},
addGithubAccount() {
return githubHelper.addAccount();
},
openGoogleDrive(token) {
return googleHelper.openPicker(token, 'doc')
.then(files => this.$store.dispatch('queue/enqueue',
() => googleDriveProvider.openFiles(token, files)));
},
openDropbox(token) {
return dropboxHelper.openChooser(token)
.then(paths => this.$store.dispatch('queue/enqueue',
() => {
if (token.fullAccess) {
return dropboxProvider.openFiles(token, paths);
}
return dropboxRestrictedProvider.openFiles(token, paths);
}));
},
saveGoogleDrive(token) {
return openSyncModal(token, 'googleDriveSync');
},
saveDropbox(token) {
return openSyncModal(token, 'dropboxSync');
},
saveGithub(token) {
return openSyncModal(token, 'githubSync');
},
saveGist(token) {
return openSyncModal(token, 'gistSync');
},
},
};
</script>

View File

@ -0,0 +1,104 @@
<template>
<div class="modal__inner-1 modal__inner-1--google-drive-sync">
<div class="modal__inner-2">
<div class="modal__image">
<icon-provider provider-id="bloggerPage"></icon-provider>
</div>
<p>This will publish <b>{{currentFileName}}</b> to your <b>Blogger Page</b>.</p>
<div class="form-entry">
<label class="form-entry__label" for="blogUrl">Blog URL</label>
<div class="form-entry__field">
<input id="blogUrl" type="text" class="textfield" v-model="blogUrl" @keyup.enter="resolve()">
</div>
<div class="form-entry__info">
<b>Example:</b> http://example.blogger.com/
</div>
</div>
<div class="form-entry">
<label class="form-entry__label" for="fileId">Existing page ID (optional)</label>
<div class="form-entry__field">
<input id="fileId" type="text" class="textfield" v-model="pageId" @keyup.enter="resolve()">
</div>
</div>
<div class="form-entry">
<label class="form-entry__label" for="template">Template</label>
<div class="form-entry__field">
<select class="textfield" id="template" v-model="selectedTemplate" @keyup.enter="resolve()">
<option v-for="(template, id) in allTemplates" :key="id" v-bind:value="id">
{{ template.name }}
</option>
</select>
</div>
<div class="form-entry__actions">
<a href="javascript:void(0)" @click="configureTemplates">Configure templates</a>
</div>
</div>
<div class="modal__tip">
<b>Tip:</b> You can provide a value for <code>title</code> in the <b>file properties</b>.
</div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
</div>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import bloggerPageProvider from '../../services/providers/bloggerPageProvider';
import store from '../../store';
const computedLocalSetting = id => ({
get() {
return store.getters['data/localSettings'][id];
},
set(value) {
store.dispatch('data/patchLocalSettings', {
[id]: value,
});
},
});
export default {
data: () => ({
pageId: '',
}),
computed: {
...mapGetters('modal', [
'config',
]),
currentFileName() {
return this.$store.getters['file/current'].name;
},
...mapGetters('data', [
'allTemplates',
]),
blogUrl: computedLocalSetting('bloggerBlogUrl'),
selectedTemplate: computedLocalSetting('bloggerPublishTemplate'),
},
methods: {
configureTemplates() {
this.$store.dispatch('modal/open', {
type: 'templates',
selectedId: this.selectedTemplate,
})
.then(({ templates, selectedId }) => {
this.$store.dispatch('data/setTemplates', templates);
this.$store.dispatch('data/patchLocalSettings', {
bloggerPublishTemplate: selectedId,
});
});
},
resolve() {
if (this.blogUrl) {
// Return new location
const location = bloggerPageProvider.makeLocation(
this.config.token, this.blogUrl, this.pageId);
location.templateId = this.selectedTemplate;
this.config.resolve(location);
}
},
},
};
</script>

View File

@ -0,0 +1,105 @@
<template>
<div class="modal__inner-1 modal__inner-1--google-drive-sync">
<div class="modal__inner-2">
<div class="modal__image">
<icon-provider provider-id="blogger"></icon-provider>
</div>
<p>This will publish <b>{{currentFileName}}</b> to your <b>Blogger</b> site.</p>
<div class="form-entry">
<label class="form-entry__label" for="blogUrl">Blog URL</label>
<div class="form-entry__field">
<input id="blogUrl" type="text" class="textfield" v-model="blogUrl" @keyup.enter="resolve()">
</div>
<div class="form-entry__info">
<b>Example:</b> http://example.blogger.com/
</div>
</div>
<div class="form-entry">
<label class="form-entry__label" for="fileId">Existing post ID (optional)</label>
<div class="form-entry__field">
<input id="fileId" type="text" class="textfield" v-model="postId" @keyup.enter="resolve()">
</div>
</div>
<div class="form-entry">
<label class="form-entry__label" for="template">Template</label>
<div class="form-entry__field">
<select class="textfield" id="template" v-model="selectedTemplate" @keyup.enter="resolve()">
<option v-for="(template, id) in allTemplates" :key="id" v-bind:value="id">
{{ template.name }}
</option>
</select>
</div>
<div class="form-entry__actions">
<a href="javascript:void(0)" @click="configureTemplates">Configure templates</a>
</div>
</div>
<div class="modal__tip">
<b>Tip:</b> You can provide values for <code>title</code>, <code>tags</code>,
<code>status</code> and <code>date</code> in the <b>file properties</b>.
</div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
</div>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import bloggerProvider from '../../services/providers/bloggerProvider';
import store from '../../store';
const computedLocalSetting = id => ({
get() {
return store.getters['data/localSettings'][id];
},
set(value) {
store.dispatch('data/patchLocalSettings', {
[id]: value,
});
},
});
export default {
data: () => ({
postId: '',
}),
computed: {
...mapGetters('modal', [
'config',
]),
currentFileName() {
return this.$store.getters['file/current'].name;
},
...mapGetters('data', [
'allTemplates',
]),
blogUrl: computedLocalSetting('bloggerBlogUrl'),
selectedTemplate: computedLocalSetting('bloggerPublishTemplate'),
},
methods: {
configureTemplates() {
this.$store.dispatch('modal/open', {
type: 'templates',
selectedId: this.selectedTemplate,
})
.then(({ templates, selectedId }) => {
this.$store.dispatch('data/setTemplates', templates);
this.$store.dispatch('data/patchLocalSettings', {
bloggerPublishTemplate: selectedId,
});
});
},
resolve() {
if (this.blogUrl) {
// Return new location
const location = bloggerProvider.makeLocation(
this.config.token, this.blogUrl, this.postId);
location.templateId = this.selectedTemplate;
this.config.resolve(location);
}
},
},
};
</script>

View File

@ -0,0 +1,100 @@
<template>
<div class="modal__inner-1 modal__inner-1--google-drive-sync">
<div class="modal__inner-2">
<div class="modal__image">
<icon-provider provider-id="dropbox"></icon-provider>
</div>
<p>This will publish <b>{{currentFileName}}</b> to your <b>Dropbox</b>.</p>
<div class="form-entry">
<label class="form-entry__label" for="path">File path</label>
<div class="form-entry__field">
<input id="path" type="text" class="textfield" v-model.trim="path" @keyup.enter="resolve()">
</div>
<div class="form-entry__info">
<b>Example:</b> /path/to/My Document.html<br>
If the file exists, it will be replaced.
</div>
</div>
<div class="form-entry">
<label class="form-entry__label" for="template">Template</label>
<div class="form-entry__field">
<select class="textfield" id="template" v-model="selectedTemplate" @keyup.enter="resolve()">
<option v-for="(template, id) in allTemplates" :key="id" v-bind:value="id">
{{ template.name }}
</option>
</select>
</div>
<div class="form-entry__actions">
<a href="javascript:void(0)" @click="configureTemplates">Configure templates</a>
</div>
</div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
</div>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import dropboxProvider from '../../services/providers/dropboxProvider';
import dropboxRestrictedProvider from '../../services/providers/dropboxRestrictedProvider';
import store from '../../store';
const computedLocalSetting = id => ({
get() {
return store.getters['data/localSettings'][id];
},
set(value) {
store.dispatch('data/patchLocalSettings', {
[id]: value,
});
},
});
export default {
data: () => ({
path: '',
}),
computed: {
...mapGetters('modal', [
'config',
]),
currentFileName() {
return this.$store.getters['file/current'].name;
},
...mapGetters('data', [
'allTemplates',
]),
selectedTemplate: computedLocalSetting('dropboxPublishTemplate'),
},
created() {
this.path = `/${this.currentFileName}.html`;
},
methods: {
configureTemplates() {
this.$store.dispatch('modal/open', {
type: 'templates',
selectedId: this.selectedTemplate,
})
.then(({ templates, selectedId }) => {
this.$store.dispatch('data/setTemplates', templates);
this.$store.dispatch('data/patchLocalSettings', {
dropboxPublishTemplate: selectedId,
});
});
},
resolve() {
if (dropboxProvider.checkPath(this.path)) {
// Return new location
const location = this.config.token.fullAccess
? dropboxProvider.makeLocation(this.config.token, this.path)
: dropboxRestrictedProvider.makeLocation(this.config.token, this.path);
location.templateId = this.selectedTemplate;
this.config.resolve(location);
}
},
},
};
</script>

View File

@ -0,0 +1,58 @@
<template>
<div class="modal__inner-1 modal__inner-1--google-drive-sync">
<div class="modal__inner-2">
<div class="modal__image">
<icon-provider provider-id="dropbox"></icon-provider>
</div>
<p>This will save <b>{{currentFileName}}</b> to your <b>Dropbox</b> and keep it synchronized.</p>
<div class="form-entry">
<label class="form-entry__label" for="path">File path</label>
<div class="form-entry__field">
<input id="path" type="text" class="textfield" v-model.trim="path" @keyup.enter="resolve()">
</div>
<div class="form-entry__info">
<b>Example:</b> /path/to/My Document.md<br>
If the file exists, it will be replaced.
</div>
</div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
</div>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import dropboxProvider from '../../services/providers/dropboxProvider';
import dropboxRestrictedProvider from '../../services/providers/dropboxRestrictedProvider';
export default {
data: () => ({
path: '',
}),
computed: {
...mapGetters('modal', [
'config',
]),
currentFileName() {
return this.$store.getters['file/current'].name;
},
},
created() {
this.path = `/${this.currentFileName}.md`;
},
methods: {
resolve() {
if (dropboxProvider.checkPath(this.path)) {
// Return new location
const location = this.config.token.fullAccess
? dropboxProvider.makeLocation(this.config.token, this.path)
: dropboxRestrictedProvider.makeLocation(this.config.token, this.path);
this.config.resolve(location);
}
},
},
};
</script>

View File

@ -1,11 +1,11 @@
<template> <template>
<div class="modal__inner-1 modal__inner-1--file-properties"> <div class="modal__inner-1 modal__inner-1--file-properties">
<div class="modal__inner-2"> <div class="modal__inner-2">
<div class="tabs"> <div class="tabs flex flex--row">
<div class="tabs__tab" :class="{'tabs__tab--active': tab === 'custom'}" @click="tab = 'custom'"> <div class="tabs__tab flex flex--column flex--center" :class="{'tabs__tab--active': tab === 'custom'}" @click="tab = 'custom'">
Current file properties Current file properties
</div> </div>
<div class="tabs__tab" :class="{'tabs__tab--active': tab === 'default'}" @click="tab = 'default'"> <div class="tabs__tab flex flex--column flex--center" :class="{'tabs__tab--active': tab === 'default'}" @click="tab = 'default'">
Default properties Default properties
</div> </div>
</div> </div>
@ -27,9 +27,9 @@
<script> <script>
import yaml from 'js-yaml'; import yaml from 'js-yaml';
import { mapState } from 'vuex'; import { mapGetters } from 'vuex';
import CodeEditor from './CodeEditor'; import CodeEditor from '../CodeEditor';
import defaultProperties from '../data/defaultFileProperties.yml'; import defaultProperties from '../../data/defaultFileProperties.yml';
const emptyProperties = '# Add custom properties for the current file here to override the default properties.\n'; const emptyProperties = '# Add custom properties for the current file here to override the default properties.\n';
@ -44,7 +44,7 @@ export default {
error: null, error: null,
}), }),
computed: { computed: {
...mapState('modal', [ ...mapGetters('modal', [
'config', 'config',
]), ]),
strippedCustomProperties() { strippedCustomProperties() {
@ -70,7 +70,7 @@ export default {
</script> </script>
<style lang="scss"> <style lang="scss">
@import 'common/variables.scss'; @import '../common/variables.scss';
.modal__inner-1--file-properties { .modal__inner-1--file-properties {
max-width: 600px; max-width: 600px;

View File

@ -0,0 +1,115 @@
<template>
<div class="modal__inner-1 modal__inner-1--google-drive-sync">
<div class="modal__inner-2">
<div class="modal__image">
<icon-provider provider-id="gist"></icon-provider>
</div>
<p>This will publish <b>{{currentFileName}}</b> to a <b>Gist</b>.</p>
<div class="form-entry">
<label class="form-entry__label" for="filename">Filename</label>
<div class="form-entry__field">
<input id="filename" type="text" class="textfield" v-model.trim="filename" @keyup.enter="resolve()">
</div>
</div>
<div class="form-entry">
<div class="form-entry__checkbox">
<label>
<input type="checkbox" v-model="isPublic"> Public
</label>
</div>
</div>
<div class="form-entry">
<label class="form-entry__label" for="gistId">Gist ID (optional)</label>
<div class="form-entry__field">
<input id="gistId" type="text" class="textfield" v-model.trim="gistId" @keyup.enter="resolve()">
</div>
<div class="form-entry__info">
If the file exists in the provided Gist, it will be replaced.
</div>
</div>
<div class="form-entry">
<label class="form-entry__label" for="template">Template</label>
<div class="form-entry__field">
<select class="textfield" id="template" v-model="selectedTemplate" @keyup.enter="resolve()">
<option v-for="(template, id) in allTemplates" :key="id" v-bind:value="id">
{{ template.name }}
</option>
</select>
</div>
<div class="form-entry__actions">
<a href="javascript:void(0)" @click="configureTemplates">Configure templates</a>
</div>
</div>
<div class="modal__tip">
<b>Tip:</b> You can provide a value for <code>title</code> in the <b>file properties</b>.
</div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
</div>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import gistProvider from '../../services/providers/gistProvider';
import store from '../../store';
const computedLocalSetting = id => ({
get() {
return store.getters['data/localSettings'][id];
},
set(value) {
store.dispatch('data/patchLocalSettings', {
[id]: value,
});
},
});
export default {
data: () => ({
filename: '',
gistId: '',
}),
computed: {
...mapGetters('modal', [
'config',
]),
currentFileName() {
return this.$store.getters['file/current'].name;
},
...mapGetters('data', [
'allTemplates',
]),
isPublic: computedLocalSetting('gistIsPublic'),
selectedTemplate: computedLocalSetting('gistPublishTemplate'),
},
created() {
this.filename = `${this.currentFileName}.md`;
},
methods: {
configureTemplates() {
this.$store.dispatch('modal/open', {
type: 'templates',
selectedId: this.selectedTemplate,
})
.then(({ templates, selectedId }) => {
this.$store.dispatch('data/setTemplates', templates);
this.$store.dispatch('data/patchLocalSettings', {
gistPublishTemplate: selectedId,
});
});
},
resolve() {
if (this.filename) {
// Return new location
const location = gistProvider.makeLocation(
this.config.token, this.filename, this.isPublic, this.gistId);
location.templateId = this.selectedTemplate;
this.config.resolve(location);
}
},
},
};
</script>

View File

@ -0,0 +1,82 @@
<template>
<div class="modal__inner-1 modal__inner-1--google-drive-sync">
<div class="modal__inner-2">
<div class="modal__image">
<icon-provider provider-id="gist"></icon-provider>
</div>
<p>This will save <b>{{currentFileName}}</b> to a <b>Gist</b> repository and keep it synchronized.</p>
<div class="form-entry">
<label class="form-entry__label" for="filename">Filename</label>
<div class="form-entry__field">
<input id="filename" type="text" class="textfield" v-model.trim="filename" @keyup.enter="resolve()">
</div>
</div>
<div class="form-entry">
<div class="form-entry__checkbox">
<label>
<input type="checkbox" v-model="isPublic"> Public
</label>
</div>
</div>
<div class="form-entry">
<label class="form-entry__label" for="gistId">Gist ID (optional)</label>
<div class="form-entry__field">
<input id="gistId" type="text" class="textfield" v-model.trim="gistId" @keyup.enter="resolve()">
</div>
<div class="form-entry__info">
If the file exists in the provided Gist, it will be replaced.
</div>
</div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
</div>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import gistProvider from '../../services/providers/gistProvider';
import store from '../../store';
const computedLocalSetting = id => ({
get() {
return store.getters['data/localSettings'][id];
},
set(value) {
store.dispatch('data/patchLocalSettings', {
[id]: value,
});
},
});
export default {
data: () => ({
filename: '',
gistId: '',
}),
computed: {
...mapGetters('modal', [
'config',
]),
currentFileName() {
return this.$store.getters['file/current'].name;
},
isPublic: computedLocalSetting('gistIsPublic'),
},
created() {
this.filename = `${this.currentFileName}.md`;
},
methods: {
resolve() {
if (this.filename) {
// Return new location
const location = gistProvider.makeLocation(
this.config.token, this.filename, this.isPublic, this.gistId);
this.config.resolve(location);
}
},
},
};
</script>

View File

@ -0,0 +1,121 @@
<template>
<div class="modal__inner-1 modal__inner-1--google-drive-sync">
<div class="modal__inner-2">
<div class="modal__image">
<icon-provider provider-id="github"></icon-provider>
</div>
<p>This will publish <b>{{currentFileName}}</b> to your <b>GitHub</b> repository.</p>
<div class="form-entry">
<label class="form-entry__label" for="repo">Repository URL</label>
<div class="form-entry__field">
<input id="repo" type="text" class="textfield" v-model.trim="repoUrl" @keyup.enter="resolve()">
</div>
<div class="form-entry__info">
<b>Example:</b> https://github.com/benweet/stackedit
</div>
</div>
<div class="form-entry">
<label class="form-entry__label" for="branch">Branch (optional)</label>
<div class="form-entry__field">
<input id="branch" type="text" class="textfield" v-model.trim="branch" @keyup.enter="resolve()">
</div>
<div class="form-entry__info">
If not provided, the master branch will be used.
</div>
</div>
<div class="form-entry">
<label class="form-entry__label" for="path">File path</label>
<div class="form-entry__field">
<input id="path" type="text" class="textfield" v-model.trim="path" @keyup.enter="resolve()">
</div>
<div class="form-entry__info">
<b>Example:</b> docs/README.md<br>
If the file exists, it will be replaced.
</div>
</div>
<div class="form-entry">
<label class="form-entry__label" for="template">Template</label>
<div class="form-entry__field">
<select class="textfield" id="template" v-model="selectedTemplate" @keyup.enter="resolve()">
<option v-for="(template, id) in allTemplates" :key="id" v-bind:value="id">
{{ template.name }}
</option>
</select>
</div>
<div class="form-entry__actions">
<a href="javascript:void(0)" @click="configureTemplates">Configure templates</a>
</div>
</div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
</div>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import githubProvider from '../../services/providers/githubProvider';
import store from '../../store';
const computedLocalSetting = id => ({
get() {
return store.getters['data/localSettings'][id];
},
set(value) {
store.dispatch('data/patchLocalSettings', {
[id]: value,
});
},
});
export default {
data: () => ({
branch: '',
path: '',
}),
computed: {
...mapGetters('modal', [
'config',
]),
currentFileName() {
return this.$store.getters['file/current'].name;
},
...mapGetters('data', [
'allTemplates',
]),
repoUrl: computedLocalSetting('githubRepoUrl'),
selectedTemplate: computedLocalSetting('githubPublishTemplate'),
},
created() {
this.path = `${this.currentFileName}.md`;
},
methods: {
configureTemplates() {
this.$store.dispatch('modal/open', {
type: 'templates',
selectedId: this.selectedTemplate,
})
.then(({ templates, selectedId }) => {
this.$store.dispatch('data/setTemplates', templates);
this.$store.dispatch('data/patchLocalSettings', {
githubPublishTemplate: selectedId,
});
});
},
resolve() {
if (this.repoUrl && this.path) {
const parsedRepo = this.repoUrl.match(/[/:]?([^/:]+)\/([^/]+?)(?:\.git)?$/);
if (parsedRepo) {
// Return new location
const location = githubProvider.makeLocation(
this.config.token, parsedRepo[1], parsedRepo[2], this.branch || 'master', this.path);
location.templateId = this.selectedTemplate;
this.config.resolve(location);
}
}
},
},
};
</script>

View File

@ -0,0 +1,88 @@
<template>
<div class="modal__inner-1 modal__inner-1--google-drive-sync">
<div class="modal__inner-2">
<div class="modal__image">
<icon-provider provider-id="github"></icon-provider>
</div>
<p>This will save <b>{{currentFileName}}</b> to your <b>GitHub</b> repository and keep it synchronized.</p>
<div class="form-entry">
<label class="form-entry__label" for="repo">Repository URL</label>
<div class="form-entry__field">
<input id="repo" type="text" class="textfield" v-model.trim="repoUrl" @keyup.enter="resolve()">
</div>
<div class="form-entry__info">
<b>Example:</b> https://github.com/benweet/stackedit
</div>
</div>
<div class="form-entry">
<label class="form-entry__label" for="branch">Branch (optional)</label>
<div class="form-entry__field">
<input id="branch" type="text" class="textfield" v-model.trim="branch" @keyup.enter="resolve()">
</div>
<div class="form-entry__info">
If not provided, the master branch will be used.
</div>
</div>
<div class="form-entry">
<label class="form-entry__label" for="path">File path</label>
<div class="form-entry__field">
<input id="path" type="text" class="textfield" v-model.trim="path" @keyup.enter="resolve()">
</div>
<div class="form-entry__info">
<b>Example:</b> docs/README.md<br>
If the file exists, it will be replaced.
</div>
</div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
</div>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import githubProvider from '../../services/providers/githubProvider';
export default {
data: () => ({
branch: '',
path: '',
}),
computed: {
...mapGetters('modal', [
'config',
]),
currentFileName() {
return this.$store.getters['file/current'].name;
},
repoUrl: {
get() {
return this.$store.getters['data/localSettings'].githubRepoUrl;
},
set(value) {
this.$store.dispatch('data/patchLocalSettings', {
githubRepoUrl: value,
});
},
},
},
created() {
this.path = `${this.currentFileName}.md`;
},
methods: {
resolve() {
if (this.repoUrl && this.path) {
const parsedRepo = this.repoUrl.match(/[/:]?([^/:]+)\/([^/]+?)(?:\.git)?$/);
if (parsedRepo) {
// Return new location
const location = githubProvider.makeLocation(
this.config.token, parsedRepo[1], parsedRepo[2], this.branch || 'master', this.path);
this.config.resolve(location);
}
}
},
},
};
</script>

View File

@ -0,0 +1,121 @@
<template>
<div class="modal__inner-1 modal__inner-1--google-drive-sync">
<div class="modal__inner-2">
<div class="modal__image">
<icon-provider provider-id="googleDrive"></icon-provider>
</div>
<p>This will publish <b>{{currentFileName}}</b> to your <b>Google Drive</b> account.</p>
<div class="form-entry">
<label class="form-entry__label" for="fileId">File ID (optional)</label>
<div class="form-entry__field">
<input id="fileId" type="text" class="textfield" v-model="fileId" @keyup.enter="resolve()">
</div>
<div class="form-entry__info">
If no file ID is supplied, a new file will be created in your Google Drive root folder.
</div>
</div>
<div class="form-entry">
<div class="form-entry__radio">
<label>
<input type="radio" v-model="format" value="markdown"> Export Markdown
</label>
</div>
<div class="form-entry__radio">
<label>
<input type="radio" v-model="format" value="html"> Export HTML
</label>
</div>
</div>
<div class="form-entry">
<label class="form-entry__label" for="template">Template</label>
<div class="form-entry__field">
<select class="textfield" id="template" v-model="selectedTemplate" :disabled="format === 'markdown'" @keyup.enter="resolve()">
<option v-for="(template, id) in allTemplates" :key="id" v-bind:value="id">
{{ template.name }}
</option>
</select>
</div>
<div class="form-entry__actions">
<a href="javascript:void(0)" @click="configureTemplates">Configure templates</a>
</div>
</div>
<div class="modal__tip">
<b>Tip:</b> You can provide a value for <code>title</code> in the <b>file properties</b>.
</div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
</div>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import googleHelper from '../../services/providers/helpers/googleHelper';
import googleDriveProvider from '../../services/providers/googleDriveProvider';
import store from '../../store';
const computedLocalSetting = id => ({
get() {
return store.getters['data/localSettings'][id];
},
set(value) {
store.dispatch('data/patchLocalSettings', {
[id]: value,
});
},
});
export default {
data: () => ({
fileId: '',
}),
computed: {
...mapGetters('modal', [
'config',
]),
currentFileName() {
return this.$store.getters['file/current'].name;
},
...mapGetters('data', [
'allTemplates',
]),
selectedTemplate: computedLocalSetting('googleDrivePublishTemplate'),
format: computedLocalSetting('googleDrivePublishFormat'),
},
methods: {
configureTemplates() {
this.$store.dispatch('modal/open', {
type: 'templates',
selectedId: this.selectedTemplate,
})
.then(({ templates, selectedId }) => {
this.$store.dispatch('data/setTemplates', templates);
this.$store.dispatch('data/patchLocalSettings', {
googleDrivePublishTemplate: selectedId,
});
});
},
openFolder() {
return this.$store.dispatch(
'modal/hideUntil',
googleHelper.openPicker(this.config.token, 'folder')
.then((folders) => {
this.$store.dispatch('data/patchLocalSettings', {
googleDriveFolderId: folders[0].id,
});
}));
},
resolve() {
// Return new location
const location = googleDriveProvider.makeLocation(
this.config.token, this.fileId);
if (this.format) {
location.templateId = this.selectedTemplate;
}
this.config.resolve(location);
},
},
};
</script>

View File

@ -0,0 +1,90 @@
<template>
<div class="modal__inner-1 modal__inner-1--google-drive-sync">
<div class="modal__inner-2">
<div class="modal__image">
<icon-provider provider-id="googleDrive"></icon-provider>
</div>
<p>This will save <b>{{currentFileName}}</b> to your <b>Google Drive</b> account and keep it synchronized.</p>
<a href="javascript:void(0)" v-if="!showOptions" @click="showOptions = true">See options </a>
<div v-else>
<div class="form-entry">
<label class="form-entry__label" for="folderId">Folder ID (optional)</label>
<div class="form-entry__field">
<input id="folderId" type="text" class="textfield" v-model.trim="folderId" @keyup.enter="resolve()">
</div>
<div class="form-entry__info">
If no folder ID is supplied, the file will be created in your root folder.
</div>
<div class="form-entry__actions">
<a href="javascript:void(0)" @click="openFolder">Choose folder</a>
</div>
</div>
<div class="form-entry">
<label class="form-entry__label" for="fileId">File ID (optional)</label>
<div class="form-entry__field">
<input id="fileId" type="text" class="textfield" v-model="fileId" @keyup.enter="resolve()">
</div>
<div class="form-entry__info">
This will overwrite the existing file on the server.
</div>
</div>
</div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
</div>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import googleHelper from '../../services/providers/helpers/googleHelper';
import googleDriveProvider from '../../services/providers/googleDriveProvider';
export default {
data: () => ({
showOptions: false,
fileId: '',
}),
computed: {
...mapGetters('modal', [
'config',
]),
currentFileName() {
return this.$store.getters['file/current'].name;
},
folderId: {
get() {
return this.$store.getters['data/localSettings'].googleDriveFolderId;
},
set(value) {
this.$store.dispatch('data/patchLocalSettings', {
googleDriveFolderId: value,
});
},
},
},
created() {
this.showOptions = this.folderId || this.fileId;
},
methods: {
openFolder() {
return this.$store.dispatch(
'modal/hideUntil',
googleHelper.openPicker(this.config.token, 'folder')
.then((folders) => {
this.$store.dispatch('data/patchLocalSettings', {
googleDriveFolderId: folders[0].id,
});
}));
},
resolve() {
// Return new location
const location = googleDriveProvider.makeLocation(
this.config.token, this.fileId, this.folderId);
this.config.resolve(location);
},
},
};
</script>

View File

@ -23,7 +23,7 @@
</template> </template>
<script> <script>
import { mapState } from 'vuex'; import { mapGetters } from 'vuex';
const makeThumbnail = (url, size) => `${url}=s${size}`; const makeThumbnail = (url, size) => `${url}=s${size}`;
@ -36,7 +36,7 @@ export default {
thumbnailUrl() { thumbnailUrl() {
return `url(${makeThumbnail(this.config.url, 320)})`; return `url(${makeThumbnail(this.config.url, 320)})`;
}, },
...mapState('modal', [ ...mapGetters('modal', [
'config', 'config',
]), ]),
}, },

View File

@ -4,7 +4,7 @@
<div class="form-entry"> <div class="form-entry">
<label class="form-entry__label" for="template">Template</label> <label class="form-entry__label" for="template">Template</label>
<div class="form-entry__field"> <div class="form-entry__field">
<select id="template" v-model="selectedTemplate" class="textfield"> <select class="textfield" id="template" v-model="selectedTemplate" @keyup.enter="resolve()">
<option v-for="(template, id) in allTemplates" :key="id" v-bind:value="id"> <option v-for="(template, id) in allTemplates" :key="id" v-bind:value="id">
{{ template.name }} {{ template.name }}
</option> </option>
@ -24,16 +24,16 @@
</template> </template>
<script> <script>
import { mapState, mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import Clipboard from 'clipboard'; import Clipboard from 'clipboard';
import exportSvc from '../services/exportSvc'; import exportSvc from '../../services/exportSvc';
export default { export default {
data: () => ({ data: () => ({
result: '', result: '',
}), }),
computed: { computed: {
...mapState('modal', [ ...mapGetters('modal', [
'config', 'config',
]), ]),
...mapGetters('data', [ ...mapGetters('data', [
@ -41,11 +41,11 @@ export default {
]), ]),
selectedTemplate: { selectedTemplate: {
get() { get() {
return this.$store.getters['data/localSettings'].htmlExportLastTemplate; return this.$store.getters['data/localSettings'].htmlExportTemplate;
}, },
set(value) { set(value) {
this.$store.dispatch('data/patchLocalSettings', { this.$store.dispatch('data/patchLocalSettings', {
htmlExportLastTemplate: value, htmlExportTemplate: value,
}); });
}, },
}, },
@ -54,8 +54,8 @@ export default {
this.$watch('selectedTemplate', (selectedTemplate) => { this.$watch('selectedTemplate', (selectedTemplate) => {
const currentFile = this.$store.getters['file/current']; const currentFile = this.$store.getters['file/current'];
exportSvc.applyTemplate(currentFile.id, this.allTemplates[selectedTemplate]) exportSvc.applyTemplate(currentFile.id, this.allTemplates[selectedTemplate])
.then((res) => { .then((html) => {
this.result = res; this.result = html;
}); });
}, { }, {
immediate: true, immediate: true,
@ -69,13 +69,16 @@ export default {
}, },
methods: { methods: {
configureTemplates() { configureTemplates() {
const reopen = () => this.$store.dispatch('modal/open', 'htmlExport');
this.$store.dispatch('modal/open', { this.$store.dispatch('modal/open', {
type: 'templates', type: 'templates',
selectedKey: this.selectedTemplate, selectedId: this.selectedTemplate,
}) })
.then(templates => this.$store.dispatch('data/setTemplates', templates)) .then(({ templates, selectedId }) => {
.then(reopen, reopen); this.$store.dispatch('data/setTemplates', templates);
this.$store.dispatch('data/patchLocalSettings', {
htmlExportTemplate: selectedId,
});
});
}, },
resolve() { resolve() {
const currentFile = this.$store.getters['file/current']; const currentFile = this.$store.getters['file/current'];

View File

@ -1,6 +1,7 @@
<template> <template>
<div class="modal__inner-1 modal__inner-1--image"> <div class="modal__inner-1 modal__inner-1--image">
<div class="modal__inner-2"> <div class="modal__inner-2">
<p>Please provide a <b>URL</b> for your image.
<div class="form-entry"> <div class="form-entry">
<label class="form-entry__label" for="url">URL</label> <label class="form-entry__label" for="url">URL</label>
<div class="form-entry__field"> <div class="form-entry__field">
@ -8,12 +9,12 @@
</div> </div>
</div> </div>
<menu-entry @click.native="openGooglePhotos(token)" v-for="token in googlePhotosTokens" :key="token.sub"> <menu-entry @click.native="openGooglePhotos(token)" v-for="token in googlePhotosTokens" :key="token.sub">
<icon-google-photos slot="icon"></icon-google-photos> <icon-provider slot="icon" provider-id="googlePhotos"></icon-provider>
<div>Open from Google Photos</div> <div>Open from Google Photos</div>
<span>{{token.name}}</span> <span>{{token.name}}</span>
</menu-entry> </menu-entry>
<menu-entry @click.native="addGooglePhotosAccount"> <menu-entry @click.native="addGooglePhotosAccount">
<icon-google-photos slot="icon"></icon-google-photos> <icon-provider slot="icon" provider-id="googlePhotos"></icon-provider>
<span>Add Google Photos account</span> <span>Add Google Photos account</span>
</menu-entry> </menu-entry>
<div class="modal__button-bar"> <div class="modal__button-bar">
@ -25,9 +26,9 @@
</template> </template>
<script> <script>
import { mapState } from 'vuex'; import { mapGetters } from 'vuex';
import MenuEntry from './MenuEntry'; import MenuEntry from '../menus/MenuEntry';
import googleHelper from '../services/providers/helpers/googleHelper'; import googleHelper from '../../services/providers/helpers/googleHelper';
export default { export default {
components: { components: {
@ -37,7 +38,7 @@ export default {
url: '', url: '',
}), }),
computed: { computed: {
...mapState('modal', [ ...mapGetters('modal', [
'config', 'config',
]), ]),
googlePhotosTokens() { googlePhotosTokens() {

View File

@ -1,6 +1,7 @@
<template> <template>
<div class="modal__inner-1 modal__inner-1--link" @keyup.enter="resolve()"> <div class="modal__inner-1 modal__inner-1--link" @keyup.enter="resolve()">
<div class="modal__inner-2"> <div class="modal__inner-2">
<p>Please provide a <b>URL</b> for your link.
<div class="form-entry"> <div class="form-entry">
<label class="form-entry__label" for="url">URL</label> <label class="form-entry__label" for="url">URL</label>
<div class="form-entry__field"> <div class="form-entry__field">
@ -16,13 +17,13 @@
</template> </template>
<script> <script>
import { mapState } from 'vuex'; import { mapGetters } from 'vuex';
export default { export default {
data: () => ({ data: () => ({
url: '', url: '',
}), }),
computed: mapState('modal', [ computed: mapGetters('modal', [
'config', 'config',
]), ]),
methods: { methods: {

View File

@ -0,0 +1,104 @@
<template>
<div class="modal__inner-1 modal__inner-1--publish-management">
<div class="modal__inner-2">
<p v-if="publishLocations.length"><b>{{currentFileName}}</b> is published to the following location(s):</p>
<p v-else><b>{{currentFileName}}</b> is not published yet.</p>
<div>
<div v-for="location in publishLocations" :key="location.id" class="publish-entry flex flex--row flex--align-center">
<div class="publish-entry__icon flex flex--column flex--center">
<icon-provider :provider-id="location.providerId"></icon-provider>
</div>
<div class="publish-entry__description">
{{location.description}}
</div>
<div class="publish-entry__buttons flex flex--row flex--center">
<a class="publish-entry__button button" :href="location.url" target="_blank">
<icon-open-in-new></icon-open-in-new>
</a>
<button class="publish-entry__button button" @click="remove(location)">
<icon-delete></icon-delete>
</button>
</div>
</div>
</div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="config.resolve()">Ok</button>
</div>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
export default {
computed: {
...mapGetters('modal', [
'config',
]),
...mapGetters('publishLocation', {
publishLocations: 'current',
}),
currentFileName() {
return this.$store.getters['file/current'].name;
},
},
methods: {
remove(location) {
this.$store.commit('publishLocation/deleteItem', location.id);
},
},
};
</script>
<style lang="scss">
@import '../common/variables.scss';
.modal__inner-1--publish-management {
max-width: 560px;
}
.publish-entry {
padding: 0.5rem 0.25rem;
border-bottom: 1px solid $hr-color;
&:last-child {
border-bottom: none;
}
}
.publish-entry__icon {
height: 30px;
width: 30px;
margin-right: 0.75rem;
flex: none;
}
.publish-entry__description {
opacity: 0.5;
line-height: 1.4;
font-size: 0.9em;
width: 100%;
overflow: hidden;
}
.publish-entry__buttons {
margin-left: 0.75rem;
}
.publish-entry__button {
width: 38px;
height: 38px;
padding: 6px;
background-color: transparent;
opacity: 0.75;
&:active,
&:focus,
&:hover {
opacity: 1;
background-color: rgba(0, 0, 0, 0.1);
}
}
</style>

View File

@ -1,11 +1,11 @@
<template> <template>
<div class="modal__inner-1 modal__inner-1--settings"> <div class="modal__inner-1 modal__inner-1--settings">
<div class="modal__inner-2"> <div class="modal__inner-2">
<div class="tabs"> <div class="tabs flex flex--row">
<div class="tabs__tab" :class="{'tabs__tab--active': tab === 'custom'}" @click="tab = 'custom'"> <div class="tabs__tab flex flex--column flex--center" :class="{'tabs__tab--active': tab === 'custom'}" @click="tab = 'custom'">
Custom settings Custom settings
</div> </div>
<div class="tabs__tab" :class="{'tabs__tab--active': tab === 'default'}" @click="tab = 'default'"> <div class="tabs__tab flex flex--column flex--center" :class="{'tabs__tab--active': tab === 'default'}" @click="tab = 'default'">
Default settings Default settings
</div> </div>
</div> </div>
@ -27,9 +27,9 @@
<script> <script>
import yaml from 'js-yaml'; import yaml from 'js-yaml';
import { mapState } from 'vuex'; import { mapGetters } from 'vuex';
import CodeEditor from './CodeEditor'; import CodeEditor from '../CodeEditor';
import defaultSettings from '../data/defaultSettings.yml'; import defaultSettings from '../../data/defaultSettings.yml';
const emptySettings = '# Add your custom settings here to override the default settings.\n'; const emptySettings = '# Add your custom settings here to override the default settings.\n';
@ -44,7 +44,7 @@ export default {
error: null, error: null,
}), }),
computed: { computed: {
...mapState('modal', [ ...mapGetters('modal', [
'config', 'config',
]), ]),
strippedCustomSettings() { strippedCustomSettings() {
@ -70,7 +70,7 @@ export default {
</script> </script>
<style lang="scss"> <style lang="scss">
@import 'common/variables.scss'; @import '../common/variables.scss';
.modal__inner-1--settings { .modal__inner-1--settings {
max-width: 600px; max-width: 600px;

View File

@ -0,0 +1,104 @@
<template>
<div class="modal__inner-1 modal__inner-1--sync-management">
<div class="modal__inner-2">
<p v-if="syncLocations.length"><b>{{currentFileName}}</b> is synchronized with the following location(s):</p>
<p v-else><b>{{currentFileName}}</b> is not synchronized yet.</p>
<div>
<div v-for="location in syncLocations" :key="location.id" class="sync-entry flex flex--row flex--align-center">
<div class="sync-entry__icon flex flex--column flex--center">
<icon-provider :provider-id="location.providerId"></icon-provider>
</div>
<div class="sync-entry__description">
{{location.description}}
</div>
<div class="sync-entry__buttons flex flex--row flex--center">
<a class="sync-entry__button button" :href="location.url" target="_blank">
<icon-open-in-new></icon-open-in-new>
</a>
<button class="sync-entry__button button" @click="remove(location)">
<icon-delete></icon-delete>
</button>
</div>
</div>
</div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="config.resolve()">Ok</button>
</div>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
export default {
computed: {
...mapGetters('modal', [
'config',
]),
...mapGetters('syncLocation', {
syncLocations: 'current',
}),
currentFileName() {
return this.$store.getters['file/current'].name;
},
},
methods: {
remove(location) {
this.$store.commit('syncLocation/deleteItem', location.id);
},
},
};
</script>
<style lang="scss">
@import '../common/variables.scss';
.modal__inner-1--sync-management {
max-width: 560px;
}
.sync-entry {
padding: 0.5rem 0.25rem;
border-bottom: 1px solid $hr-color;
&:last-child {
border-bottom: none;
}
}
.sync-entry__icon {
height: 30px;
width: 30px;
margin-right: 0.75rem;
flex: none;
}
.sync-entry__description {
opacity: 0.5;
line-height: 1.4;
font-size: 0.9em;
width: 100%;
overflow: hidden;
}
.sync-entry__buttons {
margin-left: 0.75rem;
}
.sync-entry__button {
width: 38px;
height: 38px;
padding: 6px;
background-color: transparent;
opacity: 0.75;
&:active,
&:focus,
&:hover {
opacity: 1;
background-color: rgba(0, 0, 0, 0.1);
}
}
</style>

View File

@ -51,11 +51,13 @@
</template> </template>
<script> <script>
import { mapState } from 'vuex'; import { mapGetters } from 'vuex';
import utils from '../services/utils'; import utils from '../../services/utils';
import CodeEditor from './CodeEditor'; import CodeEditor from '../CodeEditor';
import emptyTemplateValue from '../data/emptyTemplateValue.html'; import emptyTemplateValue from '../../data/emptyTemplateValue.html';
import emptyTemplateHelpers from '!raw-loader!../data/emptyTemplateHelpers.js'; // eslint-disable-line import emptyTemplateHelpers from '!raw-loader!../../data/emptyTemplateHelpers.js'; // eslint-disable-line
const collator = new Intl.Collator(undefined, { sensitivity: 'base' });
function fillEmptyFields(template) { function fillEmptyFields(template) {
if (template.value === '\n') { if (template.value === '\n') {
@ -78,7 +80,7 @@ export default {
editingName: '', editingName: '',
}), }),
computed: { computed: {
...mapState('modal', [ ...mapGetters('modal', [
'config', 'config',
]), ]),
isReadOnly() { isReadOnly() {
@ -89,13 +91,17 @@ export default {
this.$watch( this.$watch(
() => this.$store.getters['data/allTemplates'], () => this.$store.getters['data/allTemplates'],
(allTemplates) => { (allTemplates) => {
const templates = utils.sortObject( const templates = {};
utils.deepCopy(allTemplates), // Sort templates by name
(key, template) => template.name, Object.keys(allTemplates)
); .sort((id1, id2) => collator.compare(allTemplates[id1].name, allTemplates[id2].name))
Object.keys(templates).forEach(id => fillEmptyFields(templates[id])); .forEach((id) => {
const template = utils.deepCopy(allTemplates[id]);
fillEmptyFields(template);
templates[id] = template;
});
this.templates = templates; this.templates = templates;
this.selectedId = this.$store.state.modal.config.selectedId; this.selectedId = this.config.selectedId;
if (!templates[this.selectedId]) { if (!templates[this.selectedId]) {
this.selectedId = Object.keys(templates)[0]; this.selectedId = Object.keys(templates)[0];
} }
@ -143,7 +149,10 @@ export default {
}, 1); }, 1);
}, },
resolve() { resolve() {
this.config.resolve(this.templates); this.config.resolve({
templates: this.templates,
selectedId: this.selectedId,
});
}, },
}, },
}; };
@ -151,6 +160,6 @@ export default {
<style lang="scss"> <style lang="scss">
.modal__inner-1--templates { .modal__inner-1--templates {
max-width: 720px; max-width: 680px;
} }
</style> </style>

View File

@ -1,7 +1,12 @@
### File properties can contain metadata used for your publications (Wordpress, Blogger...). ### File properties can contain metadata used for your publications (Wordpress, Blogger...).
### For example, you can specify a blog post title:
#title: My article #title: My article
#author:
#tags: Tag 1, Tag 2
#categories: Categorie 1, Categorie 2
#excerpt:
#featuredImage:
#status: draft
#date: YYYY-MM-DD HH:MM:SS
### Extension configuration ### Extension configuration
extensions: extensions:

View File

@ -7,5 +7,15 @@ export default () => ({
showExplorer: false, showExplorer: false,
focusMode: false, focusMode: false,
sideBarPanel: 'menu', sideBarPanel: 'menu',
htmlExportLastTemplate: 'styledHtml', htmlExportTemplate: 'styledHtml',
googleDriveFolderId: '',
googleDrivePublishFormat: 'markdown',
googleDrivePublishTemplate: 'styledHtml',
bloggerBlogUrl: '',
bloggerPublishTemplate: 'styledHtml',
dropboxPublishTemplate: 'styledHtml',
githubRepoUrl: '',
githubPublishTemplate: 'jekyllSite',
gistIsPublic: false,
gistPublishTemplate: 'plainText',
}); });

View File

@ -75,7 +75,7 @@ newFileContent: |
# Default properties for newly created files # Default properties for newly created files
newFileProperties: | newFileProperties: |
# extensions: #extensions:
# markdown: # markdown:
# breaks: true # breaks: true

View File

@ -0,0 +1,8 @@
export default () => ({
id: null,
type: 'publishLocation',
providerId: null,
fileId: null,
templateId: null,
hash: 0,
});

View File

@ -1,7 +1,7 @@
export default () => ({ export default () => ({
id: null, id: null,
type: 'syncLocation', type: 'syncLocation',
provider: null, providerId: null,
fileId: null, fileId: null,
hash: 0, hash: 0,
}); });

View File

@ -8,3 +8,4 @@ Handlebars.registerHelper('transform', function (options) {
); );
}); });
*/ */

View File

@ -19,6 +19,7 @@ The following JavaScript context will be passed to the template:
} }
You can use Handlebars built-in helpers and some custom StackEdit helpers: You can use Handlebars built-in helpers and some custom StackEdit helpers:
- {{#tocToHtml files.0.content.toc}}{{/tocToHtml}} will produce a nice TOC. {{#tocToHtml files.0.content.toc}}{{/tocToHtml}} will produce a nice TOC.
- {{#tocToHtml files.0.content.toc 3}}{{/tocToHtml}} will limit the TOC depth to 3. {{#tocToHtml files.0.content.toc 3}}{{/tocToHtml}} will limit the TOC depth to 3.
--> -->

View File

@ -0,0 +1,5 @@
---
{{{files.0.content.yamlProperties}}}
---
{{{files.0.content.html}}}

5
src/icons/Alert.vue Normal file
View File

@ -0,0 +1,5 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="100%" height="100%" viewBox="0 0 24.00 24.00">
<path d="M 13,14L 11,14L 11,9.99998L 13,9.99998M 13,18L 11,18L 11,16L 13,16M 1,21L 23,21L 12,1.99998L 1,21 Z "/>
</svg>
</template>

5
src/icons/FolderOpen.vue Normal file
View File

@ -0,0 +1,5 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="100%" height="100%" viewBox="0 0 24.00 24.00">
<path d="M 18.9994,19.9981L 3.99939,19.9981C 2.89437,19.9981 1.99939,19.1021 1.99939,17.9981L 2.0094,5.99807C 2.0094,4.89407 2.89437,3.99807 3.99939,3.99807L 9.99939,3.99807L 11.9994,5.99807L 18.9994,5.99808C 20.1034,5.99808 20.9994,6.89407 20.9994,7.99808L 20.9994,8.00001L 3.99939,7.99807L 3.99939,17.9981L 6.14359,10L 23.2141,10L 20.9318,18.5176C 20.7023,19.37 19.9237,19.9981 18.9994,19.9981 Z " />
</svg>
</template>

View File

@ -1,9 +0,0 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="100%" height="100%" viewBox="0 0 133156 115341">
<g>
<polygon style="fill:#3777E3" points="22194,115341 44385,76894 133156,76894 110963,115341 "/>
<polygon style="fill:#FFCF63" points="88772,76894 133156,76894 88772,0 44385,0 "/>
<polygon style="fill:#11A861" points="0,76894 22194,115341 66578,38447 44385,0 "/>
</g>
</svg>
</template>

View File

@ -1,12 +0,0 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="100%" height="100%" viewBox="0 0 512 511">
<path d="M255.912,0.08c1.4,0.8 2.6,2 3.7,3.2c41.3,41.5 82.7,83 123.899,124.6c-26,25.6 -51.6,51.6 -77.399,77.3c-9.7,9.8 -19.601,19.4 -29.2,29.4c-7.2,-17.4 -14.1,-34.9 -21,-52.4c0,-18.2 0.1,-36.4 0,-54.7c-0.1,-42.4 -0.2,-84.9 0,-127.4l0,0Z" style="fill:#dc4b3e;fill-rule:nonzero;stroke:#dd4b39;stroke-width:0.09px;"/>
<path d="M127.812,127.48l128.1,0c0.1,18.3 0,36.5 0,54.7c-7.1,17.2 -14,34.5 -20.8,51.9c-2.2,-1.2 -3.8,-3 -5.5,-4.8l-101.4,-101.4l-0.4,-0.4Z" style="fill:#ff9e0e;fill-rule:nonzero;stroke:#ef851c;stroke-width:0.09px;"/>
<path d="M383.511,127.88l0.4,-0.3c-0.1,42.6 -0.1,85.3 0,127.9l-55.1,0c-17.2,-7.2 -34.601,-13.8 -51.9,-20.9c9.6,-10 19.5,-19.6 29.2,-29.4c25.801,-25.7 51.4,-51.7 77.4,-77.3l0,0Z" style="fill:#af195a;fill-rule:nonzero;stroke:#7e3794;stroke-width:0.09px;"/>
<path d="M106.912,148.98c7.2,-6.9 13.9,-14.3 21.3,-21.1l101.4,101.4c1.7,1.8 3.3,3.6 5.5,4.8c-2.3,1.7 -5.2,2.3 -7.8,3.5c-14.801,6 -29.801,11.6 -44.5,18c-18.301,-0.2 -36.601,-0.1 -54.9,-0.1c-42.6,-0.1 -85.2,0.2 -127.8,-0.1c35.5,-35.6 71.2,-71 106.8,-106.4l0,0Z" style="fill:#ffc112;fill-rule:nonzero;stroke:#ffbb1b;stroke-width:0.09px;"/>
<path d="M127.912,255.48c18.3,0 36.6,-0.1 54.9,0.1c17.3,7.1 34.6,13.8 51.899,20.8c-28.399,28.8 -57.099,57.2 -85.599,85.9c-7.2,6.8 -13.7,14.3 -21.3,20.7c0,-42.5 -0.1,-85 0.1,-127.5Z" style="fill:#17a05e;fill-rule:nonzero;stroke:#1a8763;stroke-width:0.09px;"/>
<path d="M328.812,255.48l55.1,0c42.5,0.1 85.1,-0.1 127.6,0.1c-27.3,27.7 -55,55.1 -82.399,82.6c-15.2,15.1 -30.2,30.399 -45.4,45.3c-34,-34.4 -68.5,-68.4 -102.6,-102.8c-1.4,-1.5 -2.9,-2.8 -4.601,-3.8c2.9,-1.801 6.101,-2.7 9.2,-4c14.4,-5.8 28.799,-11.4 43.1,-17.4l0,0Z" style="fill:#4587f4;fill-rule:nonzero;stroke:#427fed;stroke-width:0.09px;"/>
<path d="M234.712,276.38c7.3,17.399 13.9,35 21.2,52.399c-0.1,18.2 0,36.5 -0.1,54.7l0,88c-0.2,13.1 0.3,26.2 -0.2,39.2c-2.101,-1 -3.4,-2.9 -5.101,-4.5c-40.899,-41.099 -81.699,-82.199 -122.699,-123.199c7.6,-6.4 14.1,-13.9 21.3,-20.7c28.5,-28.7 57.2,-57.1 85.6,-85.9Z" style="fill:#8dc44d;fill-rule:nonzero;stroke:#65b045;stroke-width:0.09px;"/>
<path d="M276.511,276.88c1.7,1 3.2,2.3 4.601,3.8c34.1,34.4 68.6,68.4 102.6,102.8c-42.7,-0.1 -85.3,0.1 -127.899,0c0.1,-18.2 0,-36.5 0.1,-54.7c6.699,-17.3 13.899,-34.5 20.598,-51.9l0,0Z" style="fill:#3569d6;fill-rule:nonzero;stroke:#43459d;stroke-width:0.09px;"/>
</svg>
</template>

View File

@ -1,5 +1,5 @@
<template> <template>
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="100%" height="100%" viewBox="0 0 24.00 24.00"> <svg xmlns="http://www.w3.org/2000/svg" class="icon" width="100%" height="100%" viewBox="0 0 24.00 24.00">
<path fill="#000000" fill-opacity="1" stroke-width="0.2" stroke-linejoin="round" d="M 15.0661,11.2518L 14.1711,12.1697C 13.4471,12.8937 12.9991,13.4977 12.9991,14.9977L 10.9991,14.9977L 10.9991,14.4977C 10.9991,13.3937 11.4471,12.3937 12.1711,11.6697L 13.4141,10.4117C 13.7751,10.0497 13.9991,9.54974 13.9991,8.99774C 13.9991,7.89374 13.1041,6.99774 11.9991,6.99774C 10.8951,6.99774 9.99908,7.89374 9.99908,8.99774L 7.99908,8.99774C 7.99908,6.78876 9.7901,4.99774 11.9991,4.99774C 14.2091,4.99774 15.9991,6.78876 15.9991,8.99774C 15.9991,9.87775 15.6431,10.6747 15.0661,11.2518 Z M 12.9991,18.9977L 10.9991,18.9977L 10.9991,16.9977L 12.9991,16.9977M 11.9991,1.99774C 6.4761,1.99774 1.99908,6.47473 1.99908,11.9977C 1.99908,17.5217 6.4761,21.9977 11.9991,21.9977C 17.5231,21.9977 21.9991,17.5217 21.9991,11.9977C 21.9991,6.47473 17.5231,1.99774 11.9991,1.99774 Z "/> <path d="M 15.0661,11.2518L 14.1711,12.1697C 13.4471,12.8937 12.9991,13.4977 12.9991,14.9977L 10.9991,14.9977L 10.9991,14.4977C 10.9991,13.3937 11.4471,12.3937 12.1711,11.6697L 13.4141,10.4117C 13.7751,10.0497 13.9991,9.54974 13.9991,8.99774C 13.9991,7.89374 13.1041,6.99774 11.9991,6.99774C 10.8951,6.99774 9.99908,7.89374 9.99908,8.99774L 7.99908,8.99774C 7.99908,6.78876 9.7901,4.99774 11.9991,4.99774C 14.2091,4.99774 15.9991,6.78876 15.9991,8.99774C 15.9991,9.87775 15.6431,10.6747 15.0661,11.2518 Z M 12.9991,18.9977L 10.9991,18.9977L 10.9991,16.9977L 12.9991,16.9977M 11.9991,1.99774C 6.4761,1.99774 1.99908,6.47473 1.99908,11.9977C 1.99908,17.5217 6.4761,21.9977 11.9991,21.9977C 17.5231,21.9977 21.9991,17.5217 21.9991,11.9977C 21.9991,6.47473 17.5231,1.99774 11.9991,1.99774 Z "/>
</svg> </svg>
</template> </template>

View File

@ -0,0 +1,5 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="100%" height="100%" viewBox="0 0 24.00 24.00">
<path d="M 12.9994,8.99805L 10.9994,8.99805L 10.9994,6.99805L 12.9994,6.99805M 12.9994,16.998L 10.9994,16.998L 10.9994,10.998L 12.9994,10.998M 11.9994,1.99805C 6.47642,1.99805 1.99943,6.47504 1.99943,11.998C 1.99943,17.5211 6.47642,21.998 11.9994,21.998C 17.5224,21.998 21.9994,17.5211 21.9994,11.998C 21.9994,6.47504 17.5224,1.99805 11.9994,1.99805 Z "/>
</svg>
</template>

5
src/icons/Menu.vue Normal file
View File

@ -0,0 +1,5 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="100%" height="100%" viewBox="0 0 24.00 24.00">
<path fill="#000000" fill-opacity="1" stroke-width="0.2" stroke-linejoin="round" d="M 3,6L 21,6L 21,8L 3,8L 3,6 Z M 3,11L 21,11L 21,13L 3,13L 3,11 Z M 3,16L 21,16L 21,18L 3,18L 3,16 Z "/>
</svg>
</template>

5
src/icons/OpenInNew.vue Normal file
View File

@ -0,0 +1,5 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="100%" height="100%" viewBox="0 0 24.00 24.00">
<path d="M 14,3L 14,5L 17.59,5L 7.76,14.83L 9.17,16.24L 19,6.41L 19,10L 21,10L 21,3M 19,19L 5,19L 5,5L 12,5L 12,3L 5,3C 3.89,3 3,3.9 3,5L 3,19C 3,20.1 3.89,21 5,21L 19,21C 20.1,21 21,20.1 21,19L 21,12L 19,12L 19,19 Z "/>
</svg>
</template>

47
src/icons/Provider.vue Normal file
View File

@ -0,0 +1,47 @@
<template>
<div class="icon-provider" :class="['icon-provider--' + providerId]">
</div>
</template>
<script>
export default {
props: ['providerId'],
};
</script>
<style lang="scss">
.icon-provider {
width: 100%;
height: 100%;
background-position: center;
background-repeat: no-repeat;
background-size: contain;
}
.icon-provider--stackedit {
background-image: url(../assets/iconStackedit.svg);
}
.icon-provider--googleDrive {
background-image: url(../assets/iconGoogleDrive.svg);
}
.icon-provider--googlePhotos {
background-image: url(../assets/iconGooglePhotos.svg);
}
.icon-provider--github,
.icon-provider--gist {
background-image: url(../assets/iconGithub.svg);
}
.icon-provider--dropbox,
.icon-provider--dropboxRestricted {
background-image: url(../assets/iconDropbox.svg);
}
.icon-provider--blogger,
.icon-provider--bloggerPage {
background-image: url(../assets/iconBlogger.svg);
}
</style>

View File

@ -1,19 +0,0 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="100%" height="100%" viewBox="0 0 126 126">
<path d="M103.5,0c3.762,0.003 7.395,0.971 10.725,2.716c0.966,0.506 1.808,1.221 2.712,1.831l-53.937,40.453l-53.937,-40.453c4.402,-3.289 8.02,-4.273 13.437,-4.547l81,0Z" style="fill:#ffe600;fill-rule:nonzero;"/>
<path d="M9.063,4.547l53.937,40.453l-58.04,72.55c-3.44,-4.417 -4.681,-8.528 -4.96,-14.05l0,-81c0.064,-5.221 1.801,-10.265 5.138,-14.312c0.914,-1.109 2.033,-2.033 3.05,-3.05l0.875,-0.591Z" style="fill:#bbd500;fill-rule:nonzero;"/>
<path d="M63,45l58.04,72.549l-0.178,0.263c-4.901,5.465 -10.068,7.82 -17.362,8.188l-81,0c-5.221,-0.065 -10.265,-1.801 -14.312,-5.138c-1.109,-0.915 -2.033,-2.034 -3.05,-3.05l-0.177,-0.262l58.039,-72.55Z" style="fill:#ff8a00;fill-rule:nonzero;"/>
<path d="M116.937,4.547c3.844,2.631 6.684,6.83 8.051,11.262c0.441,1.427 0.673,2.914 0.896,4.391c0.114,0.759 0.077,1.533 0.116,2.3l0,81c-0.023,3.748 -0.939,7.415 -2.716,10.725c-0.632,1.178 -1.496,2.216 -2.245,3.325l-58.039,-72.55l53.937,-40.453Z" style="fill:#75b7fd;fill-rule:nonzero;"/>
<path d="M32.063,12l61.874,0c7.767,0 14.063,6.296 14.063,14.063l0,61.874c0,7.767 -6.296,14.063 -14.063,14.063l-61.875,0c-7.766,0 -14.062,-6.296 -14.062,-14.063l0,-61.875c0,-7.766 6.296,-14.062 14.062,-14.062Z" style="fill:#fff;fill-rule:nonzero;"/>
<g>
<g>
<rect x="49.927" y="23.501" width="8.137" height="58.668" style="fill:#737373;"/>
<rect x="67.937" y="23.501" width="8.137" height="58.668" style="fill:#737373;"/>
</g>
<g>
<path d="M84.047,46.623l0,-10.239l-42.094,0l0,10.239l42.094,0Z" style="fill:#737373;"/>
<path d="M84.047,69.287l0,-10.24l-42.094,0l0,10.24l42.094,0Z" style="fill:#737373;"/>
</g>
</g>
</svg>
</template>

View File

@ -20,7 +20,7 @@ import FileMultiple from './FileMultiple';
import FolderPlus from './FolderPlus'; import FolderPlus from './FolderPlus';
import Delete from './Delete'; import Delete from './Delete';
import Close from './Close'; import Close from './Close';
import FolderMultiple from './FolderMultiple'; import FolderOpen from './FolderOpen';
import Pen from './Pen'; import Pen from './Pen';
import Target from './Target'; import Target from './Target';
import ArrowLeft from './ArrowLeft'; import ArrowLeft from './ArrowLeft';
@ -36,10 +36,11 @@ import HardDisk from './HardDisk';
import Download from './Download'; import Download from './Download';
import CodeTags from './CodeTags'; import CodeTags from './CodeTags';
import CodeBraces from './CodeBraces'; import CodeBraces from './CodeBraces';
import OpenInNew from './OpenInNew';
import Information from './Information';
import Alert from './Alert';
// Providers // Providers
import Stackedit from './Stackedit'; import Provider from './Provider';
import GoogleDrive from './GoogleDrive';
import GooglePhotos from './GooglePhotos';
Vue.component('iconFormatBold', FormatBold); Vue.component('iconFormatBold', FormatBold);
Vue.component('iconFormatItalic', FormatItalic); Vue.component('iconFormatItalic', FormatItalic);
@ -62,7 +63,7 @@ Vue.component('iconFileMultiple', FileMultiple);
Vue.component('iconFolderPlus', FolderPlus); Vue.component('iconFolderPlus', FolderPlus);
Vue.component('iconDelete', Delete); Vue.component('iconDelete', Delete);
Vue.component('iconClose', Close); Vue.component('iconClose', Close);
Vue.component('iconFolderMultiple', FolderMultiple); Vue.component('iconFolderOpen', FolderOpen);
Vue.component('iconPen', Pen); Vue.component('iconPen', Pen);
Vue.component('iconTarget', Target); Vue.component('iconTarget', Target);
Vue.component('iconArrowLeft', ArrowLeft); Vue.component('iconArrowLeft', ArrowLeft);
@ -78,7 +79,8 @@ Vue.component('iconHardDisk', HardDisk);
Vue.component('iconDownload', Download); Vue.component('iconDownload', Download);
Vue.component('iconCodeTags', CodeTags); Vue.component('iconCodeTags', CodeTags);
Vue.component('iconCodeBraces', CodeBraces); Vue.component('iconCodeBraces', CodeBraces);
Vue.component('iconOpenInNew', OpenInNew);
Vue.component('iconInformation', Information);
Vue.component('iconAlert', Alert);
// Providers // Providers
Vue.component('iconStackedit', Stackedit); Vue.component('iconProvider', Provider);
Vue.component('iconGoogleDrive', GoogleDrive);
Vue.component('iconGooglePhotos', GooglePhotos);

View File

@ -356,7 +356,7 @@ const editorSvc = Object.assign(new Vue(), { // Use a vue instance as an event b
if (sectionDesc) { if (sectionDesc) {
const scrollTop = sectionDesc[objectToScroll.dimensionKey].startOffset + const scrollTop = sectionDesc[objectToScroll.dimensionKey].startOffset +
(sectionDesc[objectToScroll.dimensionKey].height * scrollPosition.posInSection); (sectionDesc[objectToScroll.dimensionKey].height * scrollPosition.posInSection);
objectToScroll.elt.scrollTop = scrollTop; objectToScroll.elt.scrollTop = Math.floor(scrollTop);
} }
} }
}, },
@ -468,6 +468,7 @@ const editorSvc = Object.assign(new Vue(), { // Use a vue instance as an event b
}); });
this.editorElt.parentNode.addEventListener('scroll', () => this.saveContentState(true)); this.editorElt.parentNode.addEventListener('scroll', () => this.saveContentState(true));
this.previewElt.parentNode.addEventListener('scroll', () => this.saveContentState(true));
const refreshPreview = () => { const refreshPreview = () => {
this.convert(); this.convert();
@ -594,12 +595,6 @@ const editorSvc = Object.assign(new Vue(), { // Use a vue instance as an event b
this.$emit('inited'); this.$emit('inited');
// scope.$watch('editorLayoutSvc.currentControl', function (currentControl) {
// !currentControl && setTimeout(function () {
// !scope.isDialogOpen && clEditorSvc.cledit && clEditorSvc.cledit.focus()
// }, 1)
// })
// clEditorSvc.setPreviewElt(element[0].querySelector('.preview__inner-2')) // clEditorSvc.setPreviewElt(element[0].querySelector('.preview__inner-2'))
// var previewElt = element[0].querySelector('.preview') // var previewElt = element[0].querySelector('.preview')
// clEditorSvc.isPreviewTop = previewElt.scrollTop < 10 // clEditorSvc.isPreviewTop = previewElt.scrollTop < 10

View File

@ -4,8 +4,8 @@ import utils from './utils';
import store from '../store'; import store from '../store';
const indexedDB = window.indexedDB; const indexedDB = window.indexedDB;
const localStorage = window.localStorage;
const dbVersion = 1; const dbVersion = 1;
const dbVersionKey = `${utils.workspaceId}/localDbVersion`;
const dbStoreName = 'objects'; const dbStoreName = 'objects';
if (!indexedDB) { if (!indexedDB) {
@ -27,7 +27,7 @@ class Connection {
request.onsuccess = (event) => { request.onsuccess = (event) => {
this.db = event.target.result; this.db = event.target.result;
localStorage.localDbVersion = this.db.version; // Safari does not support onversionchange localStorage[dbVersionKey] = this.db.version; // Safari does not support onversionchange
this.db.onversionchange = () => window.location.reload(); this.db.onversionchange = () => window.location.reload();
this.getTxCbs.forEach(({ onTx, onError }) => this.createTx(onTx, onError)); this.getTxCbs.forEach(({ onTx, onError }) => this.createTx(onTx, onError));
@ -68,7 +68,7 @@ class Connection {
} }
// If DB version has changed (Safari support) // If DB version has changed (Safari support)
if (parseInt(localStorage.localDbVersion, 10) !== this.db.version) { if (parseInt(localStorage[dbVersionKey], 10) !== this.db.version) {
return window.location.reload(); return window.location.reload();
} }
@ -292,8 +292,8 @@ export default {
request.onsuccess = resolve; request.onsuccess = resolve;
}) })
.then(() => { .then(() => {
localStorage.removeItem('localDbVersion'); localStorage.removeItem(dbVersionKey);
window.location.reload(); window.location.reload();
}, () => console.error('Could not delete local database.')); }, () => store.dispatch('notification/error', 'Could not delete local database.'));
}, },
}; };

View File

@ -167,9 +167,9 @@ editorSvc.$on('previewText', () => {
store.watch( store.watch(
() => store.getters['layout/styles'], () => store.getters['layout/styles'],
() => { (styles) => {
isScrollEditor = true; isScrollEditor = styles.showEditor;
isScrollPreview = false; isScrollPreview = !styles.showEditor;
skipAnimation = true; skipAnimation = true;
}); });

View File

@ -4,7 +4,7 @@ import editorSvc from '../../services/editorSvc';
import syncSvc from '../../services/syncSvc'; import syncSvc from '../../services/syncSvc';
// Skip shortcuts if modal is open or editor is hidden // Skip shortcuts if modal is open or editor is hidden
Mousetrap.prototype.stopCallback = () => store.state.modal.config || !store.getters['layout/styles'].showEditor; Mousetrap.prototype.stopCallback = () => store.getters['modal/config'] || !store.getters['layout/styles'].showEditor;
const pagedownHandler = name => () => editorSvc.pagedownEditor.uiManager.doClick(name); const pagedownHandler = name => () => editorSvc.pagedownEditor.uiManager.doClick(name);

View File

@ -0,0 +1,48 @@
import store from '../../store';
import googleHelper from './helpers/googleHelper';
import providerRegistry from './providerRegistry';
export default providerRegistry.register({
id: 'bloggerPage',
getToken(location) {
const token = store.getters['data/googleTokens'][location.sub];
return token && token.isBlogger ? token : null;
},
getUrl(location) {
return `https://www.blogger.com/blogger.g?blogID=${location.blogId}#editor/target=page;pageID=${location.pageId}`;
},
getDescription(location) {
const token = this.getToken(location);
return `${location.pageId}${location.blogUrl}${token.name}`;
},
publish(token, html, metadata, publishLocation) {
return googleHelper.uploadBlogger(
token,
publishLocation.blogUrl,
publishLocation.blogId,
publishLocation.pageId,
metadata.title,
html,
null,
null,
null,
true,
)
.then(page => ({
...publishLocation,
blogId: page.blog.id,
pageId: page.id,
}));
},
makeLocation(token, blogUrl, pageId) {
const location = {
providerId: this.id,
sub: token.sub,
blogUrl,
};
if (pageId) {
location.pageId = pageId;
}
return location;
},
});

View File

@ -0,0 +1,47 @@
import store from '../../store';
import googleHelper from './helpers/googleHelper';
import providerRegistry from './providerRegistry';
export default providerRegistry.register({
id: 'blogger',
getToken(location) {
const token = store.getters['data/googleTokens'][location.sub];
return token && token.isBlogger ? token : null;
},
getUrl(location) {
return `https://www.blogger.com/blogger.g?blogID=${location.blogId}#editor/target=post;postID=${location.postId}`;
},
getDescription(location) {
const token = this.getToken(location);
return `${location.postId}${location.blogUrl}${token.name}`;
},
publish(token, html, metadata, publishLocation) {
return googleHelper.uploadBlogger(
token,
publishLocation.blogUrl,
publishLocation.blogId,
publishLocation.postId,
metadata.title,
html,
metadata.tags,
metadata.status === 'draft',
metadata.date,
)
.then(post => ({
...publishLocation,
blogId: post.blog.id,
postId: post.id,
}));
},
makeLocation(token, blogUrl, postId) {
const location = {
providerId: this.id,
sub: token.sub,
blogUrl,
};
if (postId) {
location.postId = postId;
}
return location;
},
});

View File

@ -0,0 +1,143 @@
import store from '../../store';
import dropboxHelper from './helpers/dropboxHelper';
import providerUtils from './providerUtils';
import providerRegistry from './providerRegistry';
import utils from '../utils';
const restrictedFolder = '/Applications/StackEdit (restricted)';
const restrictedFolderRegexp = /^\/Applications\/StackEdit \(restricted\)/;
export default providerRegistry.register({
id: 'dropbox',
fullAccess: true,
getToken(location) {
const token = store.getters['data/dropboxTokens'][location.sub];
if (token && !!token.fullAccess === this.fullAccess) {
return token;
}
return null;
},
getUrl(location) {
const pathComponents = location.path.split('/').map(encodeURIComponent);
const filename = pathComponents.pop();
let baseUrl = 'https://www.dropbox.com/home';
if (!this.fullAccess) {
baseUrl += encodeURIComponent(restrictedFolder);
}
return `${baseUrl}${pathComponents.join('/')}?preview=${filename}`;
},
getDescription(location) {
const token = this.getToken(location);
if (this.fullAccess) {
return `${location.path}${token.name}`;
}
return `${location.path}${token.name} (restricted)`;
},
checkPath(path) {
return path && path.match(/^\/[^\\<>:"|?*]+$/);
},
downloadContent(token, location) {
return dropboxHelper.downloadFile(token, location.path, location.dropboxFileId)
.then(({ content }) => providerUtils.parseContent(content));
},
uploadContent(token, content, location) {
return dropboxHelper.uploadFile(
token,
location.path,
providerUtils.serializeContent(content),
location.dropboxFileId,
)
.then(dropboxFile => ({
...location,
path: dropboxFile.path_display,
dropboxFileId: dropboxFile.id,
}));
},
publish(token, html, metadata, location) {
return dropboxHelper.uploadFile(
token,
location.path,
html,
location.dropboxFileId,
)
.then(dropboxFile => ({
...location,
path: dropboxFile.path_display,
dropboxFileId: dropboxFile.id,
}));
},
openFiles(token, paths) {
const openOneFile = () => {
let path = paths.pop();
if (!path) {
return null;
}
if (!token.fullAccess) {
path = path.replace(restrictedFolderRegexp, '');
}
let syncLocation;
// Try to find an existing sync location
store.getters['syncLocation/items'].some((existingSyncLocation) => {
if (existingSyncLocation.providerId === this.id &&
existingSyncLocation.path === path
) {
syncLocation = existingSyncLocation;
}
return syncLocation;
});
if (syncLocation) {
// Sync location already exists, just open the file
store.commit('file/setCurrentId', syncLocation.fileId);
return openOneFile();
}
// Sync location does not exist, download content from Dropbox and create the file
syncLocation = {
path,
providerId: this.id,
sub: token.sub,
};
return this.downloadContent(token, syncLocation)
.then((content) => {
const id = utils.uid();
delete content.history;
store.commit('content/setItem', {
...content,
id: `${id}/content`,
});
let name = path;
const slashPos = name.lastIndexOf('/');
if (slashPos > -1 && slashPos < name.length - 1) {
name = name.slice(slashPos + 1);
}
const dotPos = name.lastIndexOf('.');
if (dotPos > 0 && slashPos < name.length) {
name = name.slice(0, dotPos);
}
store.commit('file/setItem', {
id,
name: name.slice(0, 250),
parentId: store.getters['file/current'].parentId,
});
store.commit('syncLocation/setItem', {
...syncLocation,
id: utils.uid(),
fileId: id,
});
store.commit('file/setCurrentId', id);
store.dispatch('notification/info', `${store.getters['file/current'].name} was imported from Dropbox.`);
}, () => {
store.dispatch('notification/error', `Could not open file ${path}.`);
})
.then(() => openOneFile());
};
return Promise.resolve()
.then(() => openOneFile());
},
makeLocation(token, path) {
return {
providerId: this.id,
sub: token.sub,
path,
};
},
});

View File

@ -0,0 +1,8 @@
import dropboxProvider from './dropboxProvider';
import providerRegistry from './providerRegistry';
export default providerRegistry.register({
...dropboxProvider,
id: 'dropboxRestricted',
fullAccess: false,
});

View File

@ -0,0 +1,63 @@
import store from '../../store';
import githubHelper from './helpers/githubHelper';
import providerUtils from './providerUtils';
import providerRegistry from './providerRegistry';
const defaultDescription = 'Untitled';
export default providerRegistry.register({
id: 'gist',
getToken(location) {
return store.getters['data/githubTokens'][location.sub];
},
getUrl(location) {
return `https://gist.github.com/${location.gistId}`;
},
getDescription(location) {
const token = this.getToken(location);
return `${location.filename}${location.gistId}${token.name}`;
},
downloadContent(token, location) {
return githubHelper.downloadGist(token, location.gistId, location.filename)
.then(content => providerUtils.parseContent(content));
},
uploadContent(token, content, location) {
const file = store.state.file.itemMap[location.fileId];
const description = (file && file.name) || defaultDescription;
return githubHelper.uploadGist(
token,
description,
location.filename,
providerUtils.serializeContent(content),
location.isPublic,
location.gistId,
)
.then(gist => ({
...location,
gistId: gist.id,
}));
},
publish(token, html, metadata, location) {
return githubHelper.uploadGist(
token,
metadata.title,
location.filename,
html,
location.isPublic,
location.gistId,
)
.then(gist => ({
...location,
gistId: gist.id,
}));
},
makeLocation(token, filename, isPublic, gistId) {
return {
providerId: this.id,
sub: token.sub,
filename,
isPublic,
gistId,
};
},
});

View File

@ -0,0 +1,71 @@
import store from '../../store';
import githubHelper from './helpers/githubHelper';
import providerUtils from './providerUtils';
import providerRegistry from './providerRegistry';
const savedSha = {};
export default providerRegistry.register({
id: 'github',
getToken(location) {
return store.getters['data/githubTokens'][location.sub];
},
getUrl(location) {
return `https://github.com/${encodeURIComponent(location.owner)}/${encodeURIComponent(location.repo)}/blob/${encodeURIComponent(location.branch)}/${encodeURIComponent(location.path)}`;
},
getDescription(location) {
const token = this.getToken(location);
return `${location.path}${location.owner}/${location.repo}${token.name}`;
},
downloadContent(token, location) {
return githubHelper.downloadFile(
token, location.owner, location.repo, location.branch, location.path,
)
.then(({ sha, content }) => {
savedSha[location.id] = sha;
return providerUtils.parseContent(content);
})
.catch(() => null); // Ignore error, without the sha upload is going to fail anyway
},
uploadContent(token, content, location) {
const sha = savedSha[location.id];
delete savedSha[location.id];
return githubHelper.uploadFile(
token,
location.owner,
location.repo,
location.branch,
location.path,
providerUtils.serializeContent(content),
sha,
)
.then(() => location);
},
publish(token, html, metadata, location) {
return this.downloadContent(token, location) // Get the last sha
.then(() => {
const sha = savedSha[location.id];
delete savedSha[location.id];
return githubHelper.uploadFile(
token,
location.owner,
location.repo,
location.branch,
location.path,
html,
sha,
);
})
.then(() => location);
},
makeLocation(token, owner, repo, branch, path) {
return {
providerId: this.id,
sub: token.sub,
owner,
repo,
branch,
path,
};
},
});

View File

@ -1,7 +1,12 @@
import store from '../../store'; import store from '../../store';
import googleHelper from './helpers/googleHelper'; import googleHelper from './helpers/googleHelper';
import providerRegistry from './providerRegistry';
export default { export default providerRegistry.register({
id: 'googleDriveAppData',
getToken() {
return store.getters['data/loginToken'];
},
getChanges(token) { getChanges(token) {
return googleHelper.getChanges(token) return googleHelper.getChanges(token)
.then((result) => { .then((result) => {
@ -37,7 +42,7 @@ export default {
} }
}, },
saveItem(token, item, syncData, ifNotTooLate) { saveItem(token, item, syncData, ifNotTooLate) {
return googleHelper.saveAppDataFile( return googleHelper.uploadAppDataFile(
token, token,
JSON.stringify(item), ['appDataFolder'], JSON.stringify(item), ['appDataFolder'],
null, null,
@ -76,19 +81,19 @@ export default {
return item; return item;
}); });
}, },
uploadContent(token, item, syncLocation, ifNotTooLate) { uploadContent(token, content, syncLocation, ifNotTooLate) {
const syncData = store.getters['data/syncDataByItemId'][`${syncLocation.fileId}/content`]; const syncData = store.getters['data/syncDataByItemId'][`${syncLocation.fileId}/content`];
if (syncData && syncData.hash === item.hash) { if (syncData && syncData.hash === content.hash) {
return Promise.resolve(); return Promise.resolve();
} }
return googleHelper.saveAppDataFile( return googleHelper.uploadAppDataFile(
token, token,
JSON.stringify({ JSON.stringify({
id: item.id, id: content.id,
type: item.type, type: content.type,
hash: item.hash, hash: content.hash,
}), ['appDataFolder'], }), ['appDataFolder'],
JSON.stringify(item), JSON.stringify(content),
syncData && syncData.id, syncData && syncData.id,
ifNotTooLate, ifNotTooLate,
) )
@ -97,10 +102,10 @@ export default {
[file.id]: { [file.id]: {
// Build sync data // Build sync data
id: file.id, id: file.id,
itemId: item.id, itemId: content.id,
type: item.type, type: content.type,
hash: item.hash, hash: content.hash,
}, },
})); }));
}, },
}; });

View File

@ -1,28 +1,42 @@
import store from '../../store'; import store from '../../store';
import googleHelper from './helpers/googleHelper'; import googleHelper from './helpers/googleHelper';
import providerUtils from './providerUtils'; import providerUtils from './providerUtils';
import providerRegistry from './providerRegistry';
import utils from '../utils'; import utils from '../utils';
const defaultFilename = 'Untitled'; const defaultFilename = 'Untitled';
export default { export default providerRegistry.register({
id: 'googleDrive',
getToken(location) {
const token = store.getters['data/googleTokens'][location.sub];
return token && token.isDrive ? token : null;
},
getUrl(location) {
return `https://docs.google.com/file/d/${location.driveFileId}/edit`;
},
getDescription(location) {
const token = this.getToken(location);
return `${location.driveFileId}${token.name}`;
},
downloadContent(token, syncLocation) { downloadContent(token, syncLocation) {
return googleHelper.downloadFile(token, syncLocation.driveFileId) return googleHelper.downloadFile(token, syncLocation.driveFileId)
.then(content => providerUtils.parseContent(content)); .then(content => providerUtils.parseContent(content));
}, },
uploadContent(token, item, syncLocation, ifNotTooLate) { uploadContent(token, content, syncLocation, ifNotTooLate) {
const file = store.state.file.itemMap[syncLocation.fileId]; const file = store.state.file.itemMap[syncLocation.fileId];
const name = (file && file.name) || defaultFilename; const name = (file && file.name) || defaultFilename;
const parents = []; const parents = [];
if (syncLocation.driveParentId) { if (syncLocation.driveParentId) {
parents.push(syncLocation.driveParentId); parents.push(syncLocation.driveParentId);
} }
return googleHelper.saveFile( return googleHelper.uploadFile(
token, token,
name, name,
parents, parents,
providerUtils.serializeContent(item), providerUtils.serializeContent(content),
syncLocation && syncLocation.driveFileId, undefined,
syncLocation.driveFileId,
ifNotTooLate, ifNotTooLate,
) )
.then(driveFile => ({ .then(driveFile => ({
@ -30,6 +44,20 @@ export default {
driveFileId: driveFile.id, driveFileId: driveFile.id,
})); }));
}, },
publish(token, html, metadata, publishLocation) {
return googleHelper.uploadFile(
token,
metadata.title,
[],
html,
publishLocation.templateId ? 'text/html' : undefined,
publishLocation.driveFileId,
)
.then(driveFile => ({
...publishLocation,
driveFileId: driveFile.id,
}));
},
openFiles(token, files) { openFiles(token, files) {
const openOneFile = () => { const openOneFile = () => {
const file = files.pop(); const file = files.pop();
@ -39,20 +67,22 @@ export default {
let syncLocation; let syncLocation;
// Try to find an existing sync location // Try to find an existing sync location
store.getters['syncLocation/items'].some((existingSyncLocation) => { store.getters['syncLocation/items'].some((existingSyncLocation) => {
if (existingSyncLocation.driveFileId === file.id) { if (existingSyncLocation.providerId === this.id &&
existingSyncLocation.driveFileId === file.id
) {
syncLocation = existingSyncLocation; syncLocation = existingSyncLocation;
} }
return syncLocation; return syncLocation;
}); });
if (syncLocation) { if (syncLocation) {
// Sync location already exists, just open the file // Sync location already exists, just open the file
this.$store.commit('file/setCurrentId', syncLocation.fileId); store.commit('file/setCurrentId', syncLocation.fileId);
return openOneFile(); return openOneFile();
} }
// Sync location does not exist, download content from Google Drive and create the file // Sync location does not exist, download content from Google Drive and create the file
syncLocation = { syncLocation = {
driveFileId: file.id, driveFileId: file.id,
provider: 'googleDrive', providerId: this.id,
sub: token.sub, sub: token.sub,
}; };
return this.downloadContent(token, syncLocation) return this.downloadContent(token, syncLocation)
@ -74,12 +104,26 @@ export default {
fileId: id, fileId: id,
}); });
store.commit('file/setCurrentId', id); store.commit('file/setCurrentId', id);
store.dispatch('notification/info', `${store.getters['file/current'].name} was imported from Google Drive.`);
}, () => { }, () => {
console.error(`Could not open file ${file.id}.`); store.dispatch('notification/error', `Could not open file ${file.id}.`);
}) })
.then(() => openOneFile()); .then(() => openOneFile());
}; };
return Promise.resolve() return Promise.resolve()
.then(() => openOneFile()); .then(() => openOneFile());
}, },
}; makeLocation(token, fileId, folderId) {
const location = {
providerId: this.id,
sub: token.sub,
};
if (fileId) {
location.driveFileId = fileId;
}
if (folderId) {
location.driveParentId = folderId;
}
return location;
},
});

View File

@ -0,0 +1,106 @@
import utils from '../../utils';
import store from '../../../store';
let Dropbox;
const getAppKey = (fullAccess) => {
if (fullAccess) {
return 'lq6mwopab8wskas';
}
return 'sw0hlixhr8q1xk0';
};
const request = (token, options, args) => utils.request({
...options,
headers: {
...options.headers,
'Content-Type': 'application/octet-stream',
'Dropbox-API-Arg': args && JSON.stringify(args),
Authorization: `Bearer ${token.accessToken}`,
},
});
export default {
startOauth2(fullAccess, sub = null, silent = false) {
return utils.startOauth2(
'https://www.dropbox.com/oauth2/authorize', {
client_id: getAppKey(fullAccess),
response_type: 'token',
}, silent)
// Call the user info endpoint
.then(({ accessToken }) => request({ accessToken }, {
method: 'POST',
url: 'https://api.dropboxapi.com/2/users/get_current_account',
})
.then((res) => {
// Check the returned sub consistency
if (sub && res.body.account_id !== sub) {
throw new Error('Dropbox account ID not expected.');
}
// Build token object including scopes and sub
const token = {
accessToken,
name: res.body.name.display_name,
sub: res.body.account_id,
fullAccess,
};
// Add token to githubTokens
store.dispatch('data/setDropboxToken', token);
return token;
}));
},
loadClientScript() {
if (Dropbox) {
return Promise.resolve();
}
return utils.loadScript('https://www.dropbox.com/static/api/2/dropins.js')
.then(() => {
Dropbox = window.Dropbox;
});
},
addAccount(fullAccess = false) {
return this.startOauth2(fullAccess);
},
uploadFile(token, path, content, fileId) {
return request(token, {
method: 'POST',
url: 'https://content.dropboxapi.com/2/files/upload',
body: content,
}, {
path: fileId || path,
mode: 'overwrite',
})
.then(res => res.body);
},
downloadFile(token, path, fileId) {
return request(token, {
method: 'POST',
url: 'https://content.dropboxapi.com/2/files/download',
raw: true,
}, {
path: fileId || path,
})
.then(res => ({
id: JSON.parse(res.headers['dropbox-api-result']).id,
content: res.body,
}));
},
openChooser(token) {
return this.loadClientScript()
.then(() => new Promise((resolve) => {
Dropbox.appKey = getAppKey(token.fullAccess);
Dropbox.choose({
multiselect: true,
linkType: 'direct',
success: (files) => {
const paths = files.map((file) => {
const path = file.link.replace(/.*\/view\/[^/]*/, '');
return decodeURI(path);
});
resolve(paths);
},
cancel: () => resolve([]),
});
}));
},
};

View File

@ -0,0 +1,132 @@
import utils from '../../utils';
import store from '../../../store';
let clientId = 'cbf0cf25cfd026be23e1';
if (utils.origin === 'https://stackedit.io') {
clientId = '30c1491057c9ad4dbd56';
}
const getScopes = token => [token.repoFullAccess ? 'repo' : 'public_repo', 'gist'];
const request = (token, options) => utils.request({
...options,
headers: {
...options.headers,
Authorization: `token ${token.accessToken}`,
},
});
const base64Encode = str => btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g,
(match, p1) => String.fromCharCode(`0x${p1}`),
));
const base64Decode = str => decodeURIComponent(atob(str).split('').map(
c => `%${`00${c.charCodeAt(0).toString(16)}`.slice(-2)}`,
).join(''));
export default {
startOauth2(scopes, sub = null, silent = false) {
return utils.startOauth2(
'https://github.com/login/oauth/authorize', {
client_id: clientId,
scope: scopes.join(' '),
}, silent)
// Exchange code with token
.then(data => utils.request({
method: 'GET',
url: 'oauth2/githubToken',
params: {
clientId,
code: data.code,
},
})
.then(res => res.body))
// Call the user info endpoint
.then(accessToken => utils.request({
method: 'GET',
url: 'https://api.github.com/user',
params: {
access_token: accessToken,
},
})
.then((res) => {
// Check the returned sub consistency
if (sub && res.body.id !== sub) {
throw new Error('GitHub account ID not expected.');
}
// Build token object including scopes and sub
const token = {
scopes,
accessToken,
name: res.body.name,
sub: res.body.id,
repoFullAccess: scopes.indexOf('repo') !== -1,
};
// Add token to githubTokens
store.dispatch('data/setGithubToken', token);
return token;
}));
},
addAccount(repoFullAccess = false) {
return this.startOauth2(getScopes({ repoFullAccess }));
},
uploadFile(token, owner, repo, branch, path, content, sha) {
return request(token, {
method: 'PUT',
url: `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${encodeURIComponent(path)}`,
body: {
message: 'Uploaded by https://stackedit.io/',
content: base64Encode(content),
sha,
branch,
},
});
},
downloadFile(token, owner, repo, branch, path) {
return request(token, {
url: `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${encodeURIComponent(path)}`,
params: { ref: branch },
})
.then(res => ({
sha: res.body.sha,
content: base64Decode(res.body.content),
}));
},
uploadGist(token, description, filename, content, isPublic, gistId) {
return request(token, gistId ? {
method: 'PATCH',
url: `https://api.github.com/gists/${gistId}`,
body: {
description,
files: {
[filename]: {
content,
},
},
},
} : {
method: 'POST',
url: 'https://api.github.com/gists',
body: {
description,
files: {
[filename]: {
content,
},
},
public: isPublic,
},
})
.then(res => res.body);
},
downloadGist(token, gistId, filename) {
return request(token, {
url: `https://api.github.com/gists/${gistId}`,
})
.then((res) => {
const result = res.body.files[filename];
if (!result) {
throw new Error('Gist file not found.');
}
return result.content;
});
},
};

View File

@ -12,7 +12,7 @@ const getDriveScopes = token => [token.driveFullAccess
? 'https://www.googleapis.com/auth/drive' ? 'https://www.googleapis.com/auth/drive'
: 'https://www.googleapis.com/auth/drive.file', : 'https://www.googleapis.com/auth/drive.file',
'https://www.googleapis.com/auth/drive.install']; 'https://www.googleapis.com/auth/drive.install'];
// const bloggerScopes = ['https://www.googleapis.com/auth/blogger']; const bloggerScopes = ['https://www.googleapis.com/auth/blogger'];
const photosScopes = ['https://www.googleapis.com/auth/photos']; const photosScopes = ['https://www.googleapis.com/auth/photos'];
const libraries = ['picker']; const libraries = ['picker'];
@ -25,9 +25,7 @@ const request = (token, options) => utils.request({
}, },
}); });
function saveFile(refreshedToken, name, parents, media = null, fileId = null, function uploadFile(refreshedToken, name, parents, media = null, mediaType = 'text/plain', fileId = null, ifNotTooLate = cb => res => cb(res)) {
ifNotTooLate = cb => res => cb(res),
) {
return Promise.resolve() return Promise.resolve()
// Refreshing a token can take a while if an oauth window pops up, so check if it's too late // Refreshing a token can take a while if an oauth window pops up, so check if it's too late
.then(ifNotTooLate(() => { .then(ifNotTooLate(() => {
@ -52,7 +50,7 @@ function saveFile(refreshedToken, name, parents, media = null, fileId = null,
multipartRequestBody += 'Content-Type: application/json; charset=UTF-8\r\n\r\n'; multipartRequestBody += 'Content-Type: application/json; charset=UTF-8\r\n\r\n';
multipartRequestBody += JSON.stringify(metadata); multipartRequestBody += JSON.stringify(metadata);
multipartRequestBody += delimiter; multipartRequestBody += delimiter;
multipartRequestBody += 'Content-Type: text/plain; charset=UTF-8\r\n\r\n'; multipartRequestBody += `Content-Type: ${mediaType}; charset=UTF-8\r\n\r\n`;
multipartRequestBody += media; multipartRequestBody += media;
multipartRequestBody += closeDelimiter; multipartRequestBody += closeDelimiter;
options.url = options.url.replace( options.url = options.url.replace(
@ -95,7 +93,7 @@ export default {
login_hint: sub, login_hint: sub,
prompt: silent ? 'none' : null, prompt: silent ? 'none' : null,
}, silent) }, silent)
// Call the tokeninfo endpoint // Call the token info endpoint
.then(data => utils.request({ .then(data => utils.request({
method: 'POST', method: 'POST',
url: 'https://www.googleapis.com/oauth2/v3/tokeninfo', url: 'https://www.googleapis.com/oauth2/v3/tokeninfo',
@ -121,11 +119,12 @@ export default {
scopes.indexOf('https://www.googleapis.com/auth/drive.appdata') !== -1, scopes.indexOf('https://www.googleapis.com/auth/drive.appdata') !== -1,
isDrive: scopes.indexOf('https://www.googleapis.com/auth/drive') !== -1 || isDrive: scopes.indexOf('https://www.googleapis.com/auth/drive') !== -1 ||
scopes.indexOf('https://www.googleapis.com/auth/drive.file') !== -1, scopes.indexOf('https://www.googleapis.com/auth/drive.file') !== -1,
isBlogger: scopes.indexOf('https://www.googleapis.com/auth/blogger') !== -1,
isPhotos: scopes.indexOf('https://www.googleapis.com/auth/photos') !== -1, isPhotos: scopes.indexOf('https://www.googleapis.com/auth/photos') !== -1,
driveFullAccess: scopes.indexOf('https://www.googleapis.com/auth/drive') !== -1, driveFullAccess: scopes.indexOf('https://www.googleapis.com/auth/drive') !== -1,
}; };
})) }))
// Call the tokeninfo endpoint // Call the user info endpoint
.then(token => request(token, { .then(token => request(token, {
method: 'GET', method: 'GET',
url: 'https://www.googleapis.com/plus/v1/people/me', url: 'https://www.googleapis.com/plus/v1/people/me',
@ -139,6 +138,7 @@ export default {
// Save flags // Save flags
token.isLogin = existingToken.isLogin || token.isLogin; token.isLogin = existingToken.isLogin || token.isLogin;
token.isDrive = existingToken.isDrive || token.isDrive; token.isDrive = existingToken.isDrive || token.isDrive;
token.isBlogger = existingToken.isBlogger || token.isBlogger;
token.isPhotos = existingToken.isPhotos || token.isPhotos; token.isPhotos = existingToken.isPhotos || token.isPhotos;
token.driveFullAccess = existingToken.driveFullAccess || token.driveFullAccess; token.driveFullAccess = existingToken.driveFullAccess || token.driveFullAccess;
// Save nextPageToken // Save nextPageToken
@ -168,7 +168,8 @@ export default {
// Try to get a new token in background // Try to get a new token in background
return this.startOauth2(mergedScopes, sub, true) return this.startOauth2(mergedScopes, sub, true)
// If it fails try to popup a window // If it fails try to popup a window
.catch(() => this.startOauth2(mergedScopes, sub)); .catch(() => utils.checkOnline()
.then(() => this.startOauth2(mergedScopes, sub)));
}); });
}, },
loadClientScript() { loadClientScript() {
@ -176,17 +177,17 @@ export default {
return Promise.resolve(); return Promise.resolve();
} }
return utils.loadScript('https://apis.google.com/js/api.js') return utils.loadScript('https://apis.google.com/js/api.js')
.then(() => Promise.all(libraries.map( .then(() => Promise.all(libraries.map(
library => new Promise((resolve, reject) => window.gapi.load(library, { library => new Promise((resolve, reject) => window.gapi.load(library, {
callback: resolve, callback: resolve,
onerror: reject, onerror: reject,
timeout: 30000, timeout: 30000,
ontimeout: reject, ontimeout: reject,
}))))) })))))
.then(() => { .then(() => {
gapi = window.gapi; gapi = window.gapi;
google = window.google; google = window.google;
}); });
}, },
signin() { signin() {
return this.startOauth2(driveAppDataScopes); return this.startOauth2(driveAppDataScopes);
@ -194,6 +195,9 @@ export default {
addDriveAccount(fullAccess = false) { addDriveAccount(fullAccess = false) {
return this.startOauth2(getDriveScopes({ driveFullAccess: fullAccess })); return this.startOauth2(getDriveScopes({ driveFullAccess: fullAccess }));
}, },
addBloggerAccount() {
return this.startOauth2(bloggerScopes);
},
addPhotosAccount() { addPhotosAccount() {
return this.startOauth2(photosScopes); return this.startOauth2(photosScopes);
}, },
@ -224,13 +228,15 @@ export default {
return getPage(refreshedToken.nextPageToken); return getPage(refreshedToken.nextPageToken);
}); });
}, },
saveFile(token, name, parents, media, fileId, ifNotTooLate) { uploadFile(token, name, parents, media, mediaType, fileId, ifNotTooLate) {
return this.refreshToken(getDriveScopes(token), token) return this.refreshToken(getDriveScopes(token), token)
.then(refreshedToken => saveFile(refreshedToken, name, parents, media, fileId, ifNotTooLate)); .then(refreshedToken => uploadFile(
refreshedToken, name, parents, media, mediaType, fileId, ifNotTooLate));
}, },
saveAppDataFile(token, name, parents, media, fileId, ifNotTooLate) { uploadAppDataFile(token, name, parents, media, fileId, ifNotTooLate) {
return this.refreshToken(driveAppDataScopes, token) return this.refreshToken(driveAppDataScopes, token)
.then(refreshedToken => saveFile(refreshedToken, name, parents, media, fileId, ifNotTooLate)); .then(refreshedToken => uploadFile(
refreshedToken, name, parents, media, undefined, fileId, ifNotTooLate));
}, },
downloadFile(token, id) { downloadFile(token, id) {
return this.refreshToken(getDriveScopes(token), token) return this.refreshToken(getDriveScopes(token), token)
@ -248,6 +254,72 @@ export default {
url: `https://www.googleapis.com/drive/v3/files/${id}`, url: `https://www.googleapis.com/drive/v3/files/${id}`,
}))); })));
}, },
uploadBlogger(
token, blogUrl, blogId, postId, title, content, labels, isDraft, published, isPage,
) {
return this.refreshToken(bloggerScopes, token)
.then(refreshedToken => Promise.resolve()
.then(() => {
if (blogId) {
return blogId;
}
return request(refreshedToken, {
url: 'https://www.googleapis.com/blogger/v3/blogs/byurl',
params: {
url: blogUrl,
},
}).then(res => res.body.id);
})
.then((resolvedBlogId) => {
const path = isPage ? 'pages' : 'posts';
const options = {
method: 'POST',
url: `https://www.googleapis.com/blogger/v3/blogs/${resolvedBlogId}/${path}/`,
body: {
kind: isPage ? 'blogger#page' : 'blogger#post',
blog: {
id: resolvedBlogId,
},
title,
content,
},
};
if (labels) {
options.body.labels = labels;
}
if (published) {
options.body.published = published.toISOString();
}
// If it's an update
if (postId) {
options.method = 'PUT';
options.url += postId;
options.body.id = postId;
}
return request(refreshedToken, options)
.then(res => res.body);
})
.then((post) => {
if (isPage) {
return post;
}
const options = {
method: 'POST',
url: `https://www.googleapis.com/blogger/v3/blogs/${post.blog.id}/posts/${post.id}/`,
params: {},
};
if (isDraft) {
options.url += 'revert';
} else {
options.url += 'publish';
if (published) {
options.params.publishDate = published.toISOString();
}
}
return request(refreshedToken, options)
.then(res => res.body);
}));
},
openPicker(token, type = 'doc') { openPicker(token, type = 'doc') {
const scopes = type === 'img' ? photosScopes : getDriveScopes(token); const scopes = type === 'img' ? photosScopes : getDriveScopes(token);
return this.loadClientScript() return this.loadClientScript()

View File

@ -0,0 +1,7 @@
export default {
providers: {},
register(provider) {
this.providers[provider.id] = provider;
return provider;
},
};

148
src/services/publishSvc.js Normal file
View File

@ -0,0 +1,148 @@
import localDbSvc from './localDbSvc';
import store from '../store';
import utils from './utils';
import exportSvc from './exportSvc';
import providerRegistry from './providers/providerRegistry';
const hasCurrentFilePublishLocations = () => !!store.getters['publishLocation/current'].length;
const loader = type => fileId => localDbSvc.loadItem(`${fileId}/${type}`)
// Item does not exist, create it
.catch(() => store.commit(`${type}/setItem`, {
id: `${fileId}/${type}`,
}));
const loadContent = loader('content');
const ensureArray = (value) => {
if (!value) {
return [];
}
if (!Array.isArray(value)) {
return value.toString().trim().split(/\s*,\s*/);
}
return value;
};
const ensureString = (value, defaultValue) => {
if (!value) {
return defaultValue;
}
return value.toString();
};
const ensureDate = (value, defaultValue) => {
if (!value) {
return defaultValue;
}
return new Date(value.toString());
};
function publish(publishLocation) {
const fileId = publishLocation.fileId;
const template = store.getters['data/allTemplates'][publishLocation.templateId];
return exportSvc.applyTemplate(fileId, template)
.then(html => localDbSvc.loadItem(`${fileId}/content`)
.then((content) => {
const file = store.state.file.itemMap[fileId];
const properties = utils.computeProperties(content.properties);
const provider = providerRegistry.providers[publishLocation.providerId];
const token = provider.getToken(publishLocation);
const metadata = {
title: ensureString(properties.title, file.name),
author: ensureString(properties.author),
tags: ensureArray(properties.tags),
categories: ensureArray(properties.categories),
excerpt: ensureString(properties.excerpt),
featuredImage: ensureString(properties.featuredImage),
status: ensureString(properties.status),
date: ensureDate(properties.date),
};
return provider.publish(token, html, metadata, publishLocation);
}));
}
function publishFile(fileId) {
let counter = 0;
return loadContent(fileId)
.then(() => {
const publishLocations = [
...store.getters['publishLocation/groupedByFileId'][fileId] || [],
];
const publishOneContentLocation = () => {
const publishLocation = publishLocations.shift();
if (!publishLocation) {
return null;
}
return store.dispatch('queue/doWithLocation', {
location: publishLocation,
promise: publish(publishLocation)
.then((publishLocationToStore) => {
// Replace publish location if modified
if (utils.serializeObject(publishLocation) !==
utils.serializeObject(publishLocationToStore)
) {
store.commit('publishLocation/patchItem', publishLocationToStore);
}
counter += 1;
return publishOneContentLocation();
}, (err) => {
if (store.state.offline) {
throw err;
}
console.error(err); // eslint-disable-line no-console
store.dispatch('notification/error', err);
return publishOneContentLocation();
}),
});
};
return publishOneContentLocation();
})
.then(() => {
const file = store.state.file.itemMap[fileId];
store.dispatch('notification/info', `"${file.name}" was published to ${counter} location(s).`);
})
.then(
() => localDbSvc.unloadContents(),
err => localDbSvc.unloadContents()
.then(() => {
throw err;
}));
}
function requestPublish() {
store.dispatch('queue/enqueuePublishRequest', () => new Promise((resolve, reject) => {
let intervalId;
const attempt = () => {
// Only start publishing when these conditions are met
if (utils.isUserActive()) {
clearInterval(intervalId);
if (!hasCurrentFilePublishLocations()) {
// Cancel sync
reject('Publish not possible.');
return;
}
publishFile(store.getters['file/current'].id)
.then(resolve, reject);
}
};
intervalId = utils.setInterval(() => attempt(), 1000);
attempt();
}));
}
function createPublishLocation(publishLocation) {
publishLocation.id = utils.uid();
const currentFile = store.getters['file/current'];
publishLocation.fileId = currentFile.id;
store.dispatch('queue/enqueue',
() => publish(publishLocation)
.then((publishLocationToStore) => {
store.commit('publishLocation/setItem', publishLocationToStore);
store.dispatch('notification/info', `A new publication location was added to "${currentFile.name}".`);
}));
}
export default {
requestPublish,
createPublishLocation,
};

View File

@ -3,11 +3,10 @@ import store from '../store';
import welcomeFile from '../data/welcomeFile.md'; import welcomeFile from '../data/welcomeFile.md';
import utils from './utils'; import utils from './utils';
import diffUtils from './diffUtils'; import diffUtils from './diffUtils';
import userActivitySvc from './userActivitySvc'; import providerRegistry from './providers/providerRegistry';
import googleDriveAppDataProvider from './providers/googleDriveAppDataProvider'; import mainProvider from './providers/googleDriveAppDataProvider';
import googleDriveProvider from './providers/googleDriveProvider';
const lastSyncActivityKey = 'lastSyncActivity'; const lastSyncActivityKey = `${utils.workspaceId}/lastSyncActivity`;
let lastSyncActivity; let lastSyncActivity;
const getStoredLastSyncActivity = () => parseInt(localStorage[lastSyncActivityKey], 10) || 0; const getStoredLastSyncActivity = () => parseInt(localStorage[lastSyncActivityKey], 10) || 0;
const inactivityThreshold = 3 * 1000; // 3 sec const inactivityThreshold = 3 * 1000; // 3 sec
@ -37,26 +36,6 @@ function setLastSyncActivity() {
localStorage[lastSyncActivityKey] = currentDate; localStorage[lastSyncActivityKey] = currentDate;
} }
function getSyncProvider(syncLocation) {
switch (syncLocation.provider) {
case 'googleDriveAppData':
default:
return googleDriveAppDataProvider;
case 'googleDrive':
return googleDriveProvider;
}
}
function getSyncToken(syncLocation) {
switch (syncLocation.provider) {
case 'googleDriveAppData':
default:
return store.getters['data/loginToken'];
case 'googleDrive':
return store.getters['data/googleTokens'][syncLocation.sub];
}
}
function cleanSyncedContent(syncedContent) { function cleanSyncedContent(syncedContent) {
// Clean syncHistory from removed syncLocations // Clean syncHistory from removed syncLocations
Object.keys(syncedContent.syncHistory).forEach((syncLocationId) => { Object.keys(syncedContent.syncHistory).forEach((syncLocationId) => {
@ -123,6 +102,37 @@ function applyChanges(changes) {
const LAST_SENT = 0; const LAST_SENT = 0;
const LAST_MERGED = 1; const LAST_MERGED = 1;
function createSyncLocation(syncLocation) {
syncLocation.id = utils.uid();
const currentFile = store.getters['file/current'];
const fileId = currentFile.id;
syncLocation.fileId = fileId;
// Use deepCopy to freeze item
const content = utils.deepCopy(store.getters['content/current']);
store.dispatch('queue/enqueue',
() => {
const provider = providerRegistry.providers[syncLocation.providerId];
const token = provider.getToken(syncLocation);
return provider.uploadContent(token, {
...content,
history: [content.hash],
}, syncLocation)
.then(syncLocationToStore => loadSyncedContent(fileId)
.then(() => {
const newSyncedContent = utils.deepCopy(
store.state.syncedContent.itemMap[`${fileId}/syncedContent`]);
const newSyncHistoryItem = [];
newSyncedContent.syncHistory[syncLocation.id] = newSyncHistoryItem;
newSyncHistoryItem[LAST_SENT] = content.hash;
newSyncedContent.historyData[content.hash] = content;
store.commit('syncedContent/patchItem', newSyncedContent);
store.commit('syncLocation/setItem', syncLocationToStore);
store.dispatch('notification/info', `A new synchronized location was added to "${currentFile.name}".`);
}));
});
}
function syncFile(fileId) { function syncFile(fileId) {
return loadSyncedContent(fileId) return loadSyncedContent(fileId)
.then(() => loadContent(fileId)) .then(() => loadContent(fileId))
@ -131,121 +141,145 @@ function syncFile(fileId) {
const getSyncedContent = () => store.state.syncedContent.itemMap[`${fileId}/syncedContent`]; const getSyncedContent = () => store.state.syncedContent.itemMap[`${fileId}/syncedContent`];
const getSyncHistoryItem = syncLocationId => getSyncedContent().syncHistory[syncLocationId]; const getSyncHistoryItem = syncLocationId => getSyncedContent().syncHistory[syncLocationId];
const downloadedLocations = {}; const downloadedLocations = {};
const errorLocations = {};
const isLocationSynced = syncLocation => const isLocationSynced = (syncLocation) => {
getSyncHistoryItem(syncLocation.id)[LAST_SENT] === getContent().hash; const syncHistoryItem = getSyncHistoryItem(syncLocation.id);
return syncHistoryItem && syncHistoryItem[LAST_SENT] === getContent().hash;
};
const syncOneContentLocation = () => { const syncOneContentLocation = () => {
const syncLocations = [ const syncLocations = [
...store.getters['syncLocation/groupedByFileId'][fileId] || [], ...store.getters['syncLocation/groupedByFileId'][fileId] || [],
]; ];
if (isDataSyncPossible()) { if (isDataSyncPossible()) {
syncLocations.unshift({ id: 'main', provider: 'googleDriveAppData', fileId }); syncLocations.unshift({ id: 'main', providerId: mainProvider.id, fileId });
} }
let result; let result;
syncLocations.some((syncLocation) => { syncLocations.some((syncLocation) => {
if (!downloadedLocations[syncLocation.id] || !isLocationSynced(syncLocation)) { if (!errorLocations[syncLocation.id] &&
const provider = getSyncProvider(syncLocation); (!downloadedLocations[syncLocation.id] || !isLocationSynced(syncLocation))
const token = getSyncToken(syncLocation); ) {
result = provider && token && provider.downloadContent(token, syncLocation) const provider = providerRegistry.providers[syncLocation.providerId];
.then((serverContent = null) => { const token = provider.getToken(syncLocation);
downloadedLocations[syncLocation.id] = true; result = provider && token && store.dispatch('queue/doWithLocation', {
location: syncLocation,
promise: provider.downloadContent(token, syncLocation)
.then((serverContent = null) => {
downloadedLocations[syncLocation.id] = true;
const syncedContent = getSyncedContent(); const syncedContent = getSyncedContent();
const syncHistoryItem = getSyncHistoryItem(syncLocation.id); const syncHistoryItem = getSyncHistoryItem(syncLocation.id);
let mergedContent = (() => { let mergedContent = (() => {
const clientContent = utils.deepCopy(getContent()); const clientContent = utils.deepCopy(getContent());
if (!serverContent) { if (!serverContent) {
// Sync location has not been created yet // Sync location has not been created yet
return clientContent; return clientContent;
} }
if (serverContent.hash === clientContent.hash) { if (serverContent.hash === clientContent.hash) {
// Server and client contents are synced // Server and client contents are synced
return clientContent; return clientContent;
} }
if (syncedContent.historyData[serverContent.hash]) { if (syncedContent.historyData[serverContent.hash]) {
// Server content has not changed or has already been merged // Server content has not changed or has already been merged
return clientContent; return clientContent;
} }
// Perform a merge with last merged content if any, or a simple fusion otherwise // Perform a merge with last merged content if any, or a simple fusion otherwise
let lastMergedContent; let lastMergedContent;
serverContent.history.some((hash) => { serverContent.history.some((hash) => {
lastMergedContent = syncedContent.historyData[hash]; lastMergedContent = syncedContent.historyData[hash];
return lastMergedContent; return lastMergedContent;
});
if (!lastMergedContent && syncHistoryItem) {
lastMergedContent = syncedContent.historyData[syncHistoryItem[LAST_MERGED]];
}
return diffUtils.mergeContent(serverContent, clientContent, lastMergedContent);
})();
// Update content in store
store.commit('content/patchItem', {
id: `${fileId}/content`,
...mergedContent,
}); });
if (!lastMergedContent && syncHistoryItem) {
lastMergedContent = syncedContent.historyData[syncHistoryItem[LAST_MERGED]]; // Retrieve content with new `hash` value and freeze it
mergedContent = utils.deepCopy(getContent());
// Make merged content history
const mergedContentHistory = serverContent ? serverContent.history.slice() : [];
let skipUpload = true;
if (mergedContentHistory[0] !== mergedContent.hash) {
// Put merged content hash at the beginning of history
mergedContentHistory.unshift(mergedContent.hash);
// Server content is either out of sync or its history is incomplete, do upload
skipUpload = false;
} }
return diffUtils.mergeContent(serverContent, clientContent, lastMergedContent); if (syncHistoryItem && syncHistoryItem[0] !== mergedContent.hash) {
})(); // Clean up by removing the hash we've previously added
const idx = mergedContentHistory.indexOf(syncHistoryItem[LAST_SENT]);
// Update content in store if (idx !== -1) {
store.commit('content/patchItem', { mergedContentHistory.splice(idx, 1);
id: `${fileId}/content`, }
...mergedContent,
});
// Retrieve content with new `hash` value and freeze it
mergedContent = utils.deepCopy(getContent());
// Make merged content history
const mergedContentHistory = serverContent ? serverContent.history.slice() : [];
let skipUpload = true;
if (mergedContentHistory[0] !== mergedContent.hash) {
// Put merged content hash at the beginning of history
mergedContentHistory.unshift(mergedContent.hash);
// Server content is either out of sync or its history is incomplete, do upload
skipUpload = false;
}
if (syncHistoryItem && syncHistoryItem[0] !== mergedContent.hash) {
// Clean up by removing the hash we've previously added
const idx = mergedContentHistory.indexOf(syncHistoryItem[LAST_SENT]);
if (idx !== -1) {
mergedContentHistory.splice(idx, 1);
} }
}
// Store last sent if it's in the server history, // Store last sent if it's in the server history,
// and merged content which will be sent if different // and merged content which will be sent if different
const newSyncedContent = utils.deepCopy(syncedContent); const newSyncedContent = utils.deepCopy(syncedContent);
const newSyncHistoryItem = newSyncedContent.syncHistory[syncLocation.id] || []; const newSyncHistoryItem = newSyncedContent.syncHistory[syncLocation.id] || [];
newSyncedContent.syncHistory[syncLocation.id] = newSyncHistoryItem; newSyncedContent.syncHistory[syncLocation.id] = newSyncHistoryItem;
if (serverContent && (serverContent.hash === newSyncHistoryItem[LAST_SENT] || if (serverContent && (serverContent.hash === newSyncHistoryItem[LAST_SENT] ||
serverContent.history.indexOf(newSyncHistoryItem[LAST_SENT]) !== -1) serverContent.history.indexOf(newSyncHistoryItem[LAST_SENT]) !== -1)
) { ) {
// The server has accepted the content we previously sent // The server has accepted the content we previously sent
newSyncHistoryItem[LAST_MERGED] = newSyncHistoryItem[LAST_SENT]; newSyncHistoryItem[LAST_MERGED] = newSyncHistoryItem[LAST_SENT];
}
newSyncHistoryItem[LAST_SENT] = mergedContent.hash;
newSyncedContent.historyData[mergedContent.hash] = mergedContent;
// Clean synced content from unused revisions
cleanSyncedContent(newSyncedContent);
// Store synced content
store.commit('syncedContent/patchItem', newSyncedContent);
if (skipUpload) {
// Server content and merged content are equal, skip content upload
return null;
}
// Prevent from sending new content too long after old content has been fetched
const syncStartTime = Date.now();
const ifNotTooLate = cb => (res) => {
// No time to refresh a token...
if (syncStartTime + 500 < Date.now()) {
throw new Error('TOO_LATE');
} }
return cb(res); newSyncHistoryItem[LAST_SENT] = mergedContent.hash;
}; newSyncedContent.historyData[mergedContent.hash] = mergedContent;
// Upload merged content // Clean synced content from unused revisions
return provider.uploadContent(token, { cleanSyncedContent(newSyncedContent);
...mergedContent, // Store synced content
history: mergedContentHistory, store.commit('syncedContent/patchItem', newSyncedContent);
}, syncLocation, ifNotTooLate);
}) if (skipUpload) {
.then(() => syncOneContentLocation()); // Server content and merged content are equal, skip content upload
return null;
}
// Prevent from sending new content too long after old content has been fetched
const syncStartTime = Date.now();
const ifNotTooLate = cb => (res) => {
// No time to refresh a token...
if (syncStartTime + 500 < Date.now()) {
throw new Error('TOO_LATE');
}
return cb(res);
};
// Upload merged content
return provider.uploadContent(token, {
...mergedContent,
history: mergedContentHistory,
}, syncLocation, ifNotTooLate)
.then((syncLocationToStore) => {
// Replace sync location if modified
if (utils.serializeObject(syncLocation) !==
utils.serializeObject(syncLocationToStore)
) {
store.commit('syncLocation/patchItem', syncLocationToStore);
}
});
})
.catch((err) => {
if (store.state.offline) {
throw err;
}
console.error(err); // eslint-disable-line no-console
store.dispatch('notification/error', err);
errorLocations[syncLocation.id] = true;
}),
})
.then(() => syncOneContentLocation());
} }
return result; return result;
}); });
@ -271,11 +305,11 @@ function syncFile(fileId) {
function sync() { function sync() {
const googleToken = store.getters['data/loginToken']; const googleToken = store.getters['data/loginToken'];
return googleDriveAppDataProvider.getChanges(googleToken) return mainProvider.getChanges(googleToken)
.then((changes) => { .then((changes) => {
// Apply changes // Apply changes
applyChanges(changes); applyChanges(changes);
googleDriveAppDataProvider.setAppliedChanges(googleToken, changes); mainProvider.setAppliedChanges(googleToken, changes);
// Prevent from sending items too long after changes have been retrieved // Prevent from sending items too long after changes have been retrieved
const syncStartTime = Date.now(); const syncStartTime = Date.now();
@ -292,6 +326,7 @@ function sync() {
...store.state.file.itemMap, ...store.state.file.itemMap,
...store.state.folder.itemMap, ...store.state.folder.itemMap,
...store.state.syncLocation.itemMap, ...store.state.syncLocation.itemMap,
...store.state.publishLocation.itemMap,
// Deal with contents later // Deal with contents later
}; };
const syncDataByItemId = store.getters['data/syncDataByItemId']; const syncDataByItemId = store.getters['data/syncDataByItemId'];
@ -300,7 +335,7 @@ function sync() {
const item = storeItemMap[id]; const item = storeItemMap[id];
const existingSyncData = syncDataByItemId[id]; const existingSyncData = syncDataByItemId[id];
if (!existingSyncData || existingSyncData.hash !== item.hash) { if (!existingSyncData || existingSyncData.hash !== item.hash) {
result = googleDriveAppDataProvider.saveItem( result = mainProvider.saveItem(
googleToken, googleToken,
// Use deepCopy to freeze objects // Use deepCopy to freeze objects
utils.deepCopy(item), utils.deepCopy(item),
@ -323,7 +358,8 @@ function sync() {
...store.state.file.itemMap, ...store.state.file.itemMap,
...store.state.folder.itemMap, ...store.state.folder.itemMap,
...store.state.syncLocation.itemMap, ...store.state.syncLocation.itemMap,
...store.state.content.itemMap, // Deal with contents now ...store.state.publishLocation.itemMap,
...store.state.content.itemMap,
}; };
const syncData = store.getters['data/syncData']; const syncData = store.getters['data/syncData'];
let result; let result;
@ -335,7 +371,7 @@ function sync() {
) { ) {
// Use deepCopy to freeze objects // Use deepCopy to freeze objects
const syncDataToRemove = utils.deepCopy(existingSyncData); const syncDataToRemove = utils.deepCopy(existingSyncData);
result = googleDriveAppDataProvider result = mainProvider
.removeItem(googleToken, syncDataToRemove, ifNotTooLate) .removeItem(googleToken, syncDataToRemove, ifNotTooLate)
.then(() => { .then(() => {
const syncDataCopy = { ...store.getters['data/syncData'] }; const syncDataCopy = { ...store.getters['data/syncData'] };
@ -350,10 +386,7 @@ function sync() {
}); });
const getOneFileIdToSync = () => { const getOneFileIdToSync = () => {
const allContentIds = Object.keys({ const allContentIds = Object.keys(localDbSvc.hashMap.content);
...store.state.content.itemMap,
...store.getters['data/syncDataByType'].content,
});
let fileId; let fileId;
allContentIds.some((contentId) => { allContentIds.some((contentId) => {
// Get content hash from itemMap or from localDbSvc if not loaded // Get content hash from itemMap or from localDbSvc if not loaded
@ -361,7 +394,7 @@ function sync() {
const hash = loadedContent ? loadedContent.hash : localDbSvc.hashMap.content[contentId]; const hash = loadedContent ? loadedContent.hash : localDbSvc.hashMap.content[contentId];
const syncData = store.getters['data/syncDataByItemId'][contentId]; const syncData = store.getters['data/syncDataByItemId'][contentId];
// Sync if item hash and syncData hash are different // Sync if item hash and syncData hash are different
if (!hash || !syncData || hash !== syncData.hash) { if (!syncData || hash !== syncData.hash) {
[fileId] = contentId.split('/'); [fileId] = contentId.split('/');
} }
return fileId; return fileId;
@ -401,7 +434,7 @@ function requestSync() {
let intervalId; let intervalId;
const attempt = () => { const attempt = () => {
// Only start syncing when these conditions are met // Only start syncing when these conditions are met
if (userActivitySvc.isActive() && isSyncWindow()) { if (utils.isUserActive() && isSyncWindow()) {
clearInterval(intervalId); clearInterval(intervalId);
if (!isSyncPossible()) { if (!isSyncPossible()) {
// Cancel sync // Cancel sync
@ -422,6 +455,9 @@ function requestSync() {
return sync(); return sync();
} }
if (hasCurrentFileSyncLocations()) { if (hasCurrentFileSyncLocations()) {
// Only sync current file if data sync is unavailable.
// We also could sync files that are out-of-sync but it would
// require to load the syncedContent objects of all files.
return syncFile(store.getters['file/current'].id); return syncFile(store.getters['file/current'].id);
} }
return null; return null;
@ -437,7 +473,7 @@ function requestSync() {
// Sync periodically // Sync periodically
utils.setInterval(() => { utils.setInterval(() => {
if (isSyncPossible() && if (isSyncPossible() &&
userActivitySvc.isActive() && utils.isUserActive() &&
isSyncWindow() && isSyncWindow() &&
isAutoSyncReady() isAutoSyncReady()
) { ) {
@ -508,4 +544,5 @@ utils.setInterval(() => {
export default { export default {
isSyncPossible, isSyncPossible,
requestSync, requestSync,
createSyncLocation,
}; };

View File

@ -1,28 +0,0 @@
const inactiveAfter = 2 * 60 * 1000; // 2 minutes
let lastActivity;
let lastFocus;
const lastFocusKey = 'lastWindowFocus';
function setLastActivity() {
lastActivity = Date.now();
}
function setLastFocus() {
lastFocus = Date.now();
localStorage[lastFocusKey] = lastFocus;
setLastActivity();
}
setLastFocus();
window.addEventListener('focus', setLastFocus);
window.document.addEventListener('mousedown', setLastActivity);
window.document.addEventListener('keydown', setLastActivity);
export default {
isFocused() {
return parseInt(localStorage[lastFocusKey], 10) === lastFocus;
},
isActive() {
return lastActivity > Date.now() - inactiveAfter && this.isFocused();
},
};

View File

@ -1,14 +1,35 @@
import yaml from 'js-yaml'; import yaml from 'js-yaml';
import defaultProperties from '../data/defaultFileProperties.yml'; import defaultProperties from '../data/defaultFileProperties.yml';
// For sortObject const workspaceId = 'main';
const collator = new Intl.Collator(undefined, { sensitivity: 'base' });
// For uid() // For uid()
const uidLength = 16;
const crypto = window.crypto || window.msCrypto; const crypto = window.crypto || window.msCrypto;
const alphabet = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''); const alphabet = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
const radix = alphabet.length; const radix = alphabet.length;
const array = new Uint32Array(16); const array = new Uint32Array(uidLength);
// For isUserActive
const inactiveAfter = 2 * 60 * 1000; // 2 minutes
let lastActivity;
const setLastActivity = () => {
lastActivity = Date.now();
};
window.document.addEventListener('mousedown', setLastActivity);
window.document.addEventListener('keydown', setLastActivity);
window.document.addEventListener('touchstart', setLastActivity);
// For isWindowFocused
let lastFocus;
const lastFocusKey = `${workspaceId}/lastWindowFocus`;
const setLastFocus = () => {
lastFocus = Date.now();
localStorage[lastFocusKey] = lastFocus;
setLastActivity();
};
setLastFocus();
window.addEventListener('focus', setLastFocus);
// For addQueryParams() // For addQueryParams()
const urlParser = window.document.createElement('a'); const urlParser = window.document.createElement('a');
@ -17,8 +38,18 @@ const urlParser = window.document.createElement('a');
const scriptLoadingPromises = Object.create(null); const scriptLoadingPromises = Object.create(null);
export default { export default {
workspaceId,
origin: `${location.protocol}//${location.host}`, origin: `${location.protocol}//${location.host}`,
types: ['contentState', 'syncedContent', 'content', 'file', 'folder', 'syncLocation', 'data'], types: [
'contentState',
'syncedContent',
'content',
'file',
'folder',
'syncLocation',
'publishLocation',
'data',
],
deepCopy(obj) { deepCopy(obj) {
return obj == null ? obj : JSON.parse(JSON.stringify(obj)); return obj == null ? obj : JSON.parse(JSON.stringify(obj));
}, },
@ -34,15 +65,6 @@ export default {
}, {}); }, {});
}); });
}, },
sortObject(obj, sortFunc = key => key) {
const result = {};
const compare = (key1, key2) => collator.compare(
sortFunc(key1, obj[key1]), sortFunc(key2, obj[key2]));
Object.keys(obj).sort(compare).forEach((key) => {
result[key] = obj[key];
});
return result;
},
uid() { uid() {
crypto.getRandomValues(array); crypto.getRandomValues(array);
return array.cl_map(value => alphabet[value % radix]).join(''); return array.cl_map(value => alphabet[value % radix]).join('');
@ -84,6 +106,12 @@ export default {
setInterval(func, interval) { setInterval(func, interval) {
return setInterval(() => func(), this.randomize(interval)); return setInterval(() => func(), this.randomize(interval));
}, },
isWindowFocused() {
return parseInt(localStorage[lastFocusKey], 10) === lastFocus;
},
isUserActive() {
return lastActivity > Date.now() - inactiveAfter && this.isWindowFocused();
},
addQueryParams(url = '', params = {}) { addQueryParams(url = '', params = {}) {
const keys = Object.keys(params).filter(key => params[key] != null); const keys = Object.keys(params).filter(key => params[key] != null);
if (!keys.length) { if (!keys.length) {
@ -166,7 +194,7 @@ export default {
event.data.state === state event.data.state === state
) { ) {
oauth2Context.clean(); oauth2Context.clean();
if (event.data.accessToken) { if (event.data.accessToken || event.data.code) {
resolve(event.data); resolve(event.data);
} else { } else {
reject(event.data); reject(event.data);
@ -259,7 +287,7 @@ export default {
// Add query params to URL // Add query params to URL
const url = this.addQueryParams(config.url, config.params); const url = this.addQueryParams(config.url, config.params);
xhr.open(config.method, url); xhr.open(config.method || 'GET', url);
Object.keys(config.headers).forEach((key) => { Object.keys(config.headers).forEach((key) => {
xhr.setRequestHeader(key, config.headers[key]); xhr.setRequestHeader(key, config.headers[key]);
}); });
@ -281,4 +309,24 @@ export default {
return attempt(); return attempt();
}, },
checkOnline() {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
let timeout;
const cleaner = (cb, res) => () => {
clearTimeout(timeout);
cb(res);
document.head.removeChild(script);
};
script.onload = cleaner(resolve, 'Online.');
script.onerror = cleaner(reject, 'Offline.');
script.src = `https://apis.google.com/js/api.js?${Date.now()}`;
try {
document.head.appendChild(script); // This can fail with bad network
timeout = setTimeout(cleaner(reject), 15000); // 15 sec
} catch (e) {
reject(e);
}
});
},
}; };

View File

@ -7,11 +7,13 @@ import syncedContent from './modules/syncedContent';
import content from './modules/content'; import content from './modules/content';
import file from './modules/file'; import file from './modules/file';
import folder from './modules/folder'; import folder from './modules/folder';
import publishLocation from './modules/publishLocation';
import syncLocation from './modules/syncLocation'; import syncLocation from './modules/syncLocation';
import data from './modules/data'; import data from './modules/data';
import layout from './modules/layout'; import layout from './modules/layout';
import explorer from './modules/explorer'; import explorer from './modules/explorer';
import modal from './modules/modal'; import modal from './modules/modal';
import notification from './modules/notification';
import queue from './modules/queue'; import queue from './modules/queue';
Vue.use(Vuex); Vue.use(Vuex);
@ -44,21 +46,41 @@ const store = new Vuex.Store({
content, content,
file, file,
folder, folder,
publishLocation,
syncLocation, syncLocation,
data, data,
layout, layout,
explorer, explorer,
modal, modal,
notification,
queue, queue,
}, },
strict: debug, strict: debug,
plugins: debug ? [createLogger()] : [], plugins: debug ? [createLogger()] : [],
}); });
let isConnectionDown = false;
let lastConnectionCheck = 0;
function checkOffline() { function checkOffline() {
const isOffline = window.navigator.onLine === false; const isBrowserOffline = window.navigator.onLine === false;
if (!isBrowserOffline && lastConnectionCheck + 30000 < Date.now() && utils.isUserActive()) {
lastConnectionCheck = Date.now();
utils.checkOnline()
.then(() => {
isConnectionDown = false;
}, () => {
isConnectionDown = true;
});
}
const isOffline = isBrowserOffline || isConnectionDown;
if (isOffline !== store.state.offline) { if (isOffline !== store.state.offline) {
store.commit('setOffline', isOffline); store.commit('setOffline', isOffline);
if (isOffline) {
store.dispatch('notification/info', 'You are offline.');
} else {
store.dispatch('notification/info', 'You are back online!');
}
} }
} }
utils.setInterval(checkOffline, 1000); utils.setInterval(checkOffline, 1000);

View File

@ -1,3 +1,4 @@
import Vue from 'vue';
import yaml from 'js-yaml'; import yaml from 'js-yaml';
import moduleTemplate from './moduleTemplate'; import moduleTemplate from './moduleTemplate';
import utils from '../../services/utils'; import utils from '../../services/utils';
@ -5,6 +6,7 @@ import defaultSettings from '../../data/defaultSettings.yml';
import defaultLocalSettings from '../../data/defaultLocalSettings'; import defaultLocalSettings from '../../data/defaultLocalSettings';
import plainHtmlTemplate from '../../data/plainHtmlTemplate.html'; import plainHtmlTemplate from '../../data/plainHtmlTemplate.html';
import styledHtmlTemplate from '../../data/styledHtmlTemplate.html'; import styledHtmlTemplate from '../../data/styledHtmlTemplate.html';
import jekyllSiteTemplate from '../../data/jekyllSiteTemplate.html';
const itemTemplate = (id, data = {}) => ({ id, type: 'data', data, hash: 0 }); const itemTemplate = (id, data = {}) => ({ id, type: 'data', data, hash: 0 });
@ -20,6 +22,23 @@ const empty = (id) => {
}; };
const module = moduleTemplate(empty, true); const module = moduleTemplate(empty, true);
module.mutations.setItem = (state, value) => {
const emptyItem = empty(value.id);
let itemData = value.data || emptyItem.data;
if (typeof itemData === 'object') {
itemData = Object.assign(emptyItem.data, value.data);
}
const item = {
...emptyItem,
...value,
data: itemData,
};
if (!item.hash) {
item.hash = Date.now();
}
Vue.set(state.itemMap, item.id, item);
};
const getter = id => state => (state.itemMap[id] || empty(id)).data; const getter = id => state => (state.itemMap[id] || empty(id)).data;
const setter = id => ({ commit }, data) => commit('setItem', itemTemplate(id, data)); const setter = id => ({ commit }, data) => commit('setItem', itemTemplate(id, data));
const patcher = id => ({ state, commit }, data) => { const patcher = id => ({ state, commit }, data) => {
@ -86,8 +105,10 @@ const makeAdditionalTemplate = (name, value, helpers = '\n') => ({
isAdditional: true, isAdditional: true,
}); });
const additionalTemplates = { const additionalTemplates = {
plainText: makeAdditionalTemplate('Plain text', '{{{files.0.content.text}}}'),
plainHtml: makeAdditionalTemplate('Plain HTML', plainHtmlTemplate), plainHtml: makeAdditionalTemplate('Plain HTML', plainHtmlTemplate),
styledHtml: makeAdditionalTemplate('Styled HTML', styledHtmlTemplate), styledHtml: makeAdditionalTemplate('Styled HTML', styledHtmlTemplate),
jekyllSite: makeAdditionalTemplate('Jekyll site', jekyllSiteTemplate),
}; };
module.getters.allTemplates = (state, getters) => ({ module.getters.allTemplates = (state, getters) => ({
...getters.templates, ...getters.templates,
@ -154,6 +175,8 @@ module.actions.setSyncData = setter('syncData');
// Tokens // Tokens
module.getters.tokens = getter('tokens'); module.getters.tokens = getter('tokens');
module.getters.googleTokens = (state, getters) => getters.tokens.google || {}; module.getters.googleTokens = (state, getters) => getters.tokens.google || {};
module.getters.dropboxTokens = (state, getters) => getters.tokens.dropbox || {};
module.getters.githubTokens = (state, getters) => getters.tokens.github || {};
module.getters.loginToken = (state, getters) => { module.getters.loginToken = (state, getters) => {
// Return the first google token that has the isLogin flag // Return the first google token that has the isLogin flag
const googleTokens = getters.googleTokens; const googleTokens = getters.googleTokens;
@ -170,5 +193,21 @@ module.actions.setGoogleToken = ({ getters, dispatch }, token) => {
}, },
}); });
}; };
module.actions.setDropboxToken = ({ getters, dispatch }, token) => {
dispatch('patchTokens', {
dropbox: {
...getters.dropboxTokens,
[token.sub]: token,
},
});
};
module.actions.setGithubToken = ({ getters, dispatch }, token) => {
dispatch('patchTokens', {
github: {
...getters.githubTokens,
[token.sub]: token,
},
});
};
export default module; export default module;

View File

@ -18,8 +18,9 @@ const collator = new Intl.Collator(undefined, { sensitivity: 'base' });
const compare = (node1, node2) => collator.compare(node1.item.name, node2.item.name); const compare = (node1, node2) => collator.compare(node1.item.name, node2.item.name);
class Node { class Node {
constructor(item, isFolder, isRoot) { constructor(item, locations = [], isFolder = false, isRoot = false) {
this.item = item; this.item = item;
this.locations = locations;
this.isFolder = isFolder; this.isFolder = isFolder;
this.isRoot = isRoot; this.isRoot = isRoot;
if (isFolder) { if (isFolder) {
@ -69,12 +70,16 @@ export default {
nodeStructure: (state, getters, rootState, rootGetters) => { nodeStructure: (state, getters, rootState, rootGetters) => {
const nodeMap = {}; const nodeMap = {};
rootGetters['folder/items'].forEach((item) => { rootGetters['folder/items'].forEach((item) => {
nodeMap[item.id] = new Node(item, true); nodeMap[item.id] = new Node(item, [], true);
}); });
rootGetters['file/items'].forEach((item) => { rootGetters['file/items'].forEach((item) => {
nodeMap[item.id] = new Node(item); const locations = [
...rootGetters['syncLocation/groupedByFileId'][item.id] || [],
...rootGetters['publishLocation/groupedByFileId'][item.id] || [],
];
nodeMap[item.id] = new Node(item, locations);
}); });
const rootNode = new Node(emptyFolder(), true, true); const rootNode = new Node(emptyFolder(), [], true, true);
Object.keys(nodeMap).forEach((id) => { Object.keys(nodeMap).forEach((id) => {
const node = nodeMap[id]; const node = nodeMap[id];
let parentNode = nodeMap[node.item.parentId]; let parentNode = nodeMap[node.item.parentId];
@ -121,7 +126,7 @@ export default {
setDragSourceId: setter('dragSourceId'), setDragSourceId: setter('dragSourceId'),
setDragTargetId: setter('dragTargetId'), setDragTargetId: setter('dragTargetId'),
setNewItem(state, item) { setNewItem(state, item) {
state.newChildNode = item ? new Node(item, item.type === 'folder') : nilFileNode; state.newChildNode = item ? new Node(item, [], item.type === 'folder') : nilFileNode;
}, },
setNewItemName(state, name) { setNewItemName(state, name) {
state.newChildNode.item.name = name; state.newChildNode.item.name = name;

View File

@ -2,8 +2,13 @@ const editorMinWidth = 320;
const minPadding = 20; const minPadding = 20;
const previewButtonWidth = 55; const previewButtonWidth = 55;
const editorTopPadding = 10; const editorTopPadding = 10;
const navigationBarSpaceWidth = 30; const navigationBarEditButtonsWidth = 36 * 12; // 12 buttons
const navigationBarLeftWidth = 570; const navigationBarLeftButtonWidth = 38 + 4 + 15;
const navigationBarRightButtonWidth = 38 + 8;
const navigationBarSpinnerWidth = 18 + 15 + 5; // 5 for left margin
const navigationBarLocationWidth = 20;
const navigationBarSyncPublishButtonsWidth = 36 + 10;
const navigationBarTitleMargin = 8;
const maxTitleMaxWidth = 800; const maxTitleMaxWidth = 800;
const minTitleMaxWidth = 200; const minTitleMaxWidth = 200;
@ -15,7 +20,7 @@ const constants = {
statusBarHeight: 20, statusBarHeight: 20,
}; };
function computeStyles(state, computedSettings, localSettings, styles = { function computeStyles(state, localSettings, getters, styles = {
showNavigationBar: !localSettings.showEditor || localSettings.showNavigationBar, showNavigationBar: !localSettings.showEditor || localSettings.showNavigationBar,
showStatusBar: localSettings.showStatusBar, showStatusBar: localSettings.showStatusBar,
showEditor: localSettings.showEditor, showEditor: localSettings.showEditor,
@ -49,9 +54,10 @@ function computeStyles(state, computedSettings, localSettings, styles = {
if (styles.showSidePreview && doublePanelWidth / 2 < editorMinWidth) { if (styles.showSidePreview && doublePanelWidth / 2 < editorMinWidth) {
styles.showSidePreview = false; styles.showSidePreview = false;
styles.showPreview = false; styles.showPreview = false;
return computeStyles(state, computedSettings, localSettings, styles); return computeStyles(state, localSettings, getters, styles);
} }
const computedSettings = getters['data/computedSettings'];
styles.fontSize = 18; styles.fontSize = 18;
styles.textWidth = 990; styles.textWidth = 990;
if (doublePanelWidth < 1120) { if (doublePanelWidth < 1120) {
@ -89,9 +95,23 @@ function computeStyles(state, computedSettings, localSettings, styles = {
Math.floor((styles.editorWidth - styles.textWidth) / 2), minPadding); Math.floor((styles.editorWidth - styles.textWidth) / 2), minPadding);
styles.editorPadding = `${editorTopPadding}px ${editorSidePadding}px ${bottomPadding}px`; styles.editorPadding = `${editorTopPadding}px ${editorSidePadding}px ${bottomPadding}px`;
styles.titleMaxWidth = styles.innerWidth - navigationBarSpaceWidth; styles.titleMaxWidth = styles.innerWidth;
if (styles.showEditor) { if (styles.showEditor) {
styles.titleMaxWidth -= navigationBarLeftWidth; const syncLocations = getters['syncLocation/current'];
const publishLocations = getters['publishLocation/current'];
const isSyncPossible = getters['data/loginToken'] || syncLocations.length;
styles.titleMaxWidth = styles.innerWidth -
navigationBarEditButtonsWidth -
navigationBarLeftButtonWidth -
navigationBarRightButtonWidth -
navigationBarSpinnerWidth -
(navigationBarLocationWidth * (syncLocations.length + publishLocations.length)) -
(isSyncPossible ? navigationBarSyncPublishButtonsWidth : 0) -
(publishLocations.length ? navigationBarSyncPublishButtonsWidth : 0) -
navigationBarTitleMargin;
if (styles.titleMaxWidth + navigationBarEditButtonsWidth < minTitleMaxWidth) {
styles.hideLocations = true;
}
} }
styles.titleMaxWidth = Math.min(styles.titleMaxWidth, maxTitleMaxWidth); styles.titleMaxWidth = Math.min(styles.titleMaxWidth, maxTitleMaxWidth);
styles.titleMaxWidth = Math.max(styles.titleMaxWidth, minTitleMaxWidth); styles.titleMaxWidth = Math.max(styles.titleMaxWidth, minTitleMaxWidth);
@ -113,9 +133,8 @@ export default {
getters: { getters: {
constants: () => constants, constants: () => constants,
styles: (state, getters, rootState, rootGetters) => { styles: (state, getters, rootState, rootGetters) => {
const computedSettings = rootGetters['data/computedSettings'];
const localSettings = rootGetters['data/localSettings']; const localSettings = rootGetters['data/localSettings'];
return computeStyles(state, computedSettings, localSettings); return computeStyles(state, localSettings, rootGetters);
}, },
}, },
}; };

View File

@ -1,34 +1,47 @@
export default { export default {
namespaced: true, namespaced: true,
state: { state: {
config: null, stack: [],
hidden: false,
}, },
mutations: { mutations: {
setConfig: (state, value) => { setStack: (state, value) => {
state.config = value; state.stack = value;
},
setHidden: (state, value) => {
state.hidden = value;
}, },
}, },
getters: {
config: state => !state.hidden && state.stack[0],
},
actions: { actions: {
open({ commit }, param) { open({ commit, state }, param) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let config = param; const config = typeof param === 'object' ? { ...param } : { type: param };
if (typeof config === 'string') { const clean = () => commit('setStack', state.stack.filter((otherConfig => otherConfig !== config)));
config = {
type: config,
};
}
config.resolve = (result) => { config.resolve = (result) => {
if (config.onResolve) { if (config.onResolve) {
config.onResolve(result); config.onResolve(result);
} }
commit('setConfig'); clean();
resolve(result); resolve(result);
}; };
config.reject = (error) => { config.reject = (error) => {
commit('setConfig'); clean();
reject(error); reject(error);
}; };
commit('setConfig', config); commit('setStack', [config, ...state.stack]);
});
},
hideUntil({ commit, state }, promise) {
commit('setHidden', true);
return promise.then((res) => {
commit('setHidden', false);
return res;
}, (err) => {
commit('setHidden', false);
throw err;
}); });
}, },
notImplemented: ({ dispatch }) => dispatch('open', { notImplemented: ({ dispatch }) => dispatch('open', {

View File

@ -0,0 +1,49 @@
const itemTimeout = 5000;
export default {
namespaced: true,
state: {
items: [],
},
mutations: {
setItems: (state, value) => {
state.items = value;
},
},
actions: {
info({ state, commit }, info) {
const item = {
type: 'info',
content: info,
};
commit('setItems', [item, ...state.items]);
setTimeout(() =>
commit('setItems', state.items.filter(otherItem => otherItem !== item)), itemTimeout);
},
error({ state, commit, rootState }, error) {
const item = {
type: 'error',
};
if (error) {
if (error.message) {
item.content = error.message;
} else if (error.status) {
const location = rootState.queue.currentLocation;
if (location.providerId) {
item.content = `HTTP error ${error.status} on ${location.providerId} location.`;
} else {
item.content = `HTTP error ${error.status}.`;
}
} else {
item.content = error.toString();
}
}
if (!item.content || item.content === '[object Object]') {
item.content = 'Unknown error.';
}
commit('setItems', [item, ...state.items]);
setTimeout(() =>
commit('setItems', state.items.filter(otherItem => otherItem !== item)), itemTimeout);
},
},
};

View File

@ -0,0 +1,34 @@
import moduleTemplate from './moduleTemplate';
import empty from '../../data/emptyPublishLocation';
import providerRegistry from '../../services/providers/providerRegistry';
const module = moduleTemplate(empty);
module.getters = {
...module.getters,
groupedByFileId: (state, getters) => {
const result = {};
getters.items.forEach((item) => {
// Filter items that we can't use
if (providerRegistry.providers[item.providerId].getToken(item)) {
const list = result[item.fileId] || [];
list.push(item);
result[item.fileId] = list;
}
});
return result;
},
current: (state, getters, rootState, rootGetters) => {
const locations = getters.groupedByFileId[rootGetters['file/current'].id] || [];
return locations.map((location) => {
const provider = providerRegistry.providers[location.providerId];
return {
...location,
description: provider.getDescription(location),
url: provider.getUrl(location),
};
});
},
};
export default module;

View File

@ -9,26 +9,41 @@ export default {
state: { state: {
isEmpty: true, isEmpty: true,
isSyncRequested: false, isSyncRequested: false,
isPublishRequested: false,
currentLocation: {},
}, },
mutations: { mutations: {
setIsEmpty: setter('isEmpty'), setIsEmpty: setter('isEmpty'),
setIsSyncRequested: setter('isSyncRequested'), setIsSyncRequested: setter('isSyncRequested'),
setIsPublishRequested: setter('isPublishRequested'),
setCurrentLocation: setter('currentLocation'),
}, },
actions: { actions: {
enqueue({ state, commit }, cb) { enqueue({ state, commit, dispatch }, cb) {
const checkOffline = () => {
if (state.offline) {
// Empty queue
queue = Promise.resolve();
commit('setIsEmpty', true);
throw new Error('offline');
}
};
if (state.isEmpty) { if (state.isEmpty) {
commit('setIsEmpty', false); commit('setIsEmpty', false);
} }
const newQueue = queue const newQueue = queue
.then(cb) .then(() => checkOffline())
.catch((err) => { .then(() => cb()
console.error(err); .catch((err) => {
}) console.error(err); // eslint-disable-line no-console
.then(() => { checkOffline();
if (newQueue === queue) { dispatch('notification/error', err, { root: true });
commit('setIsEmpty', true); })
} .then(() => {
}); if (newQueue === queue) {
commit('setIsEmpty', true);
}
}));
queue = newQueue; queue = newQueue;
}, },
enqueueSyncRequest({ state, commit, dispatch }, cb) { enqueueSyncRequest({ state, commit, dispatch }, cb) {
@ -41,5 +56,26 @@ export default {
})); }));
} }
}, },
enqueuePublishRequest({ state, commit, dispatch }, cb) {
if (!state.isSyncRequested) {
commit('setIsPublishRequested', true);
const unset = () => commit('setIsPublishRequested', false);
dispatch('enqueue', () => cb().then(unset, (err) => {
unset();
throw err;
}));
}
},
doWithLocation({ state, commit, dispatch }, { location, promise }) {
commit('setCurrentLocation', location);
return promise
.then((res) => {
commit('setCurrentLocation', {});
return res;
}, (err) => {
commit('setCurrentLocation', {});
throw err;
});
},
}, },
}; };

View File

@ -1,5 +1,6 @@
import moduleTemplate from './moduleTemplate'; import moduleTemplate from './moduleTemplate';
import empty from '../../data/emptySyncLocation'; import empty from '../../data/emptySyncLocation';
import providerRegistry from '../../services/providers/providerRegistry';
const module = moduleTemplate(empty); const module = moduleTemplate(empty);
@ -8,14 +9,26 @@ module.getters = {
groupedByFileId: (state, getters) => { groupedByFileId: (state, getters) => {
const result = {}; const result = {};
getters.items.forEach((item) => { getters.items.forEach((item) => {
const list = result[item.fileId] || []; // Filter items that we can't use
list.push(item); if (providerRegistry.providers[item.providerId].getToken(item)) {
result[item.fileId] = list; const list = result[item.fileId] || [];
list.push(item);
result[item.fileId] = list;
}
}); });
return result; return result;
}, },
current: (state, getters, rootState, rootGetters) => current: (state, getters, rootState, rootGetters) => {
getters.groupedByFileId[rootGetters['file/current'].id] || [], const locations = getters.groupedByFileId[rootGetters['file/current'].id] || [];
return locations.map((location) => {
const provider = providerRegistry.providers[location.providerId];
return {
...location,
description: provider.getDescription(location),
url: provider.getUrl(location),
};
});
},
}; };
export default module; export default module;

View File

@ -4,6 +4,7 @@
<script> <script>
var state; var state;
var accessToken; var accessToken;
var code;
var expiresIn; var expiresIn;
function parse(search) { function parse(search) {
(search || '').slice(1).split('&').forEach(function (param) { (search || '').slice(1).split('&').forEach(function (param) {
@ -14,6 +15,8 @@
state = value; state = value;
} else if (key === 'access_token') { } else if (key === 'access_token') {
accessToken = value; accessToken = value;
} else if (key === 'code') {
code = value;
} else if (key === 'expires_in') { } else if (key === 'expires_in') {
expiresIn = value; expiresIn = value;
} }
@ -25,6 +28,7 @@
(window.opener || window.parent).postMessage({ (window.opener || window.parent).postMessage({
state: state, state: state,
accessToken: accessToken, accessToken: accessToken,
code: code,
expiresIn: expiresIn expiresIn: expiresIn
}, origin); }, origin);
</script> </script>

135
yarn.lock
View File

@ -55,7 +55,7 @@ ajv@^4.11.2, ajv@^4.7.0, ajv@^4.9.1:
co "^4.6.0" co "^4.6.0"
json-stable-stringify "^1.0.1" json-stable-stringify "^1.0.1"
ajv@^5.0.0: ajv@^5.0.0, ajv@^5.1.0:
version "5.2.2" version "5.2.2"
resolved "https://registry.yarnpkg.com/ajv/-/ajv-5.2.2.tgz#47c68d69e86f5d953103b0074a9430dc63da5e39" resolved "https://registry.yarnpkg.com/ajv/-/ajv-5.2.2.tgz#47c68d69e86f5d953103b0074a9430dc63da5e39"
dependencies: dependencies:
@ -243,7 +243,11 @@ aws-sign2@~0.6.0:
version "0.6.0" version "0.6.0"
resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.6.0.tgz#14342dd38dbcc94d0e5b87d763cd63612c0e794f" resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.6.0.tgz#14342dd38dbcc94d0e5b87d763cd63612c0e794f"
aws4@^1.2.1: aws-sign2@~0.7.0:
version "0.7.0"
resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8"
aws4@^1.2.1, aws4@^1.6.0:
version "1.6.0" version "1.6.0"
resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.6.0.tgz#83ef5ca860b2b32e4a0deedee8c771b9db57471e" resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.6.0.tgz#83ef5ca860b2b32e4a0deedee8c771b9db57471e"
@ -889,6 +893,18 @@ boom@2.x.x:
dependencies: dependencies:
hoek "2.x.x" hoek "2.x.x"
boom@4.x.x:
version "4.3.1"
resolved "https://registry.yarnpkg.com/boom/-/boom-4.3.1.tgz#4f8a3005cb4a7e3889f749030fd25b96e01d2e31"
dependencies:
hoek "4.x.x"
boom@5.x.x:
version "5.2.0"
resolved "https://registry.yarnpkg.com/boom/-/boom-5.2.0.tgz#5dd9da6ee3a5f302077436290cb717d3f4a54e02"
dependencies:
hoek "4.x.x"
brace-expansion@^1.0.0: brace-expansion@^1.0.0:
version "1.1.8" version "1.1.8"
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.8.tgz#c07b211c7c952ec1f8efd51a77ef0d1d3990a292" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.8.tgz#c07b211c7c952ec1f8efd51a77ef0d1d3990a292"
@ -1443,6 +1459,12 @@ cryptiles@2.x.x:
dependencies: dependencies:
boom "2.x.x" boom "2.x.x"
cryptiles@3.x.x:
version "3.1.2"
resolved "https://registry.yarnpkg.com/cryptiles/-/cryptiles-3.1.2.tgz#a89fbb220f5ce25ec56e8c4aa8a4fd7b5b0d29fe"
dependencies:
boom "5.x.x"
crypto-browserify@^3.11.0: crypto-browserify@^3.11.0:
version "3.11.0" version "3.11.0"
resolved "https://registry.yarnpkg.com/crypto-browserify/-/crypto-browserify-3.11.0.tgz#3652a0906ab9b2a7e0c3ce66a408e957a2485522" resolved "https://registry.yarnpkg.com/crypto-browserify/-/crypto-browserify-3.11.0.tgz#3652a0906ab9b2a7e0c3ce66a408e957a2485522"
@ -2222,7 +2244,7 @@ express@^4.14.1, express@^4.15.2:
utils-merge "1.0.0" utils-merge "1.0.0"
vary "~1.1.1" vary "~1.1.1"
extend@^3.0.0, extend@~3.0.0: extend@^3.0.0, extend@~3.0.0, extend@~3.0.1:
version "3.0.1" version "3.0.1"
resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.1.tgz#a755ea7bc1adfcc5a31ce7e762dbaadc5e636444" resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.1.tgz#a755ea7bc1adfcc5a31ce7e762dbaadc5e636444"
@ -2419,6 +2441,14 @@ form-data@~2.1.1:
combined-stream "^1.0.5" combined-stream "^1.0.5"
mime-types "^2.1.12" mime-types "^2.1.12"
form-data@~2.3.1:
version "2.3.1"
resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.1.tgz#6fb94fbd71885306d73d15cc497fe4cc4ecd44bf"
dependencies:
asynckit "^0.4.0"
combined-stream "^1.0.5"
mime-types "^2.1.12"
forwarded@~0.1.0: forwarded@~0.1.0:
version "0.1.0" version "0.1.0"
resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.1.0.tgz#19ef9874c4ae1c297bcf078fde63a09b66a84363" resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.1.0.tgz#19ef9874c4ae1c297bcf078fde63a09b66a84363"
@ -2778,6 +2808,10 @@ har-schema@^1.0.5:
version "1.0.5" version "1.0.5"
resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-1.0.5.tgz#d263135f43307c02c602afc8fe95970c0151369e" resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-1.0.5.tgz#d263135f43307c02c602afc8fe95970c0151369e"
har-schema@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92"
har-validator@~4.2.1: har-validator@~4.2.1:
version "4.2.1" version "4.2.1"
resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-4.2.1.tgz#33481d0f1bbff600dd203d75812a6a5fba002e2a" resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-4.2.1.tgz#33481d0f1bbff600dd203d75812a6a5fba002e2a"
@ -2785,6 +2819,13 @@ har-validator@~4.2.1:
ajv "^4.9.1" ajv "^4.9.1"
har-schema "^1.0.5" har-schema "^1.0.5"
har-validator@~5.0.3:
version "5.0.3"
resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-5.0.3.tgz#ba402c266194f15956ef15e0fcf242993f6a7dfd"
dependencies:
ajv "^5.1.0"
har-schema "^2.0.0"
has-ansi@^2.0.0: has-ansi@^2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91" resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91"
@ -2836,6 +2877,15 @@ hawk@~3.1.3:
hoek "2.x.x" hoek "2.x.x"
sntp "1.x.x" sntp "1.x.x"
hawk@~6.0.2:
version "6.0.2"
resolved "https://registry.yarnpkg.com/hawk/-/hawk-6.0.2.tgz#af4d914eb065f9b5ce4d9d11c1cb2126eecc3038"
dependencies:
boom "4.x.x"
cryptiles "3.x.x"
hoek "4.x.x"
sntp "2.x.x"
he@1.1.x, he@^1.1.0: he@1.1.x, he@^1.1.0:
version "1.1.1" version "1.1.1"
resolved "https://registry.yarnpkg.com/he/-/he-1.1.1.tgz#93410fd21b009735151f8868c2f271f3427e23fd" resolved "https://registry.yarnpkg.com/he/-/he-1.1.1.tgz#93410fd21b009735151f8868c2f271f3427e23fd"
@ -2852,6 +2902,10 @@ hoek@2.x.x:
version "2.16.3" version "2.16.3"
resolved "https://registry.yarnpkg.com/hoek/-/hoek-2.16.3.tgz#20bb7403d3cea398e91dc4710a8ff1b8274a25ed" resolved "https://registry.yarnpkg.com/hoek/-/hoek-2.16.3.tgz#20bb7403d3cea398e91dc4710a8ff1b8274a25ed"
hoek@4.x.x:
version "4.2.0"
resolved "https://registry.yarnpkg.com/hoek/-/hoek-4.2.0.tgz#72d9d0754f7fe25ca2d01ad8f8f9a9449a89526d"
home-or-tmp@^2.0.0: home-or-tmp@^2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/home-or-tmp/-/home-or-tmp-2.0.0.tgz#e36c3f2d2cae7d746a857e38d18d5f32a7882db8" resolved "https://registry.yarnpkg.com/home-or-tmp/-/home-or-tmp-2.0.0.tgz#e36c3f2d2cae7d746a857e38d18d5f32a7882db8"
@ -2958,6 +3012,14 @@ http-signature@~1.1.0:
jsprim "^1.2.2" jsprim "^1.2.2"
sshpk "^1.7.0" sshpk "^1.7.0"
http-signature@~1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1"
dependencies:
assert-plus "^1.0.0"
jsprim "^1.2.2"
sshpk "^1.7.0"
https-browserify@0.0.1: https-browserify@0.0.1:
version "0.0.1" version "0.0.1"
resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-0.0.1.tgz#3f91365cabe60b77ed0ebba24b454e3e09d95a82" resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-0.0.1.tgz#3f91365cabe60b77ed0ebba24b454e3e09d95a82"
@ -3871,12 +3933,22 @@ mime-db@~1.27.0:
version "1.27.0" version "1.27.0"
resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.27.0.tgz#820f572296bbd20ec25ed55e5b5de869e5436eb1" resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.27.0.tgz#820f572296bbd20ec25ed55e5b5de869e5436eb1"
mime-db@~1.30.0:
version "1.30.0"
resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.30.0.tgz#74c643da2dd9d6a45399963465b26d5ca7d71f01"
mime-types@^2.1.12, mime-types@~2.1.11, mime-types@~2.1.15, mime-types@~2.1.7: mime-types@^2.1.12, mime-types@~2.1.11, mime-types@~2.1.15, mime-types@~2.1.7:
version "2.1.15" version "2.1.15"
resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.15.tgz#a4ebf5064094569237b8cf70046776d09fc92aed" resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.15.tgz#a4ebf5064094569237b8cf70046776d09fc92aed"
dependencies: dependencies:
mime-db "~1.27.0" mime-db "~1.27.0"
mime-types@~2.1.17:
version "2.1.17"
resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.17.tgz#09d7a393f03e995a79f8af857b70a9e0ab16557a"
dependencies:
mime-db "~1.30.0"
mime@1.3.4: mime@1.3.4:
version "1.3.4" version "1.3.4"
resolved "https://registry.yarnpkg.com/mime/-/mime-1.3.4.tgz#115f9e3b6b3daf2959983cb38f149a2d40eb5d53" resolved "https://registry.yarnpkg.com/mime/-/mime-1.3.4.tgz#115f9e3b6b3daf2959983cb38f149a2d40eb5d53"
@ -4197,7 +4269,7 @@ number-is-nan@^1.0.0:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d" resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d"
oauth-sign@~0.8.1: oauth-sign@~0.8.1, oauth-sign@~0.8.2:
version "0.8.2" version "0.8.2"
resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.8.2.tgz#46a6ab7f0aead8deae9ec0565780b7d4efeb9d43" resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.8.2.tgz#46a6ab7f0aead8deae9ec0565780b7d4efeb9d43"
@ -4479,6 +4551,10 @@ performance-now@^0.2.0:
version "0.2.0" version "0.2.0"
resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-0.2.0.tgz#33ef30c5c77d4ea21c5a53869d91b56d8f2555e5" resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-0.2.0.tgz#33ef30c5c77d4ea21c5a53869d91b56d8f2555e5"
performance-now@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b"
pify@^2.0.0, pify@^2.3.0: pify@^2.0.0, pify@^2.3.0:
version "2.3.0" version "2.3.0"
resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c"
@ -4917,6 +4993,10 @@ qs@6.4.0, qs@~6.4.0:
version "6.4.0" version "6.4.0"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.4.0.tgz#13e26d28ad6b0ffaa91312cd3bf708ed351e7233" resolved "https://registry.yarnpkg.com/qs/-/qs-6.4.0.tgz#13e26d28ad6b0ffaa91312cd3bf708ed351e7233"
qs@~6.5.1:
version "6.5.1"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.1.tgz#349cdf6eef89ec45c12d7d5eb3fc0c870343a6d8"
query-string@^4.1.0: query-string@^4.1.0:
version "4.3.4" version "4.3.4"
resolved "https://registry.yarnpkg.com/query-string/-/query-string-4.3.4.tgz#bbb693b9ca915c232515b228b1a02b609043dbeb" resolved "https://registry.yarnpkg.com/query-string/-/query-string-4.3.4.tgz#bbb693b9ca915c232515b228b1a02b609043dbeb"
@ -5202,6 +5282,33 @@ request@2, request@^2.79.0, request@^2.81.0:
tunnel-agent "^0.6.0" tunnel-agent "^0.6.0"
uuid "^3.0.0" uuid "^3.0.0"
request@^2.82.0:
version "2.82.0"
resolved "https://registry.yarnpkg.com/request/-/request-2.82.0.tgz#2ba8a92cd7ac45660ea2b10a53ae67cd247516ea"
dependencies:
aws-sign2 "~0.7.0"
aws4 "^1.6.0"
caseless "~0.12.0"
combined-stream "~1.0.5"
extend "~3.0.1"
forever-agent "~0.6.1"
form-data "~2.3.1"
har-validator "~5.0.3"
hawk "~6.0.2"
http-signature "~1.2.0"
is-typedarray "~1.0.0"
isstream "~0.1.2"
json-stringify-safe "~5.0.1"
mime-types "~2.1.17"
oauth-sign "~0.8.2"
performance-now "^2.1.0"
qs "~6.5.1"
safe-buffer "^5.1.1"
stringstream "~0.0.5"
tough-cookie "~2.3.2"
tunnel-agent "^0.6.0"
uuid "^3.1.0"
require-directory@^2.1.1: require-directory@^2.1.1:
version "2.1.1" version "2.1.1"
resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42"
@ -5293,7 +5400,7 @@ safe-buffer@^5.0.1, safe-buffer@~5.0.1:
version "5.0.1" version "5.0.1"
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.0.1.tgz#d263ca54696cd8a306b5ca6551e92de57918fbe7" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.0.1.tgz#d263ca54696cd8a306b5ca6551e92de57918fbe7"
safe-buffer@~5.1.0, safe-buffer@~5.1.1: safe-buffer@^5.1.1, safe-buffer@~5.1.0, safe-buffer@~5.1.1:
version "5.1.1" version "5.1.1"
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.1.tgz#893312af69b2123def71f57889001671eeb2c853" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.1.tgz#893312af69b2123def71f57889001671eeb2c853"
@ -5437,6 +5544,12 @@ sntp@1.x.x:
dependencies: dependencies:
hoek "2.x.x" hoek "2.x.x"
sntp@2.x.x:
version "2.0.2"
resolved "https://registry.yarnpkg.com/sntp/-/sntp-2.0.2.tgz#5064110f0af85f7cfdb7d6b67a40028ce52b4b2b"
dependencies:
hoek "4.x.x"
sort-keys@^1.0.0: sort-keys@^1.0.0:
version "1.1.2" version "1.1.2"
resolved "https://registry.yarnpkg.com/sort-keys/-/sort-keys-1.1.2.tgz#441b6d4d346798f1b4e49e8920adfba0e543f9ad" resolved "https://registry.yarnpkg.com/sort-keys/-/sort-keys-1.1.2.tgz#441b6d4d346798f1b4e49e8920adfba0e543f9ad"
@ -5604,7 +5717,7 @@ string_decoder@~1.0.3:
dependencies: dependencies:
safe-buffer "~5.1.0" safe-buffer "~5.1.0"
stringstream@~0.0.4: stringstream@~0.0.4, stringstream@~0.0.5:
version "0.0.5" version "0.0.5"
resolved "https://registry.yarnpkg.com/stringstream/-/stringstream-0.0.5.tgz#4e484cd4de5a0bbbee18e46307710a8a81621878" resolved "https://registry.yarnpkg.com/stringstream/-/stringstream-0.0.5.tgz#4e484cd4de5a0bbbee18e46307710a8a81621878"
@ -5883,6 +5996,12 @@ tough-cookie@~2.3.0:
dependencies: dependencies:
punycode "^1.4.1" punycode "^1.4.1"
tough-cookie@~2.3.2:
version "2.3.3"
resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.3.3.tgz#0b618a5565b6dea90bf3425d04d55edc475a7561"
dependencies:
punycode "^1.4.1"
trim-newlines@^1.0.0: trim-newlines@^1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-1.0.0.tgz#5887966bb582a4503a41eb524f7d35011815a613" resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-1.0.0.tgz#5887966bb582a4503a41eb524f7d35011815a613"
@ -6051,6 +6170,10 @@ uuid@^3.0.0:
version "3.0.1" version "3.0.1"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.0.1.tgz#6544bba2dfda8c1cf17e629a3a305e2bb1fee6c1" resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.0.1.tgz#6544bba2dfda8c1cf17e629a3a305e2bb1fee6c1"
uuid@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.1.0.tgz#3dd3d3e790abc24d7b0d3a034ffababe28ebbc04"
v8flags@^2.0.2: v8flags@^2.0.2:
version "2.1.1" version "2.1.1"
resolved "https://registry.yarnpkg.com/v8flags/-/v8flags-2.1.1.tgz#aab1a1fa30d45f88dd321148875ac02c0b55e5b4" resolved "https://registry.yarnpkg.com/v8flags/-/v8flags-2.1.1.tgz#aab1a1fa30d45f88dd321148875ac02c0b55e5b4"