Преглед на файлове

Merge branch 'release/0.9.5-2'

Book Pauk преди 4 години
родител
ревизия
5a3e4ee5ca

+ 1 - 1
client/components/App.vue

@@ -248,7 +248,7 @@ body, html, #app {
 }
 
 .dborder {
-    border: 2px solid yellow !important;
+    border: 2px solid magenta !important;
 }
 
 .icon-rotate {

+ 289 - 1
client/components/ExternalLibs/BookmarkSettings/BookmarkSettings.vue

@@ -4,6 +4,65 @@
             Настроить закладки
         </template>
 
+        <div class="col column fit">
+            <div class="row items-center top-panel bg-grey-3">
+                <q-btn class="q-mr-md" round dense color="blue" icon="la la-check" @click.stop="openSelected" size="16px" :disabled="!selected">
+                    <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">Открыть выбранную закладку</q-tooltip>
+                </q-btn>
+                <q-input class="col" ref="search" rounded outlined dense bg-color="white" placeholder="Найти" v-model="search">
+                    <template v-slot:append>
+                        <q-icon v-if="search !== ''" name="la la-times" class="cursor-pointer" @click="resetSearch"/>
+                    </template>
+                </q-input>
+            </div>
+
+            <div class="col row">
+                <div class="left-panel column items-center no-wrap bg-grey-3">
+                    <q-btn class="q-my-sm" round dense color="blue" icon="la la-plus" @click.stop="addBookmark" size="14px">
+                        <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">Добавить закладку</q-tooltip>
+                    </q-btn>
+                    <q-btn class="q-mb-sm" round dense color="blue" icon="la la-minus" @click.stop="delBookmark" size="14px" :disabled="!ticked.length">
+                        <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">Удалить отмеченные закладки</q-tooltip>
+                    </q-btn>
+                    <q-btn class="q-mb-sm" round dense color="blue" icon="la la-edit" @click.stop="editBookmark" size="14px" :disabled="!selected || selected.indexOf('r-') == 0">
+                        <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">Редактировать закладку</q-tooltip>
+                    </q-btn>
+                    <q-btn class="q-mb-sm" round dense color="blue" icon="la la-arrow-up" @click.stop="moveBookmark(false)" size="14px" :disabled="!ticked.length">
+                        <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">Переместить отмеченные вверх</q-tooltip>
+                    </q-btn>
+                    <q-btn class="q-mb-sm" round dense color="blue" icon="la la-arrow-down" @click.stop="moveBookmark(true)" size="14px" :disabled="!ticked.length">
+                        <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">Переместить отмеченные вниз</q-tooltip>
+                    </q-btn>
+                    <q-btn class="q-mb-sm" round dense color="blue" icon="la la-broom" @click.stop="setDefaultBookmarks" size="14px">
+                        <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">Установить по умолчанию</q-tooltip>
+                    </q-btn>
+                    <div class="space"/>
+                </div>
+
+                <div class="col fit tree">
+                    <div v-show="nodes.length" class="checkbox-tick-all">
+                        <q-checkbox v-model="tickAll" @input="makeTickAll" size="36px" label="Выбрать все" />
+                    </div>
+                    <q-tree
+                        class="q-my-xs"
+                        :nodes="nodes"
+                        node-key="key"
+                        tick-strategy="leaf"
+                        :selected.sync="selected"
+                        :ticked.sync="ticked"
+                        :expanded.sync="expanded"
+                        selected-color="black"
+                        :filter="search"
+                        no-nodes-label="Закладок пока нет"
+                        no-results-label="Ничего не найдено"
+                    >
+                        <template v-slot:default-header="p">
+                            <div class="q-px-xs" :class="{selected: selected == p.key}">{{ p.node.label }}</div>
+                        </template>
+                    </q-tree>
+                </div>
+            </div>
+        </div>
     </Window>
 </template>
 
@@ -11,11 +70,16 @@
 //-----------------------------------------------------------------------------
 import Vue from 'vue';
 import Component from 'vue-class-component';
+import _ from 'lodash';
+
 import Window from '../../share/Window.vue';
+import * as lu from '../linkUtils';
+import rstore from '../../../store/modules/reader';
 
 const BookmarkSettingsProps = Vue.extend({
     props: {
         libs: Object,
+        addBookmarkVisible: Boolean,
     }
 });
 
@@ -24,12 +88,20 @@ export default @Component({
         Window,
     },
     watch: {
-        libs: function() {
+        ticked: function() {
+            this.checkAllTicked();
         },
     }    
 })
 class BookmarkSettings extends BookmarkSettingsProps {
+    search = '';
+    selected = '';
+    ticked = [];
+    expanded = [];
+    tickAll = false;
+
     created() {
+        this.afterInit = true;
     }
 
     mounted() {
@@ -39,11 +111,193 @@ class BookmarkSettings extends BookmarkSettingsProps {
         this.$refs.window.init();
     }
 
+    get nodes() {
+        const result = [];
+
+        const expanded = [];
+        this.links = {};
+        this.libs.groups.forEach(group => {
+            const rkey = `r-${group.r}`;
+            const g = {label: group.r, key: rkey, children: []};
+            this.links[rkey] = {l: group.r, c: ''};
+
+            group.list.forEach(link => {
+                const key = link.l;
+                g.children.push({
+                    label: (link.c ? link.c + ' ': '') + lu.removeOrigin(link.l),
+                    key
+                });
+
+                this.links[key] = link;
+                if (link.l == this.libs.startLink && expanded.indexOf(rkey) < 0) {
+                    expanded.push(rkey);
+                }
+
+            });
+
+            result.push(g);
+        });
+
+        if (this.afterInit) {
+            this.$nextTick(() => {
+                this.expanded = expanded;
+            });
+            this.afterInit = false;
+        }
+
+        return result;
+    }
+
+    makeTickAll() {
+        if (this.tickAll) {
+            const newTicked = [];
+            for (const key of Object.keys(this.links)) {
+                if (key.indexOf('r-') != 0)
+                    newTicked.push(key);
+            }
+            this.ticked = newTicked;
+        } else {
+            this.ticked = [];
+        }
+    }
+
+    checkAllTicked() {
+        const ticked = new Set(this.ticked);
+
+        let newTickAll = !!(this.nodes.length);
+        for (const key of Object.keys(this.links)) {
+            if (key.indexOf('r-') != 0 && !ticked.has(key))
+                newTickAll = false;
+        }
+        this.tickAll = newTickAll;
+    }
+
+    resetSearch() {
+        this.search = '';
+        this.$refs.search.focus();
+    }
+
+    openSelected() {
+        if (!this.selected)
+            return;
+        if (this.selected.indexOf('r-') === 0) {//rootLink
+            this.$emit('do-action', {action: 'setRootLink', data: this.links[this.selected].l});
+        } else {//selectedLink
+            this.$emit('do-action', {action: 'setSelectedLink', data: this.links[this.selected].l});
+        }
+        this.close();
+    }
+
+    editBookmark() {
+        this.$emit('do-action', {action: 'editBookmark', data: {link: this.links[this.selected].l, desc: this.links[this.selected].c}});
+    }
+
+    addBookmark() {
+        this.$emit('do-action', {action: 'addBookmark'});
+    }
+
+    async delBookmark() {
+        const newLibs = _.cloneDeep(this.libs);
+
+        if (await this.$root.stdDialog.confirm(`Подтвердите удаление ${this.ticked.length} закладок:`, ' ')) {
+            const ticked = new Set(this.ticked);
+            for (let i = newLibs.groups.length - 1; i >= 0; i--) {
+                const g = newLibs.groups[i];
+                for (let j = g.list.length - 1; j >= 0; j--) {
+                    if (ticked.has(g.list[j].l)) {
+                        delete g.list[j];
+                    }
+                }
+                g.list = g.list.filter(v => v);
+                if (!g.list.length)
+                    delete newLibs.groups[i];
+                else {
+                    const item = lu.getListItemByLink(g.list, g.s);
+                    if (!item)
+                        g.s = g.list[0].l;
+                }
+            }
+
+            newLibs.groups = newLibs.groups.filter(v => v);
+            this.ticked = [];
+            this.selected = '';
+            this.$emit('do-action', {action: 'setLibs', data: newLibs});
+        }
+    }
+
+    moveBookmark(down = false) {
+        const newLibs = _.cloneDeep(this.libs);
+
+        const ticked = new Set(this.ticked);
+        let moved = false;
+        let prevFull = false;
+        if (!down) {
+            for (let i = 0; i < newLibs.groups.length; i++) {
+                const g = newLibs.groups[i];
+                let count = 0;
+                for (let j = 0; j < g.list.length; j++) {
+                    if (ticked.has(g.list[j].l)) {
+                        if (j > 0 && !ticked.has(g.list[j - 1].l)) {
+                            [g.list[j], g.list[j - 1]] = [g.list[j - 1], g.list[j]];
+                            moved = true;
+                        }
+                        count++;
+                    }
+                }
+
+                if (count == g.list.length && !prevFull && i > 0) {
+                    const gs = newLibs.groups;
+                    [gs[i], gs[i - 1]] = [gs[i - 1], gs[i]];
+                    moved = true;
+                } else
+                    prevFull = (count == g.list.length);
+            }
+        } else {
+            for (let i = newLibs.groups.length - 1; i >= 0; i--) {
+                const g = newLibs.groups[i];
+                let count = 0;
+                for (let j = g.list.length - 1; j >= 0; j--) {
+                    if (ticked.has(g.list[j].l)) {
+                        if (j < g.list.length - 1 && !ticked.has(g.list[j + 1].l)) {
+                            [g.list[j], g.list[j + 1]] = [g.list[j + 1], g.list[j]];
+                            moved = true;
+                        }
+                        count++;
+                    }
+                }
+
+                if (count == g.list.length && !prevFull && i < newLibs.groups.length - 1) {
+                    const gs = newLibs.groups;
+                    [gs[i], gs[i + 1]] = [gs[i + 1], gs[i]];
+                    moved = true;
+                } else
+                    prevFull = (count == g.list.length);
+            }
+        }
+
+        if (moved)
+            this.$emit('do-action', {action: 'setLibs', data: newLibs});
+    }
+
+    async setDefaultBookmarks() {
+        const result = await this.$root.stdDialog.prompt(`Введите 'да' для сброса всех закладок в предустановленные значения:`, ' ', {
+            inputValidator: (str) => { if (str && str.toLowerCase() === 'да') return true; else return 'Удаление не подтверждено'; },
+        });
+
+        if (result && result.value && result.value.toLowerCase() == 'да') {
+            this.$emit('do-action', {action: 'setLibs', data: _.cloneDeep(rstore.libsDefaults)});
+        }
+    }
+
     close() {
+        this.afterInit = false;
         this.$emit('close');
     }
 
     keyHook(event) {
+        if (this.addBookmarkVisible)
+            return false;
+
         if (event.type == 'keydown' && event.key == 'Escape') {
             this.close();
             return true;
@@ -56,4 +310,38 @@ class BookmarkSettings extends BookmarkSettingsProps {
 </script>
 
 <style scoped>
+.top-panel {
+    height: 50px;
+    border-bottom: 1px solid gray;
+    padding: 0 10px 0 12px;
+}
+
+.left-panel {
+    width: 60px;
+    height: 100%;
+    border-right: 1px solid gray;
+    overflow-x: hidden;
+    overflow-y: auto;
+}
+
+.tree {
+    padding: 0px 10px 10px 10px;
+    overflow-x: auto;
+    overflow-y: auto;
+}
+
+.selected {
+    text-shadow: 0 0 20px yellow, 0 0 15px yellow, 0 0 10px yellow, 0 0 10px yellow, 0 0 5px yellow;
+}
+
+.checkbox-tick-all {
+    border-bottom: 1px solid #bbbbbb;
+    margin-bottom: 7px;
+    padding: 5px 5px 2px 16px;
+}
+
+.space {
+    min-height: 1px;
+    width: 1px;
+}
 </style>

+ 248 - 106
client/components/ExternalLibs/ExternalLibs.vue

@@ -5,6 +5,10 @@
         </template>
 
         <template slot="buttons">
+            <span class="full-screen-button row justify-center items-center" @mousedown.stop @click="showHelp">
+                <q-icon name="la la-question-circle" size="16px"/>
+                <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">Справка</q-tooltip>
+            </span>
             <span class="full-screen-button row justify-center items-center" @mousedown.stop @click="fullScreenToggle">
                 <q-icon :name="(fullScreenActive ? 'la la-compress-arrows-alt': 'la la-expand-arrows-alt')" size="16px"/>
                 <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">На весь экран</q-tooltip>
@@ -23,12 +27,12 @@
                         <q-btn class="q-mr-xs" round dense color="blue" icon="la la-plus" @click.stop="addBookmark" size="12px">
                             <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">Добавить закладку</q-tooltip>
                         </q-btn>
-                        <q-btn round dense color="blue" icon="la la-bars" @click.stop="bookmarkSettings" size="12px" disabled>
+                        <q-btn round dense color="blue" icon="la la-bars" @click.stop="bookmarkSettings" size="12px">
                             <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">Настроить закладки</q-tooltip>
                         </q-btn>
                     </template>
                     <template v-slot:selected>
-                        <div style="overflow: hidden; white-space: nowrap;">{{ removeProtocol(rootLink) }}</div>
+                        <div style="overflow: hidden; white-space: nowrap;">{{ rootLinkWithoutProtocol }}</div>
                     </template>
                 </q-select>
 
@@ -44,16 +48,21 @@
                     @focus="selectAllOnFocus" @keydown="bookUrlKeyDown"
                 >
                     <template v-slot:prepend>
-                        <q-btn class="q-mr-xs" round dense color="blue" icon="la la-home" @click="goToLink(libs.startLink)" size="12px">
+                        <q-btn class="q-mr-xs" round dense color="blue" icon="la la-home" @click="goToLink(selectedLink)" size="12px">
                             <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">Вернуться на стартовую страницу</q-tooltip>
                         </q-btn>
-                        <q-btn round dense color="blue" icon="la la-angle-double-down" @click="openBookUrlInFrame" size="12px">
+                        <q-btn round dense color="blue" icon="la la-angle-double-down" @click="openBookUrlInFrame" size="12px" :disabled="!bookUrl">
                             <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">Загрузить URL во фрейм</q-tooltip>
                         </q-btn>
                     </template>
+                    <template v-slot:append>
+                        <q-btn round dense color="blue" icon="la la-cog" @click.stop="optionsVisible = true" size="12px">
+                            <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">Опции</q-tooltip>
+                        </q-btn>
+                    </template>
                 </q-input>
 
-                <q-btn rounded color="green-7" no-caps size="14px" @click="submitUrl">Открыть
+                <q-btn rounded color="green-7" no-caps size="14px" @click="submitUrl" :disabled="!bookUrl">Открыть
                     <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">Открыть в читалке</q-tooltip>
                 </q-btn>
             </div>
@@ -68,7 +77,8 @@
                 <template slot="header">
                     <div class="row items-center">
                         <q-icon class="q-mr-sm" name="la la-bookmark" size="28px"></q-icon>
-                        Добавить закладку
+                        <div v-if="addBookmarkMode == 'edit'">Редактировать закладку</div>
+                        <div v-else>Добавить закладку</div>
                     </div>
                 </template>
 
@@ -96,8 +106,30 @@
                     <q-btn class="q-px-md q-ml-sm" color="primary" dense no-caps @click="okAddBookmark" :disabled="!bookmarkLink">OK</q-btn>
                 </template>
             </Dialog>
+
+            <Dialog ref="options" v-model="optionsVisible">
+                <template slot="header">
+                    <div class="row items-center">
+                        <q-icon class="q-mr-sm" name="la la-cog" size="28px"></q-icon>
+                        Опции
+                    </div>
+                </template>
+
+                <div class="q-mx-md column">
+                    <q-checkbox v-model="closeAfterSubmit" size="36px" label="Закрыть окно при отправке ссылки в читалку" />
+                    <q-checkbox v-model="openInFrameOnEnter" size="36px" label="Открывать ссылку во фрейме при нажатии 'Enter'" />
+                    <q-checkbox v-model="openInFrameOnAdd" size="36px" label="Активировать новую закладку после добавления" />
+                </div>
+
+                <template slot="footer">
+                    <q-btn class="q-px-md q-ml-sm" color="primary" dense no-caps @click="optionsVisible = false">OK</q-btn>
+                </template>
+            </Dialog>
         </div>
-        <BookmarkSettings v-if="bookmarkSettingsActive" ref="bookmarkSettings" :libs="libs" @close="closeBookmarkSettings"></BookmarkSettings>
+
+        <BookmarkSettings v-if="bookmarkSettingsActive" ref="bookmarkSettings" :libs="libs" :addBookmarkVisible="addBookmarkVisible"
+            @do-action="doAction" @close="closeBookmarkSettings">
+        </BookmarkSettings>
     </Window>
 </template>
 
@@ -113,6 +145,7 @@ import BookmarkSettings from './BookmarkSettings/BookmarkSettings.vue';
 
 import rstore from '../../store/modules/reader';
 import * as utils from '../../share/utils';
+import * as lu from './linkUtils';
 
 const proxySubst = {
     'http://flibusta.is': 'http://b.liberama.top:23480',
@@ -128,44 +161,75 @@ export default @Component({
         libs: function() {
             this.loadLibs();
         },
-        rootLink: function() {
-            this.updateSelectedLink();
-            this.updateStartLink();
-        },
-        selectedLink: function() {
-            this.updateStartLink();
-        },
         defaultRootLink: function() {
             this.updateBookmarkLink();
-        }
+        },
+        bookUrl: function(newValue) {
+            const value = lu.addProtocol(newValue);
+            const subst = this.makeProxySubst(value, true);
+            if (value != subst) {
+                this.$nextTick(() => {
+                    this.bookUrl = subst;
+                });
+            }
+        },
+        bookmarkLink: function(newValue) {
+            const value = lu.addProtocol(newValue);
+            const subst = this.makeProxySubst(value, true);
+            if (value != subst) {
+                this.$nextTick(() => {
+                    this.bookmarkLink = subst;
+                });
+            }
+        },
+        closeAfterSubmit: function(newValue) {
+            this.commitProp('closeAfterSubmit', newValue);
+        },
+        openInFrameOnEnter: function(newValue) {
+            this.commitProp('openInFrameOnEnter', newValue);
+        },
+        openInFrameOnAdd: function(newValue) {
+            this.commitProp('openInFrameOnAdd', newValue);
+        },
     }    
 })
 class ExternalLibs extends Vue {
     ready = false;
     frameVisible = false;
-    startLink = '';
     rootLink = '';
     selectedLink = '';
     frameSrc = '';
     bookUrl = '';
     libs = {};
     fullScreenActive = false;
-    addBookmarkVisible = false;
     transparentLayoutVisible = false;
 
+    addBookmarkVisible = false;
+    optionsVisible = false;
+
+    addBookmarkMode = '';
     bookmarkLink = '';
     bookmarkDesc = '';
     defaultRootLink = '';
 
     bookmarkSettingsActive = false;
 
+    closeAfterSubmit = false;
+    openInFrameOnEnter = false;
+    openInFrameOnAdd = false;
+
     created() {
+        this.oldStartLink = '';
+        this.justOpened = true;
         this.$root.addKeyHook(this.keyHook);
 
         document.addEventListener('fullscreenchange', () => {
             this.fullScreenActive = (document.fullscreenElement !== null);
         });
 
+        this.debouncedGoToLink = _.debounce((link) => {
+            this.goToLink(link);
+        }, 100, {'maxWait':200});
         //this.commit = this.$store.commit;
         //this.commit('reader/setLibs', rstore.libsDefaults);
     }
@@ -258,8 +322,6 @@ class ExternalLibs extends Vue {
         } else if (d.type == 'libs') {
             this.ready = true;
             this.libs = _.cloneDeep(d.data);
-            if (!this.frameSrc)
-                this.goToLink(this.libs.startLink);
         } else if (d.type == 'notify') {
             this.$root.notify.success(d.data, '', {position: 'bottom-right'});
         }
@@ -284,11 +346,31 @@ class ExternalLibs extends Vue {
         this.sendMessage({type: 'libs', data: libs});
     }
 
+    commitProp(prop, value) {
+        let libs = _.cloneDeep(this.libs);
+        libs[prop] = value;
+        this.commitLibs(libs);
+    }
+
     loadLibs() {
         const libs = this.libs;
-        this.startLink = (libs.comment ? libs.comment + ' ': '') + this.removeProtocol(libs.startLink);
-        this.rootLink = this.getOrigin(libs.startLink);
-        this.updateSelectedLink();
+
+        this.selectedLink = libs.startLink;
+        this.closeAfterSubmit = libs.closeAfterSubmit || false;
+        this.openInFrameOnEnter = libs.openInFrameOnEnter || false;
+        this.openInFrameOnAdd = libs.openInFrameOnAdd || false;
+
+        this.updateStartLink();
+    }
+
+    doAction(event) {
+        switch (event.action) {
+            case 'setLibs': this.commitLibs(event.data); break;
+            case 'setRootLink': this.rootLink = event.data; this.rootLinkInput(); break;
+            case 'setSelectedLink': this.selectedLink = event.data; this.selectedLinkInput(); break;
+            case 'editBookmark': this.addBookmark('edit', event.data.link, event.data.desc); break;
+            case 'addBookmark': this.addBookmark('add'); break;
+        }
     }
 
     get mode() {
@@ -297,32 +379,60 @@ class ExternalLibs extends Vue {
 
     get header() {
         let result = (this.ready ? 'Библиотека' : 'Загрузка...');
-        if (this.ready && this.startLink) {
-            result += ` | ${this.startLink}`;
+        if (this.ready && this.selectedLink) {
+            result += ` | ${(this.libs.comment ? this.libs.comment + ' ': '') + lu.removeProtocol(this.libs.startLink)}`;
         }
         this.$root.$emit('set-app-title', result);
         return result;
     }
 
-    updateSelectedLink() {
+    get rootLinkWithoutProtocol() {
+        return lu.removeProtocol(this.rootLink);
+    }
+
+    updateSelectedLinkByRoot() {
         if (!this.ready)
             return;
-        const index = this.getRootIndexByUrl(this.libs.groups, this.rootLink);
+
+        const index = lu.getSafeRootIndexByUrl(this.libs.groups, this.rootLink);
         if (index >= 0)
             this.selectedLink = this.libs.groups[index].s;
+        else
+            this.selectedLink = '';
     }
 
-    updateStartLink() {
+    updateStartLink(force) {
         if (!this.ready)
             return;
-        const index = this.getRootIndexByUrl(this.libs.groups, this.rootLink);
+
+        let index = -1;
+        try {
+            this.rootLink = lu.getOrigin(this.selectedLink);
+            index = lu.getRootIndexByUrl(this.libs.groups, this.rootLink);
+        } catch(e) {
+            //
+        }
+
         if (index >= 0) {
             let libs = _.cloneDeep(this.libs);
-            libs.groups[index].s = this.selectedLink;
-            libs.startLink = this.selectedLink;
-            libs.comment = this.getCommentByLink(libs.groups[index].list, this.selectedLink);
-            this.goToLink(this.selectedLink);
-            this.commitLibs(libs);
+            const com = this.getCommentByLink(libs.groups[index].list, this.selectedLink);
+            if (libs.groups[index].s != this.selectedLink ||
+                libs.startLink != this.selectedLink ||
+                libs.comment != com) {
+                libs.groups[index].s = this.selectedLink;
+                libs.startLink = this.selectedLink;
+                libs.comment = com;
+                this.commitLibs(libs);
+            }
+
+            if (force || this.oldStartLink != libs.startLink) {
+                this.oldStartLink = libs.startLink;
+                this.debouncedGoToLink(this.selectedLink);
+            }
+        } else {
+            this.rootLink = '';
+            this.selectedLink = '';
+            this.debouncedGoToLink(this.selectedLink);
         }
     }
 
@@ -332,7 +442,7 @@ class ExternalLibs extends Vue {
             return result;
 
         this.libs.groups.forEach(group => {
-            result.push({label: this.removeProtocol(group.r), value: group.r});
+            result.push({label: lu.removeProtocol(group.r), value: group.r});
         });
 
         return result;
@@ -342,7 +452,7 @@ class ExternalLibs extends Vue {
         let result = [];
 
         rstore.libsDefaults.groups.forEach(group => {
-            result.push({label: this.removeProtocol(group.r), value: group.r});
+            result.push({label: lu.removeProtocol(group.r), value: group.r});
         });
 
         return result;
@@ -353,10 +463,10 @@ class ExternalLibs extends Vue {
         if (!this.ready)
             return result;
 
-        const index = this.getRootIndexByUrl(this.libs.groups, this.rootLink);
+        const index = lu.getSafeRootIndexByUrl(this.libs.groups, this.rootLink);
         if (index >= 0) {
             this.libs.groups[index].list.forEach(link => {
-                result.push({label: (link.c ? link.c + ' ': '') + this.removeOrigin(link.l), value: link.l});
+                result.push({label: (link.c ? link.c + ' ': '') + lu.removeOrigin(link.l), value: link.l});
             });
         }
 
@@ -365,66 +475,33 @@ class ExternalLibs extends Vue {
 
     openBookUrlInFrame() {
         if (this.bookUrl) {
-            this.goToLink(this.addProtocol(this.bookUrl));
+            this.goToLink(lu.addProtocol(this.bookUrl));
         }
     }
 
     goToLink(link) {
-        if (!this.ready)
+        if (!this.ready || !link)
+            return;
+
+        if (!link) {
+            this.frameVisible = false;
             return;
+        }
 
         this.frameSrc = this.makeProxySubst(link);
+
         this.frameVisible = false;
         this.$nextTick(() => {
             this.frameVisible = true;
             this.$nextTick(() => {
-                this.$refs.frame.contentWindow.focus();
+                if (this.$refs.frame)
+                    this.$refs.frame.contentWindow.focus();
             });
         });
     }
 
-    addProtocol(url) {
-        if ((url.indexOf('http://') != 0) && (url.indexOf('https://') != 0))
-            return 'http://' + url;
-        return url;
-    }
-
-    removeProtocol(url) {
-        return url.replace(/(^\w+:|^)\/\//, '');
-    }
-
-    getOrigin(url) {
-        const parsed = new URL(url);
-        return parsed.origin;
-    }
-
-    removeOrigin(url) {
-        const parsed = new URL(url);
-        const result = url.substring(parsed.origin.length);
-        return (result ? result : '/');
-    }
-
-    getRootIndexByUrl(groups, url) {
-        if (!this.ready)
-            return -1;
-        const origin = this.getOrigin(url);
-        for (let i = 0; i < groups.length; i++) {
-            if (groups[i].r == origin)
-                return i;
-        }
-        return -1;
-    }
-
-    getListItemByLink(list, link) {
-        for (const item of list) {
-            if (item.l == link)
-                return item;
-        }
-        return null;
-    }
-
     getCommentByLink(list, link) {
-        const item = this.getListItemByLink(list, link);
+        const item = lu.getListItemByLink(list, link);
         return (item ? item.c : '');
     }
 
@@ -432,7 +509,7 @@ class ExternalLibs extends Vue {
         for (const [key, value] of Object.entries(proxySubst)) {
             if (reverse && value == url.substring(0, value.length)) {
                 return key + url.substring(value.length);
-            } else if (key == url.substring(0, key.length)) {
+            } else if (!reverse && key == url.substring(0, key.length)) {
                 return value + url.substring(key.length);
             }
         }
@@ -446,29 +523,37 @@ class ExternalLibs extends Vue {
     }
 
     rootLinkInput() {
-        this.updateSelectedLink();
-        this.updateStartLink();
+        this.updateSelectedLinkByRoot();
+        this.updateStartLink(true);
     }
 
     selectedLinkInput() {
-        this.updateStartLink();
+        this.updateStartLink(true);
     }
 
     submitUrl() {
         if (this.bookUrl) {
             this.sendMessage({type: 'submitUrl', data: {
-                url: this.makeProxySubst(this.addProtocol(this.bookUrl), true), 
+                url: this.bookUrl,
                 force: true
             }});
             this.bookUrl = '';
-            if (this.libs.closeAfterSubmit)
+            if (this.closeAfterSubmit)
                 this.close();
         }
     }
 
-    addBookmark() {
-        this.bookmarkLink = (this.bookUrl ? this.makeProxySubst(this.addProtocol(this.bookUrl), true) : '');
-        this.bookmarkDesc = '';
+    addBookmark(mode = 'add', link = '', desc = '') {
+
+        if (mode == 'edit') {
+            this.editBookmarkLink = this.bookmarkLink = link;
+            this.editBookmarkDesc = this.bookmarkDesc = desc;
+        } else {
+            this.bookmarkLink = this.bookUrl;
+            this.bookmarkDesc = '';
+        }
+
+        this.addBookmarkMode = mode;
         this.addBookmarkVisible = true;
         this.$nextTick(() => {
             this.$refs.bookmarkLink.focus();
@@ -477,7 +562,7 @@ class ExternalLibs extends Vue {
     }
 
     updateBookmarkLink() {
-        const index = this.getRootIndexByUrl(rstore.libsDefaults.groups, this.defaultRootLink);
+        const index = lu.getSafeRootIndexByUrl(rstore.libsDefaults.groups, this.defaultRootLink);
         if (index >= 0) {
             this.bookmarkLink = rstore.libsDefaults.groups[index].s;
             this.bookmarkDesc = this.getCommentByLink(rstore.libsDefaults.groups[index].list, this.bookmarkLink);
@@ -500,8 +585,9 @@ class ExternalLibs extends Vue {
 
     bookmarkDescKeyDown(event) {
         if (event.key == 'Enter') {
-            this.okAddBookmark();
+            event.stopPropagation();
             event.preventDefault();
+            this.okAddBookmark();
         }
     }
 
@@ -509,46 +595,76 @@ class ExternalLibs extends Vue {
         if (!this.bookmarkLink)
             return;
 
-        const link = this.addProtocol(this.bookmarkLink);
+        const link = (this.addBookmarkMode == 'edit' ? lu.addProtocol(this.editBookmarkLink) : lu.addProtocol(this.bookmarkLink));
         let index = -1;
         try {
-            index = this.getRootIndexByUrl(this.libs.groups, link);
+            index = lu.getRootIndexByUrl(this.libs.groups, link);
         } catch (e) {
             await this.$root.stdDialog.alert('Неверный формат ссылки', 'Ошибка');
             return;
         }
 
+        let libs = _.cloneDeep(this.libs);
+
+        //добавление
         //есть группа в закладках
         if (index >= 0) {
-            const item = this.getListItemByLink(this.libs.groups[index].list, link);
-            
-            if (!item || item.c != this.bookmarkDesc) {
+            const item = lu.getListItemByLink(libs.groups[index].list, link);
+
+            //редактирование
+            if (item && this.addBookmarkMode == 'edit') {
+                if (item) {
+                    //редактируем
+                    item.l = link;
+                    item.c = this.bookmarkDesc;
+                    this.commitLibs(libs);
+                } else {
+                    await this.$root.stdDialog.alert('Не удалось отредактировать закладку', 'Ошибка');
+                }
+            } else if (!item) {
                 //добавляем
-                let libs = _.cloneDeep(this.libs);
-
                 if (libs.groups[index].list.length >= 100) {
                     await this.$root.stdDialog.alert('Достигнут предел количества закладок для этого сайта', 'Ошибка');
                     return;
                 }
 
                 libs.groups[index].list.push({l: link, c: this.bookmarkDesc});
+
+                if (this.openInFrameOnAdd) {
+                    libs.startLink = link;
+                    libs.comment = this.bookmarkDesc;
+                }
+
                 this.commitLibs(libs);
+            } else if (item.c != this.bookmarkDesc) {
+                if (await this.$root.stdDialog.confirm(`Такая закладка уже существует с другим описанием.<br>` +
+                    `Заменить '${this.$sanitize(item.c)}' на '${this.$sanitize(this.bookmarkDesc)}'?`, ' ')) {
+                    item.c = this.bookmarkDesc;
+                    this.commitLibs(libs);                    
+                } else 
+                    return;
+            } else {
+                await this.$root.stdDialog.alert('Такая закладка уже существует', ' ');
+                return;
             }
         } else {//нет группы в закладках
-            let libs = _.cloneDeep(this.libs);
-
             if (libs.groups.length >= 100) {
                 await this.$root.stdDialog.alert('Достигнут предел количества различных сайтов в закладках', 'Ошибка');
                 return;
             }
 
             //добавляем сначала группу
-            libs.groups.push({r: this.getOrigin(link), s: link, list: []});
+            libs.groups.push({r: lu.getOrigin(link), s: link, list: []});
             
-            index = this.getRootIndexByUrl(libs.groups, link);
+            index = lu.getSafeRootIndexByUrl(libs.groups, link);
             if (index >= 0)
                 libs.groups[index].list.push({l: link, c: this.bookmarkDesc});
 
+            if (this.openInFrameOnAdd) {
+                libs.startLink = link;
+                libs.comment = this.bookmarkDesc;
+            }
+
             this.commitLibs(libs);
         }
 
@@ -582,7 +698,12 @@ class ExternalLibs extends Vue {
 
     bookUrlKeyDown(event) {
         if (event.key == 'Enter') {
-            this.submitUrl();
+            if (!this.openInFrameOnEnter) {
+                this.submitUrl();
+            } else {
+                if (this.bookUrl)
+                    this.goToLink(this.bookUrl);
+            }
             event.preventDefault();
         }
     }
@@ -598,12 +719,34 @@ class ExternalLibs extends Vue {
         this.bookmarkSettingsActive = false;
     }
 
+    showHelp() {
+        this.$root.stdDialog.alert(`
+<p>Окно 'Библиотека' позволяет открывать ссылки в читалке без переключения между окнами,
+что особенно актуально для мобильных устройств.</p>
+
+<p>'Библиотека' разрешает свободный доступ к сайту flibusta.is. Имеется возможность управлять закладками
+на понравившиеся ресурсы, книги или страницы авторов. Открытие ссылок и навигация осуществляется во фрейме, но,
+к сожалению, в нем открываются не все страницы.
+</p>
+
+<p>Из-за проблем с безопасностью, навигация 'вперед-назад' во фрейме осуществляется с помощью контекстного меню правой кнопкой мыши.
+На мобильных устройствах для этого служит системная клавиша 'Назад (стрелка влево)' и опция 'Вперед (стрелка вправо)' в меню браузера. 
+</p>
+
+<p>Приятного пользования ;-)
+</p>
+            `, 'Справка', {iconName: 'la la-info-circle'});
+    }
+
     keyHook(event) {
         if (this.$root.rootRoute() == '/external-libs') {
+            if (this.$root.stdDialog.active)
+                return false;
+
             if (this.bookmarkSettingsActive && this.$refs.bookmarkSettings.keyHook(event))
                 return true;
 
-            if (this.$refs.dialogAddBookmark.active)
+            if (this.addBookmarkVisible || this.optionsVisible)
                 return false;
 
             if (event.type == 'keydown' && event.key == 'F4') {
@@ -621,7 +764,6 @@ class ExternalLibs extends Vue {
         }
         return false;
     }
-
 }
 //-----------------------------------------------------------------------------
 </script>

+ 48 - 0
client/components/ExternalLibs/linkUtils.js

@@ -0,0 +1,48 @@
+export function addProtocol(url) {
+    if ((url.indexOf('http://') != 0) && (url.indexOf('https://') != 0))
+        return 'http://' + url;
+    return url;
+}
+
+export function removeProtocol(url) {
+    return url.replace(/(^\w+:|^)\/\//, '');
+}
+
+export function getOrigin(url) {
+    const parsed = new URL(url);
+    return parsed.origin;
+}
+
+export function removeOrigin(url) {
+    const parsed = new URL(url);
+    const result = url.substring(parsed.origin.length);
+    return (result ? result : '/');
+}
+
+export function getRootIndexByUrl(groups, url) {
+    const origin = getOrigin(url);
+    for (let i = 0; i < groups.length; i++) {
+        if (groups[i].r == origin)
+            return i;
+    }
+    return -1;
+}
+
+export function getSafeRootIndexByUrl(groups, url) {
+    let index = -1;
+    try {
+        index = getRootIndexByUrl(groups, url);
+    } catch(e) {
+        //
+    }
+    return index;
+}
+
+export function getListItemByLink(list, link) {
+    for (const item of list) {
+        if (item.l == link)
+            return item;
+    }
+    return null;
+}
+

+ 1 - 1
client/components/Reader/SettingsPage/SettingsPage.vue

@@ -407,7 +407,7 @@ class SettingsPage extends Vue {
             return;
 
         try {
-            const result = await this.$root.stdDialog.prompt(`<b>Предупреждение!</b> Удаление профиля '${this.currentProfile}' необратимо.` +
+            const result = await this.$root.stdDialog.prompt(`<b>Предупреждение!</b> Удаление профиля '${this.$sanitize(this.currentProfile)}' необратимо.` +
                     `<br>Все настройки профиля будут потеряны, однако список читаемых книг сохранится.` +
                     `<br><br>Введите 'да' для подтверждения удаления:`, ' ', {
                 inputValidator: (str) => { if (str && str.toLowerCase() === 'да') return true; else return 'Удаление не подтверждено'; },

+ 1 - 1
client/components/Reader/SettingsPage/include/ProfilesTab.inc

@@ -22,7 +22,7 @@
             <q-select v-model="currentProfile" :options="currentProfileOptions"
                 style="width: 275px"
                 dropdown-icon="la la-angle-down la-sm"
-                outlined dense emit-value map-options
+                outlined dense emit-value map-options display-value-sanitize options-sanitize
             />
         </div>
     </div>

+ 14 - 5
client/components/share/StdDialog.vue

@@ -6,7 +6,7 @@
         <div v-show="type == 'alert'" class="bg-white no-wrap">
             <div class="header row">
                 <div class="caption col row items-center q-ml-md">
-                    <q-icon v-show="caption" class="q-mr-sm" :class="iconColor" name="las la-exclamation-circle" size="28px"></q-icon>
+                    <q-icon v-show="caption" class="q-mr-sm" :class="iconColor" :name="iconName" size="28px"></q-icon>
                     <div v-html="caption"></div>
                 </div>
                 <div class="close-icon column justify-center items-center">
@@ -29,7 +29,7 @@
         <div v-show="type == 'confirm'" class="bg-white no-wrap">
             <div class="header row">
                 <div class="caption col row items-center q-ml-md">
-                    <q-icon v-show="caption" class="q-mr-sm" :class="iconColor" name="las la-exclamation-circle" size="28px"></q-icon>
+                    <q-icon v-show="caption" class="q-mr-sm" :class="iconColor" :name="iconName" size="28px"></q-icon>
                     <div v-html="caption"></div>
                 </div>
                 <div class="close-icon column justify-center items-center">
@@ -53,7 +53,7 @@
         <div v-show="type == 'prompt'" class="bg-white no-wrap">
             <div class="header row">
                 <div class="caption col row items-center q-ml-md">
-                    <q-icon v-show="caption" class="q-mr-sm" :class="iconColor" name="las la-exclamation-circle" size="28px"></q-icon>
+                    <q-icon v-show="caption" class="q-mr-sm" :class="iconColor" :name="iconName" size="28px"></q-icon>
                     <div v-html="caption"></div>
                 </div>
                 <div class="close-icon column justify-center items-center">
@@ -79,7 +79,7 @@
         <div v-show="type == 'hotKey'" class="bg-white no-wrap">
             <div class="header row">
                 <div class="caption col row items-center q-ml-md">
-                    <q-icon v-show="caption" class="q-mr-sm" :class="iconColor" name="las la-exclamation-circle" size="28px"></q-icon>
+                    <q-icon v-show="caption" class="q-mr-sm" :class="iconColor" :name="iconName" size="28px"></q-icon>
                     <div v-html="caption"></div>
                 </div>
                 <div class="close-icon column justify-center items-center">
@@ -127,6 +127,7 @@ class StdDialog extends Vue {
     inputValue = '';
     error = '';
     iconColor = '';
+    iconName = '';
     hotKeyCode = '';
 
     created() {
@@ -144,12 +145,18 @@ class StdDialog extends Vue {
         this.inputValidator = null;
         this.inputValue = '';
         this.error = '';
+        this.showed = false;
 
         this.iconColor = 'text-warning';
         if (opts && opts.color) {
             this.iconColor = `text-${opts.color}`;
         }
 
+        this.iconName = 'las la-exclamation-circle';
+        if (opts && opts.iconName) {
+            this.iconName = opts.iconName;
+        }
+
         this.hotKeyCode = '';
         if (opts && opts.hotKeyCode) {
             this.hotKeyCode = opts.hotKeyCode;
@@ -161,6 +168,7 @@ class StdDialog extends Vue {
             this.hideTrigger();
             this.hideTrigger = null;
         }
+        this.showed = false;
     }
 
     onShow() {
@@ -170,6 +178,7 @@ class StdDialog extends Vue {
                 this.validate(this.inputValue);
             this.$refs.input.focus();
         }
+        this.showed = true;
     }
 
     validate(value) {
@@ -276,7 +285,7 @@ class StdDialog extends Vue {
     }
 
     keyHook(event) {
-        if (this.active) {
+        if (this.active && this.showed) {
             let handled = false;
             if (this.type == 'hotKey') {
                 if (event.type == 'keydown') {

+ 3 - 1
client/components/share/Window.vue

@@ -32,6 +32,9 @@ export default @Component({
 class Window extends Vue {
     init() {
         this.$nextTick(() => {
+            this.$refs.main.style.top = 0;
+            this.$refs.main.style.left = 0;
+
             this.$refs.windowBox.style.height = this.height;
             this.$refs.windowBox.style.width = this.width;
             if (this.maxWidth)
@@ -124,7 +127,6 @@ class Window extends Vue {
 .main {
     background-color: transparent !important;
     z-index: 50;
-    overflow: hidden;
 }
 
 .xyfit {

+ 3 - 0
client/main.js

@@ -4,6 +4,9 @@ import router from './router';
 import store from './store';
 import './quasar';
 
+import vueSanitize from 'vue-sanitize';
+Vue.use(vueSanitize);
+
 import App from './components/App.vue';
 //Vue.config.productionTip = false;
 Vue.prototype.$isMobileDevice = /Android|webOS|iPhone|iPad|iPod|BlackBerry/i.test(navigator.userAgent);

+ 2 - 0
client/quasar.js

@@ -31,6 +31,7 @@ import {QColor} from 'quasar/src/components/color';
 import {QPopupProxy} from 'quasar/src/components/popup-proxy';
 import {QDialog} from 'quasar/src/components/dialog';
 import {QChip} from 'quasar/src/components/chip';
+import {QTree} from 'quasar/src/components/tree';
 
 const components = {
     //QLayout,
@@ -57,6 +58,7 @@ const components = {
     QPopupProxy,
     QDialog,
     QChip,
+    QTree
 };
 
 //directives 

+ 2 - 0
client/store/modules/reader.js

@@ -281,6 +281,8 @@ const libsDefaults = {
     startLink: 'http://flibusta.is',
     comment: 'Флибуста | Книжное братство',
     closeAfterSubmit: false,
+    openInFrameOnEnter: false,
+    openInFrameOnAdd: false,
     groups: [
         {r: 'http://flibusta.is', s: 'http://flibusta.is', list: [
             {l: 'http://flibusta.is', c: 'Флибуста | Книжное братство'},

+ 146 - 27
package-lock.json

@@ -36,12 +36,6 @@
             "pkg-up": "^2.0.0"
           }
         },
-        "caniuse-lite": {
-          "version": "1.0.30001062",
-          "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001062.tgz",
-          "integrity": "sha512-ei9ZqeOnN7edDrb24QfJ0OZicpEbsWxv7WusOiQGz/f2SfvBgHHbOEwBJ8HKGVSyx8Z6ndPjxzR6m0NQq+0bfw==",
-          "dev": true
-        },
         "electron-to-chromium": {
           "version": "1.3.446",
           "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.446.tgz",
@@ -321,12 +315,6 @@
             "pkg-up": "^2.0.0"
           }
         },
-        "caniuse-lite": {
-          "version": "1.0.30001062",
-          "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001062.tgz",
-          "integrity": "sha512-ei9ZqeOnN7edDrb24QfJ0OZicpEbsWxv7WusOiQGz/f2SfvBgHHbOEwBJ8HKGVSyx8Z6ndPjxzR6m0NQq+0bfw==",
-          "dev": true
-        },
         "electron-to-chromium": {
           "version": "1.3.446",
           "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.446.tgz",
@@ -1603,12 +1591,6 @@
             "pkg-up": "^2.0.0"
           }
         },
-        "caniuse-lite": {
-          "version": "1.0.30001062",
-          "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001062.tgz",
-          "integrity": "sha512-ei9ZqeOnN7edDrb24QfJ0OZicpEbsWxv7WusOiQGz/f2SfvBgHHbOEwBJ8HKGVSyx8Z6ndPjxzR6m0NQq+0bfw==",
-          "dev": true
-        },
         "electron-to-chromium": {
           "version": "1.3.446",
           "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.446.tgz",
@@ -4017,9 +3999,9 @@
       }
     },
     "caniuse-lite": {
-      "version": "1.0.30000989",
-      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30000989.tgz",
-      "integrity": "sha512-vrMcvSuMz16YY6GSVZ0dWDTJP8jqk3iFQ/Aq5iqblPwxSVVZI+zxDyTX0VPqtQsDnfdrBDcsmhgTEOh5R8Lbpw==",
+      "version": "1.0.30001154",
+      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001154.tgz",
+      "integrity": "sha512-y9DvdSti8NnYB9Be92ddMZQrcOe04kcQtcxtBx4NkB04+qZ+JUWotnXBJTmxlKudhxNTQ3RRknMwNU2YQl/Org==",
       "dev": true
     },
     "caseless": {
@@ -4373,6 +4355,11 @@
         "simple-swizzle": "^0.2.2"
       }
     },
+    "colorette": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.2.1.tgz",
+      "integrity": "sha512-puCDz0CzydiSYOrnXpz/PKd69zRrribezjtE9yd4zvytoRc8+RY/KJPvtPFKZS3E3wP6neGyMe0vOTlHO5L3Pw=="
+    },
     "combined-stream": {
       "version": "1.0.8",
       "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -4759,12 +4746,6 @@
             "pkg-up": "^2.0.0"
           }
         },
-        "caniuse-lite": {
-          "version": "1.0.30001062",
-          "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001062.tgz",
-          "integrity": "sha512-ei9ZqeOnN7edDrb24QfJ0OZicpEbsWxv7WusOiQGz/f2SfvBgHHbOEwBJ8HKGVSyx8Z6ndPjxzR6m0NQq+0bfw==",
-          "dev": true
-        },
         "electron-to-chromium": {
           "version": "1.3.446",
           "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.446.tgz",
@@ -8696,6 +8677,11 @@
       "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
       "dev": true
     },
+    "klona": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/klona/-/klona-2.0.4.tgz",
+      "integrity": "sha512-ZRbnvdg/NxqzC7L9Uyqzf4psi1OM4Cuc+sJAkQPjO6XkQIJTNbfK2Rsmbw8fx1p2mkZdp2FZYo2+LwXYY/uwIA=="
+    },
     "last-call-webpack-plugin": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/last-call-webpack-plugin/-/last-call-webpack-plugin-3.0.0.tgz",
@@ -8790,6 +8776,30 @@
         "immediate": "~3.0.5"
       }
     },
+    "line-column": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/line-column/-/line-column-1.0.2.tgz",
+      "integrity": "sha1-0lryk2tvSEkXKzEuR5LR2Ye8NKI=",
+      "requires": {
+        "isarray": "^1.0.0",
+        "isobject": "^2.0.0"
+      },
+      "dependencies": {
+        "isarray": {
+          "version": "1.0.0",
+          "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+          "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE="
+        },
+        "isobject": {
+          "version": "2.1.0",
+          "resolved": "https://registry.npmjs.org/isobject/-/isobject-2.1.0.tgz",
+          "integrity": "sha1-8GVWEJaj8dou9GJy+BXIQNh+DIk=",
+          "requires": {
+            "isarray": "1.0.0"
+          }
+        }
+      }
+    },
     "loader-runner": {
       "version": "2.4.0",
       "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-2.4.0.tgz",
@@ -9372,6 +9382,11 @@
       "resolved": "https://registry.npmjs.org/nan/-/nan-2.14.0.tgz",
       "integrity": "sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg=="
     },
+    "nanoid": {
+      "version": "3.1.16",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.16.tgz",
+      "integrity": "sha512-+AK8MN0WHji40lj8AEuwLOvLSbWYApQpre/aFJZD71r43wVRLrOYS4FmJOPQYon1TqB462RzrrxlfA74XRES8w=="
+    },
     "nanomatch": {
       "version": "1.2.13",
       "resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz",
@@ -10010,6 +10025,11 @@
       "integrity": "sha1-bVuTSkVpk7I9N/QKOC1vFmao5cY=",
       "dev": true
     },
+    "parse-srcset": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
+      "integrity": "sha1-8r0iH2zJcKk42IVWq8WJyqqiveE="
+    },
     "parseurl": {
       "version": "1.3.3",
       "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -13306,6 +13326,97 @@
       "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
       "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
     },
+    "sanitize-html": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.1.1.tgz",
+      "integrity": "sha512-yb+I+K25cn6PzsRJb+kGdlOBC3SkvEwJrr/Xl+6YR42oDZDeu61yWeEMSEi0hCKlRSYlWWE4tUnF2Ds+c/M1Vg==",
+      "requires": {
+        "deepmerge": "^4.2.2",
+        "escape-string-regexp": "^4.0.0",
+        "htmlparser2": "^4.1.0",
+        "is-plain-object": "^5.0.0",
+        "klona": "^2.0.3",
+        "parse-srcset": "^1.0.2",
+        "postcss": "^8.0.2"
+      },
+      "dependencies": {
+        "dom-serializer": {
+          "version": "1.1.0",
+          "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.1.0.tgz",
+          "integrity": "sha512-ox7bvGXt2n+uLWtCRLybYx60IrOlWL/aCebWJk1T0d4m3y2tzf4U3ij9wBMUb6YJZpz06HCCYuyCDveE2xXmzQ==",
+          "requires": {
+            "domelementtype": "^2.0.1",
+            "domhandler": "^3.0.0",
+            "entities": "^2.0.0"
+          }
+        },
+        "domelementtype": {
+          "version": "2.0.2",
+          "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.0.2.tgz",
+          "integrity": "sha512-wFwTwCVebUrMgGeAwRL/NhZtHAUyT9n9yg4IMDwf10+6iCMxSkVq9MGCVEH+QZWo1nNidy8kNvwmv4zWHDTqvA=="
+        },
+        "domhandler": {
+          "version": "3.3.0",
+          "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-3.3.0.tgz",
+          "integrity": "sha512-J1C5rIANUbuYK+FuFL98650rihynUOEzRLxW+90bKZRWB6A1X1Tf82GxR1qAWLyfNPRvjqfip3Q5tdYlmAa9lA==",
+          "requires": {
+            "domelementtype": "^2.0.1"
+          }
+        },
+        "domutils": {
+          "version": "2.4.2",
+          "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.4.2.tgz",
+          "integrity": "sha512-NKbgaM8ZJOecTZsIzW5gSuplsX2IWW2mIK7xVr8hTQF2v1CJWTmLZ1HOCh5sH+IzVPAGE5IucooOkvwBRAdowA==",
+          "requires": {
+            "dom-serializer": "^1.0.1",
+            "domelementtype": "^2.0.1",
+            "domhandler": "^3.3.0"
+          }
+        },
+        "entities": {
+          "version": "2.1.0",
+          "resolved": "https://registry.npmjs.org/entities/-/entities-2.1.0.tgz",
+          "integrity": "sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w=="
+        },
+        "escape-string-regexp": {
+          "version": "4.0.0",
+          "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+          "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="
+        },
+        "htmlparser2": {
+          "version": "4.1.0",
+          "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-4.1.0.tgz",
+          "integrity": "sha512-4zDq1a1zhE4gQso/c5LP1OtrhYTncXNSpvJYtWJBtXAETPlMfi3IFNjGuQbYLuVY4ZR0QMqRVvo4Pdy9KLyP8Q==",
+          "requires": {
+            "domelementtype": "^2.0.1",
+            "domhandler": "^3.0.0",
+            "domutils": "^2.0.0",
+            "entities": "^2.0.0"
+          }
+        },
+        "is-plain-object": {
+          "version": "5.0.0",
+          "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
+          "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q=="
+        },
+        "postcss": {
+          "version": "8.1.4",
+          "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.1.4.tgz",
+          "integrity": "sha512-LfqcwgMq9LOd8pX7K2+r2HPitlIGC5p6PoZhVELlqhh2YGDVcXKpkCseqan73Hrdik6nBd2OvoDPUaP/oMj9hQ==",
+          "requires": {
+            "colorette": "^1.2.1",
+            "line-column": "^1.0.2",
+            "nanoid": "^3.1.15",
+            "source-map": "^0.6.1"
+          }
+        },
+        "source-map": {
+          "version": "0.6.1",
+          "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+          "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="
+        }
+      }
+    },
     "sax": {
       "version": "1.2.4",
       "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz",
@@ -15193,6 +15304,14 @@
       "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-3.1.6.tgz",
       "integrity": "sha512-GYhn2ynaZlysZMkFE5oCHRUTqE8BWs/a9YbKpNLi0i7xD6KG1EzDqpHQmv1F5gXjr8kL5iIVS8EOtRaVUEXTqA=="
     },
+    "vue-sanitize": {
+      "version": "0.2.1",
+      "resolved": "https://registry.npmjs.org/vue-sanitize/-/vue-sanitize-0.2.1.tgz",
+      "integrity": "sha512-MhrfAaAqIQ8JXWfdlhK6f+H2WnZ7KQwkOgLNeUyfACI2bq/3HPZ2zeUQDathnLdsXMXAa8pJehOgPYtlaG9Jew==",
+      "requires": {
+        "sanitize-html": "^2.1.1"
+      }
+    },
     "vue-style-loader": {
       "version": "4.1.2",
       "resolved": "https://registry.npmjs.org/vue-style-loader/-/vue-style-loader-4.1.2.tgz",

+ 1 - 0
package.json

@@ -81,6 +81,7 @@
     "unbzip2-stream": "^1.3.3",
     "vue": "github:bookpauk/vue",
     "vue-router": "^3.1.6",
+    "vue-sanitize": "^0.2.1",
     "vuex": "^3.1.2",
     "vuex-persistedstate": "^2.7.1",
     "webdav": "^2.10.2",