Parcourir la source

Merge branch 'release/1.0.0'

Book Pauk il y a 2 ans
Parent
commit
b98a44def2
100 fichiers modifiés avec 3902 ajouts et 3005 suppressions
  1. 5 5
      .gitignore
  2. 136 23
      README.md
  3. 0 31
      build/includer.js
  4. 0 51
      build/linux.js
  5. 51 0
      build/prepkg.js
  6. 33 0
      build/release.js
  7. 0 4
      build/webpack.base.config.js
  8. 3 3
      build/webpack.dev.config.js
  9. 1 2
      build/webpack.prod.config.js
  10. 0 45
      build/win.js
  11. 4 16
      client/api/misc.js
  12. 22 66
      client/api/reader.js
  13. 6 23
      client/components/App.vue
  14. 0 19
      client/components/CardIndex/Book/Book.vue
  15. 0 19
      client/components/CardIndex/Card/Card.vue
  16. 0 93
      client/components/CardIndex/CardIndex.vue
  17. 0 19
      client/components/CardIndex/History/History.vue
  18. 0 19
      client/components/CardIndex/Search/Search.vue
  19. 1 0
      client/components/ExternalLibs/BookmarkSettings/BookmarkSettings.vue
  20. 79 23
      client/components/ExternalLibs/ExternalLibs.vue
  21. 0 19
      client/components/Help/Help.vue
  22. 0 19
      client/components/Income/Income.vue
  23. 0 19
      client/components/NotFound404/NotFound404.vue
  24. 1 1
      client/components/Reader/HelpPage/CommonHelpPage/CommonHelpPage.vue
  25. BIN
      client/components/Reader/HelpPage/DonateHelpPage/assets/bitcoin.png
  26. BIN
      client/components/Reader/HelpPage/DonateHelpPage/assets/litecoin.png
  27. BIN
      client/components/Reader/HelpPage/DonateHelpPage/assets/monero.png
  28. 1 1
      client/components/Reader/HelpPage/HotkeysHelpPage/HotkeysHelpPage.vue
  29. 1 1
      client/components/Reader/HelpPage/MouseHelpPage/MouseHelpPage.vue
  30. 9 4
      client/components/Reader/LibsPage/LibsPage.vue
  31. 2 23
      client/components/Reader/LoaderPage/LoaderPage.vue
  32. 1 1
      client/components/Reader/LoaderPage/PasteTextPage/PasteTextPage.vue
  33. 149 156
      client/components/Reader/Reader.vue
  34. 30 23
      client/components/Reader/ReaderDialogs/ReaderDialogs.vue
  35. 2 2
      client/components/Reader/RecentBooksPage/RecentBooksPage.vue
  36. 12 5
      client/components/Reader/SearchPage/SearchPage.vue
  37. 8 1
      client/components/Reader/ServerStorage/ServerStorage.vue
  38. 0 87
      client/components/Reader/SettingsPage/ConvertTab.inc
  39. 145 0
      client/components/Reader/SettingsPage/ConvertTab/ConvertTab.vue
  40. 0 33
      client/components/Reader/SettingsPage/KeysTab.inc
  41. 78 0
      client/components/Reader/SettingsPage/KeysTab/KeysTab.vue
  42. 3 4
      client/components/Reader/SettingsPage/KeysTab/UserHotKeys/UserHotKeys.vue
  43. 0 91
      client/components/Reader/SettingsPage/OthersTab.inc
  44. 148 0
      client/components/Reader/SettingsPage/OthersTab/OthersTab.vue
  45. 0 28
      client/components/Reader/SettingsPage/PageMoveTab.inc
  46. 96 0
      client/components/Reader/SettingsPage/PageMoveTab/PageMoveTab.vue
  47. 0 101
      client/components/Reader/SettingsPage/ProfilesTab.inc
  48. 362 0
      client/components/Reader/SettingsPage/ProfilesTab/ProfilesTab.vue
  49. 0 3
      client/components/Reader/SettingsPage/ResetTab.inc
  50. 41 0
      client/components/Reader/SettingsPage/ResetTab/ResetTab.vue
  51. 89 649
      client/components/Reader/SettingsPage/SettingsPage.vue
  52. 0 18
      client/components/Reader/SettingsPage/ToolBarTab.inc
  53. 76 0
      client/components/Reader/SettingsPage/ToolBarTab/ToolBarTab.vue
  54. 0 76
      client/components/Reader/SettingsPage/UpdateTab.inc
  55. 122 0
      client/components/Reader/SettingsPage/UpdateTab/UpdateTab.vue
  56. 0 121
      client/components/Reader/SettingsPage/ViewTab/Color.inc
  57. 329 0
      client/components/Reader/SettingsPage/ViewTab/Color/Color.vue
  58. 0 56
      client/components/Reader/SettingsPage/ViewTab/Font.inc
  59. 176 0
      client/components/Reader/SettingsPage/ViewTab/Font/Font.vue
  60. 0 124
      client/components/Reader/SettingsPage/ViewTab/Mode.inc
  61. 229 0
      client/components/Reader/SettingsPage/ViewTab/Mode/Mode.vue
  62. 0 64
      client/components/Reader/SettingsPage/ViewTab/Status.inc
  63. 153 0
      client/components/Reader/SettingsPage/ViewTab/Status/Status.vue
  64. 0 127
      client/components/Reader/SettingsPage/ViewTab/Text.inc
  65. 210 0
      client/components/Reader/SettingsPage/ViewTab/Text/Text.vue
  66. 75 0
      client/components/Reader/SettingsPage/ViewTab/ViewTab.vue
  67. 29 1
      client/components/Reader/SettingsPage/ViewTab/defPalette.js
  68. 9 0
      client/components/Reader/SettingsPage/ViewTab/helper.js
  69. 36 72
      client/components/Reader/TextPage/TextPage.vue
  70. 16 0
      client/components/Reader/versionHistory.js
  71. 0 19
      client/components/Settings/Settings.vue
  72. 0 19
      client/components/Sources/Sources.vue
  73. 0 1
      client/components/share/Notify.vue
  74. 94 24
      client/components/share/NumInput.vue
  75. 3 1
      client/components/share/Window.vue
  76. 24 15
      client/components/vueComponent.js
  77. 2 27
      client/router.js
  78. 5 18
      client/share/utils.js
  79. 64 37
      client/store/modules/reader.js
  80. 14 10
      docs/beta/beta.liberama
  81. 66 10
      docs/beta/beta.omnireader
  82. 15 11
      docs/beta/beta.omnireader_http
  83. 28 20
      docs/liberama.top/liberama
  84. 7 7
      docs/omnireader.ru/README.md
  85. 63 8
      docs/omnireader.ru/omnireader
  86. 15 11
      docs/omnireader.ru/omnireader_http
  87. 259 269
      package-lock.json
  88. 43 37
      package.json
  89. 6 11
      server/config/base.js
  90. 33 12
      server/config/index.js
  91. 3 8
      server/config/production.js
  92. 18 0
      server/controllers/WebSocketController.js
  93. 4 0
      server/core/AppLogger.js
  94. 6 2
      server/core/AsyncExit.js
  95. 5 2
      server/core/FileDownloader.js
  96. 24 10
      server/core/Logger.js
  97. 55 14
      server/core/Reader/JembaReaderStorage.js
  98. 6 9
      server/core/Reader/ReaderWorker.js
  99. 59 0
      server/core/Zip/ZipReader.js
  100. 2 2
      server/core/Zip/ZipStreamer.js

+ 5 - 5
.gitignore

@@ -1,5 +1,5 @@
-/node_modules
-/server/data
-/server/public
-/server/ipfs
-/dist
+/node_modules
+/server/.liberama*
+/dist
+dev*.sh
+

+ 136 - 23
README.md

@@ -1,43 +1,156 @@
 # Liberama
 
-Браузерная онлайн-читалка книг и децентрализованная библиотека.
+Браузерная онлайн-читалка книг.
 
-Читалка <img src="https://omnireader.ru/favicon.ico" width="14px"/>[OmniReader](https://omnireader.ru) является частью данного проекта, размещенной на VPS:
+Выглядит соледующим образом: <img src="https://omnireader.ru/favicon.ico" width="14px"/>[OmniReader](https://omnireader.ru)
 
 ![](docs/assets/face.jpg)
 ![](docs/assets/reader.jpg)
 
-## VPS
-Для разворачивания читалки на чистом VPS с нуля смотрите [docs/omnireader.ru](docs/omnireader.ru/README.md)
+При запуске приложения, по умолчанию веб-сервер доступен по адресу [http://127.0.0.1:44080](http://127.0.0.1:44080)
 
-## Сборка проекта
-Необходима версия node.js не ниже 14.
+Для указания местоположения рабочей директории, воспользуйтесь [параметрами командной строки](#cli).
+Дополнительные параметры сервера настраиваются в [конфигурационном файле](#config).
 
-```
-$ git clone https://github.com/bookpauk/liberama
-$ cd liberama
-$ npm i
-```
+[Отблагодарить автора проекта](https://donatty.com/liberama)
+
+## 
+* [Возможности читалки](#capabilities)
+* [Использование](#usage)
+    * [Параметры командной строки](#cli)
+    * [Конфигурация](#config)
+    * [Разворачивание на VPS](#vps)
+* [Сборка проекта](#build)
+* [Разработка](#development)
+
+<a id="capabilities" />
+
+## Возможности читалки
+- загрузка любой страницы интернета
+- синхронизация данных (настроек и читаемых книг) между различными устройствами
+- работа в автономном режиме (без связи)
+- изменение цвета фона, текста, размер и тип шрифта и прочее
+- установка и запоминание текущей позиции и настроек в браузере и на сервере
+- кэширование файлов книг на клиенте и на сервере
+- открытие книг с локального диска
+- плавный скроллинг текста
+- анимация перелистывания
+- поиск по тексту и копирование фрагмента
+- запоминание недавних книг, скачивание книги из читалки в формате fb2
+- управление кликом и с клавиатуры
+- регистрация не требуется
+- поддерживаемые браузеры: Google Chrome, Mozilla Firefox последних версий
+- релизы сервера под Linux, MacOS и Windows
+
+<a id="usage" />
+
+## Использование
+Приложение представляет собой полноценный веб-сервер в виде единого исполнимого файла.
+При первом запуске, будет создана рабочая директория `.liberama` (по умолчанию - в той же папке, где исполнимый файл),
+в которой хранится конфигурационный файл `config.json`, файлы веб-приложения, файлы базы данных, журналы и прочее.
+Изменить рабочую директорию можно с помощью cli-параметра --app-dir
 
-### Windows
+По умолчанию веб-интерфейс будет доступен по адресу [http://127.0.0.1:44080](http://127.0.0.1:44080)
+
+<a id="cli" />
+
+### Параметры командной строки
+Запустите `liberama --help`, чтобы увидеть список опций:
+```console
+Usage: liberama [options]
+
+Options:
+  --help              Показать опции командной строки
+  --app-dir=<dirpath> Задать рабочую директорию, по умолчанию: <execDir>/.liberama
+  --auto-repair       Починить БД приложения при запуске, если она повреждена
 ```
-$ npm run build:win
+
+<a id="config" />
+
+### Конфигурация
+При первом запуске в рабочей директории будет создан конфигурационный файл `config.json`:
+```js
+{
+    // Максимальный размер файла загружаемой книги (в байтах)
+    "maxUploadFileSize": 52428800,
+
+    // Максимальный размер каталога <appDir>/public-files/tmp для хранения конвертированных
+    // файлов книг пользователей (в байтах)
+    "maxTempPublicDirSize": 536870912,
+
+    // Максимальный размер каталога <appDir>/public-files/upload для хранения
+    // загруженных в /upload (кнопка "Загрузить файл с диска") файлов книг пользователей (в байтах)
+    "maxUploadPublicDirSize": 209715200,
+
+    // Использование внешних конвертеров (только в среде Linux)
+    // Без них читалка может работать только с файлами формата fb2, txt, html, xml
+    // Инструкции установки внешних конвертеров см. в docs/omnireader.ru/README.md
+    "useExternalBookConverter": false,
+
+    // Настройки для списка серверов.
+    // Приложение может запускать одновременно несколько веб-серверов на разных портах
+    "servers": [
+        {
+            // Произвольное название сервера
+            "serverName": "1",
+
+            // Режим работы сервера:
+            //  "reader" - обычная читалка
+            //  "omnireader" - модификации для сайта omnireader.ru
+            //  "liberama" - модификации для сайта liberama.top
+            //  "book_update_checker" - сервер обновлений
+            "mode": "reader",
+
+            // Хост, порт сервера
+            "ip": "0.0.0.0",
+            "port": "44080"
+        }
+    ],
+
+    // Настройки удаленного хранилища
+    "remoteStorage": false,
+
+    // Для веб-приложения: включение/выключение работы с сервером обновлений
+    "bucEnabled": false,
+
+    // Подключение себя, как клиента, к серверу обновлений
+    "bucServer": false
+}
 ```
 
-### Linux
+При необходимости, можно настроить нужный параметр в этом файле вручную.
+
+<a id="vps" />
+
+## VPS
+Для разворачивания читалки на чистом VPS с нуля смотрите [docs/omnireader.ru](docs/omnireader.ru/README.md)
+
+<a id="build" />
+
+### Сборка проекта
+Сборка только в среде Linux.
+Необходима версия node.js не ниже 16.
+
+Для сборки linux-arm64 необходимо предварительно установить [QEMU](https://wiki.debian.org/QemuUserEmulation).
+
+```sh
+git clone https://github.com/bookpauk/liberama
+cd liberama
+npm i
 ```
-$ npm run build:linux
+
+#### Релизы
+```sh
+npm run release
 ```
 
-Результат сборки будет доступен в каталоге `dist/linux|win` в виде исполнимого (standalone) файла
+Результат сборки будет доступен в каталоге `dist/release`
+
+<a id="development" />
 
 ### Разработка
+```sh
+npm run dev
 ```
-$ npm run dev
-```
-
-## Помочь проекту
 
-* bitcoin: bc1q3tyumaj648pp2e69jalsez2lnt462ttc33nup9
-* litecoin: MP39Riec4oSNB3XMjiquKoLWxbufRYNXxZ
-* monero: 8BQPnvHcPSHM5gMQsmuypDgx9NNsYqwXKfDDuswEyF2Q2ewQSfd2pkK6ydH2wmMyq2JViZvy9DQ35hLMx7g72mFWNJTPtnz
+Связаться с автором проекта: [bookpauk@gmail.com](mailto:bookpauk@gmail.com)

+ 0 - 31
build/includer.js

@@ -1,31 +0,0 @@
-const path = require('path');
-const fs = require('fs');
-
-//пример в коде:
-//  @@include('./test/testFile.inc');
-
-function includeRecursive(self, parentFile, source, depth) {
-    depth = (depth ? depth : 0);
-    if (depth > 50)
-        throw new Error('includer: stack too big');
-    const lines = source.split('\n');
-    let result = [];
-    for (const line of lines) {
-        const trimmed = line.trim();
-        const m = trimmed.match(/^@@[\s]*?include[\s]*?\(['"](.*)['"]\)/);
-        if (m) {
-            const includedFile = path.resolve(path.dirname(parentFile), m[1]);
-            self.addDependency(includedFile);
-
-            const fileContent = fs.readFileSync(includedFile, 'utf8');
-            result = result.concat(includeRecursive(self, includedFile, fileContent, depth + 1));
-        } else {
-            result.push(line);
-        }
-    }
-    return result;
-}
-
-exports.default = function includer(source) {
-    return includeRecursive(this, this.resourcePath, source).join('\n');
-}

+ 0 - 51
build/linux.js

@@ -1,51 +0,0 @@
-const fs = require('fs-extra');
-const path = require('path');
-const util = require('util');
-const stream = require('stream');
-const pipeline = util.promisify(stream.pipeline);
-
-const axios = require('axios');
-const FileDecompressor = require('../server/core/FileDecompressor');
-
-const distDir = path.resolve(__dirname, '../dist');
-const publicDir = `${distDir}/tmp/public`;
-const outDir = `${distDir}/linux`;
-
-const tempDownloadDir = `${distDir}/tmp/download`;
-
-async function main() {
-    const decomp = new FileDecompressor();
-
-    await fs.emptyDir(outDir);
-    // перемещаем public на место
-    if (await fs.pathExists(publicDir))
-        await fs.move(publicDir, `${outDir}/public`);
-
-    await fs.ensureDir(tempDownloadDir);
-
-    //ipfs
-    const ipfsDecompressedFilename = `${tempDownloadDir}/go-ipfs/ipfs`;
-    if (!await fs.pathExists(ipfsDecompressedFilename)) {
-        // Скачиваем ipfs
-        const ipfsRemoteUrl = 'https://dist.ipfs.io/go-ipfs/v0.4.18/go-ipfs_v0.4.18_linux-amd64.tar.gz';
-
-        const res = await axios.get(ipfsRemoteUrl, {responseType: 'stream'})
-        await pipeline(res.data, fs.createWriteStream(`${tempDownloadDir}/ipfs.tar.gz`));
-        console.log(`done downloading ${ipfsRemoteUrl}`);
-
-        //распаковываем
-        console.log(await decomp.unpackTarZZ(`${tempDownloadDir}/ipfs.tar.gz`, tempDownloadDir));
-        console.log('files decompressed');
-    }
-
-    // копируем в дистрибутив
-    await fs.copy(ipfsDecompressedFilename, `${outDir}/ipfs`);
-    console.log(`copied ${tempDownloadDir}/go-ipfs/ipfs to ${outDir}/ipfs`);
-    //для development
-    const devIpfsFile = path.resolve(__dirname, '../server/ipfs');
-    if (!await fs.pathExists(devIpfsFile)) {
-        await fs.copy(ipfsDecompressedFilename, devIpfsFile);
-    }
-}
-
-main();

+ 51 - 0
build/prepkg.js

@@ -0,0 +1,51 @@
+const fs = require('fs-extra');
+const path = require('path');
+const { execSync } = require('child_process');
+
+const showdown = require('showdown');
+
+const platform = process.argv[2];
+
+const distDir = path.resolve(__dirname, '../dist');
+const tmpDir = `${distDir}/tmp`;
+const publicDir = `${tmpDir}/public`;
+const outDir = `${distDir}/${platform}`;
+
+async function build() {
+    if (!platform)
+        throw new Error(`Please set platform`);
+
+    await fs.emptyDir(outDir);
+
+    //добавляем readme в релиз
+    let readme = await fs.readFile(path.resolve(__dirname, '../README.md'), 'utf-8');
+    const converter = new showdown.Converter();
+    readme = converter.makeHtml(readme);
+    await fs.writeFile(`${outDir}/readme.html`, readme);
+
+    // перемещаем public на место
+    if (await fs.pathExists(publicDir)) {
+
+        const zipFile = `${tmpDir}/public.zip`;
+        const jsonFile = `${distDir}/public.json`;//distDir !!!
+
+        await fs.remove(zipFile);
+        execSync(`zip -r ${zipFile} .`, {cwd: publicDir, stdio: 'inherit'});
+
+        const data = (await fs.readFile(zipFile)).toString('base64');
+        await fs.writeFile(jsonFile, JSON.stringify({data}));
+    } else {
+        throw new Error(`publicDir: ${publicDir} does not exist`);
+    }
+}
+
+async function main() {
+    try {
+        await build();
+    } catch(e) {
+        console.error(e);
+        process.exit(1);
+    }
+}
+
+main();

+ 33 - 0
build/release.js

@@ -0,0 +1,33 @@
+const fs = require('fs-extra');
+const path = require('path');
+const { execSync } = require('child_process');
+
+const pckg = require('../package.json');
+
+const distDir = path.resolve(__dirname, '../dist');
+const outDir = `${distDir}/release`;
+
+async function makeRelease(target) {
+    const srcDir = `${distDir}/${target}`;
+
+    if (await fs.pathExists(srcDir)) {
+        const zipFile = `${outDir}/${pckg.name}-${pckg.version}-${target}.zip`;
+
+        execSync(`zip -r ${zipFile} .`, {cwd: srcDir, stdio: 'inherit'});
+    }
+}
+
+async function main() {
+    try {
+        await fs.emptyDir(outDir);
+        await makeRelease('win');
+        await makeRelease('linux');
+        await makeRelease('linux-arm64');
+        await makeRelease('macos');
+    } catch(e) {
+        console.error(e);
+        process.exit(1);
+    }
+}
+
+main();

+ 0 - 4
build/webpack.base.config.js

@@ -30,10 +30,6 @@ module.exports = {
                     }
                 }*/
             },
-            {
-                resourceQuery: /^\?vue/,
-                use: path.resolve(__dirname, 'includer.js')
-            },
             {
                 test: /\.js$/,
                 loader: 'babel-loader',

+ 3 - 3
build/webpack.dev.config.js

@@ -1,5 +1,6 @@
 const path = require('path');
 const webpack = require('webpack');
+const pckg = require('../package.json');
 
 const { merge } = require('webpack-merge');
 const baseWpConfig = require('./webpack.base.config');
@@ -8,16 +9,15 @@ baseWpConfig.entry.unshift('webpack-hot-middleware/client');
 const HtmlWebpackPlugin = require('html-webpack-plugin');
 const CopyWebpackPlugin = require('copy-webpack-plugin');
 
-const publicDir = path.resolve(__dirname, '../server/public');
+const publicDir = path.resolve(__dirname, `../server/.${pckg.name}/public`);
 const clientDir = path.resolve(__dirname, '../client');
 
 module.exports = merge(baseWpConfig, {
     mode: 'development',
     devtool: 'inline-source-map',
     output: {
-        path: `${publicDir}/app`,
+        path: `${publicDir}${baseWpConfig.output.publicPath}`,
         filename: 'bundle.js',
-        clean: true
     },
 
     module: {

+ 1 - 2
build/webpack.prod.config.js

@@ -17,9 +17,8 @@ const clientDir = path.resolve(__dirname, '../client');
 module.exports = merge(baseWpConfig, {
     mode: 'production',
     output: {
-        path: `${publicDir}/app_new`,
+        path: `${publicDir}${baseWpConfig.output.publicPath}`,
         filename: 'bundle.[contenthash].js',
-        clean: true        
     },
     module: {
         rules: [

+ 0 - 45
build/win.js

@@ -1,45 +0,0 @@
-const fs = require('fs-extra');
-const path = require('path');
-const util = require('util');
-const stream = require('stream');
-const pipeline = util.promisify(stream.pipeline);
-
-const axios = require('axios');
-const FileDecompressor = require('../server/core/FileDecompressor');
-
-const distDir = path.resolve(__dirname, '../dist');
-const publicDir = `${distDir}/tmp/public`;
-const outDir = `${distDir}/win`;
-
-const tempDownloadDir = `${distDir}/tmp/download`;
-
-async function main() {
-    const decomp = new FileDecompressor();
-
-    await fs.emptyDir(outDir);
-    // перемещаем public на место
-    if (await fs.pathExists(publicDir))
-        await fs.move(publicDir, `${outDir}/public`);
-
-    await fs.ensureDir(tempDownloadDir);
-
-    //ipfs
-    const ipfsDecompressedFilename = `${tempDownloadDir}/go-ipfs/ipfs.exe`;
-    if (!await fs.pathExists(ipfsDecompressedFilename)) {
-        // Скачиваем ipfs
-        const ipfsRemoteUrl = 'https://dist.ipfs.io/go-ipfs/v0.4.18/go-ipfs_v0.4.18_windows-amd64.zip';
-
-        const res = await axios.get(ipfsRemoteUrl, {responseType: 'stream'})
-        await pipeline(res.data, fs.createWriteStream(`${tempDownloadDir}/ipfs.zip`));
-        console.log(`done downloading ${ipfsRemoteUrl}`);
-
-        //распаковываем
-        console.log(await decomp.unpack(`${tempDownloadDir}/ipfs.zip`, tempDownloadDir));
-        console.log('files decompressed');
-    }
-    // копируем в дистрибутив
-    await fs.copy(ipfsDecompressedFilename, `${outDir}/ipfs.exe`);
-    console.log(`copied ${ipfsDecompressedFilename} to ${outDir}/ipfs.exe`);
-}
-
-main();

+ 4 - 16
client/api/misc.js

@@ -1,10 +1,5 @@
-import axios from 'axios';
 import wsc from './webSocketConnection';
 
-const api = axios.create({
-  baseURL: '/api'
-});
-
 class Misc {
     async loadConfig() {
 
@@ -12,18 +7,11 @@ class Misc {
             'name', 'version', 'mode', 'maxUploadFileSize', 'useExternalBookConverter', 'acceptFileExt', 'bucEnabled', 'branch',
         ]};
 
-        try {
-            const config = await wsc.message(await wsc.send(Object.assign({action: 'get-config'}, query)));
-            if (config.error)
-                throw new Error(config.error);
-            return config;
-        } catch (e) {
-            console.error(e);
-        }
+        const config = await wsc.message(await wsc.send(Object.assign({action: 'get-config'}, query)));
+        if (config.error)
+            throw new Error(config.error);
 
-        //если с WebSocket проблема, работаем по http
-        const response = await api.post('/config', query);
-        return response.data;
+        return config;
     }
 }
 

+ 22 - 66
client/api/reader.js

@@ -7,9 +7,9 @@ const api = axios.create({
     baseURL: '/api/reader'
 });
 
-const workerApi = axios.create({
+/*const workerApi = axios.create({
     baseURL: '/api/worker'
-});
+});*/
 
 class Reader {
     constructor() {
@@ -19,58 +19,24 @@ class Reader {
         if (!callback) callback = () => {};
 
         let response = {};
-        try {
-            const requestId = await wsc.send({action: 'worker-get-state-finish', workerId});
-
-            let prevResponse = false;
-            while (1) {// eslint-disable-line no-constant-condition
-                response = await wsc.message(requestId);
-
-                if (!response.state && prevResponse !== false) {//экономия траффика
-                    callback(prevResponse);
-                } else {//были изменения worker state
-                    if (!response.state)
-                        throw new Error('Неверный ответ api');
-                    callback(response);
-                    prevResponse = response;
-                }
-
-                if (response.state == 'finish' || response.state == 'error') {
-                    break;
-                }
-            }
-            return response;
-        } catch (e) {
-            console.error(e);
-        }
+        const requestId = await wsc.send({action: 'worker-get-state-finish', workerId});
 
-        //если с WebSocket проблема, работаем по http
-        const refreshPause = 500;
-        let i = 0;
-        response = {};
+        let prevResponse = false;
         while (1) {// eslint-disable-line no-constant-condition
-            const prevProgress = response.progress || 0;
-            const prevState = response.state || 0;
-            response = await workerApi.post('/get-state', {workerId});
-            response = response.data;
-            callback(response);
-
-            if (!response.state)
-                throw new Error('Неверный ответ api');
+            response = await wsc.message(requestId);
+
+            if (!response.state && prevResponse !== false) {//экономия траффика
+                callback(prevResponse);
+            } else {//были изменения worker state
+                if (!response.state)
+                    throw new Error('Неверный ответ api');
+                callback(response);
+                prevResponse = response;
+            }
 
             if (response.state == 'finish' || response.state == 'error') {
                 break;
             }
-
-            if (i > 0)
-                await utils.sleep(refreshPause);
-
-            i++;
-            if (i > 180*1000/refreshPause) {//3 мин ждем телодвижений воркера
-                throw new Error('Слишком долгое время ожидания');
-            }
-            //проверка воркера
-            i = (prevProgress != response.progress || prevState != response.state ? 1 : i);
         }
 
         return response;
@@ -79,14 +45,13 @@ class Reader {
     async loadBook(opts, callback) {
         if (!callback) callback = () => {};
 
-        let response = await api.post('/load-book', opts);
-
-        const workerId = response.data.workerId;
+        let response = await wsc.message(await wsc.send(Object.assign({action: 'load-book'}, opts)));
+        const workerId = response.workerId;
         if (!workerId)
             throw new Error('Неверный ответ api');
 
         callback({totalSteps: 4});
-        callback(response.data);
+        callback(response);
 
         response = await this.getWorkerStateFinish(workerId, callback);
 
@@ -181,22 +146,13 @@ class Reader {
     }
 
     async storage(request) {
-        let response = null;
-        try {
-            response = await wsc.message(await wsc.send({action: 'reader-storage', body: request}));
-        } catch (e) {
-            console.error(e);
-            //если с WebSocket проблема, работаем по http
-            response = await api.post('/storage', request);
-            response = response.data;
-        }
+        const response = await wsc.message(await wsc.send({action: 'reader-storage', body: request}));
 
-        const state = response.state;
-        if (!state)
-            throw new Error('Неверный ответ api');
-        if (state == 'error') {
+        if (response.error)
             throw new Error(response.error);
-        }
+
+        if (!response.state)
+            throw new Error('Неверный ответ api');
 
         return response;
     }

+ 6 - 23
client/components/App.vue

@@ -39,16 +39,6 @@ class App {
     _options = componentOptions;
     showPage = false;
 
-    itemRuText = {
-        '/cardindex': 'Картотека',
-        '/reader': 'Читалка',
-        '/forum': 'Форум-чат',
-        '/income': 'Поступления',
-        '/sources': 'Источники',
-        '/settings': 'Параметры',
-        '/help': 'Справка',
-    };
-
     created() {
         this.commit = this.$store.commit;
         this.state = this.$store.state;
@@ -130,7 +120,7 @@ class App {
 
         this.setAppTitle();
         (async() => {
-            //загрузим конфиг сревера
+            //загрузим конфиг сервера
             try {
                 const config = await miscApi.loadConfig();
                 this.commit('config/setConfig', config);
@@ -197,12 +187,12 @@ class App {
 
     setAppTitle(title) {
         if (!title) {
-            if (this.mode == 'liberama.top') {
+            if (this.mode == 'liberama') {
                 document.title = `Liberama Reader - всегда с вами`;
             } else if (this.mode == 'omnireader') {
                 document.title = `Omni Reader - всегда с вами`;
             } else if (this.config && this.mode !== null) {
-                document.title = `${this.config.name} - ${this.itemRuText[this.rootRoute]}`;
+                document.title = `Универсальная читалка книг и ресурсов интернета`;
             }
         } else {
             document.title = title;
@@ -217,19 +207,12 @@ class App {
         return this.$store.state.config.mode;
     }
 
-    get showAsideBar() {
-        return (this.mode !== null && this.mode != 'reader' && this.mode != 'omnireader' && this.mode != 'liberama.top');
-    }
-
-    set showAsideBar(value) {
-    }
-
     get isReaderActive() {
         return (this.rootRoute == '/reader' || this.rootRoute == '/external-libs');
     }
 
     redirectIfNeeded() {
-        if ((this.mode == 'reader' || this.mode == 'omnireader' || this.mode == 'liberama.top')) {
+        if ((this.mode == 'reader' || this.mode == 'omnireader' || this.mode == 'liberama')) {
             const search = window.location.search.substr(1);
 
             //распознавание параметра url вида "?url=<link>" и редирект при необходимости
@@ -271,8 +254,8 @@ body, html, #app {
     font: normal 12pt ReaderDefault;
 }
 
-.notify-margin {
-    margin-top: 55px;
+.q-notifications__list--top {
+    top: 55px !important;
 }
 
 .dborder {

+ 0 - 19
client/components/CardIndex/Book/Book.vue

@@ -1,19 +0,0 @@
-<template>
-    <div>
-        Раздел Book в разработке
-    </div>
-</template>
-
-<script>
-//-----------------------------------------------------------------------------
-import vueComponent from '../../vueComponent.js';
-
-class Book {
-    created() {
-    }
-
-}
-
-export default vueComponent(Book);
-//-----------------------------------------------------------------------------
-</script>

+ 0 - 19
client/components/CardIndex/Card/Card.vue

@@ -1,19 +0,0 @@
-<template>
-    <div>
-        Раздел Card в разработке
-    </div>
-</template>
-
-<script>
-//-----------------------------------------------------------------------------
-import vueComponent from '../../vueComponent.js';
-
-class Card {
-    created() {
-    }
-
-}
-
-export default vueComponent(Card);
-//-----------------------------------------------------------------------------
-</script>

+ 0 - 93
client/components/CardIndex/CardIndex.vue

@@ -1,93 +0,0 @@
-<template>
-    <div>
-        <router-view v-slot="{ Component }">
-            <keep-alive>
-                <component :is="Component" />
-            </keep-alive>
-        </router-view>        
-    </div>
-</template>
-
-<script>
-//-----------------------------------------------------------------------------
-import vueComponent from '../vueComponent.js';
-import _ from 'lodash';
-
-const selfRoute = '/cardindex';
-const tab2Route = [
-    '/cardindex/search',
-    '/cardindex/card',
-    '/cardindex/book',
-    '/cardindex/history',
-];
-let lastActiveTab = null;
-
-const componentOptions = {
-    watch: {
-        selectedTab: function(newValue) {
-            lastActiveTab = newValue;
-            this.setRouteByTab(newValue);
-        },
-        curRoute: function(newValue) {
-            this.setTabByRoute(newValue);
-        },
-    },
-};
-class CardIndex {
-    _options = componentOptions;
-    selectedTab = null;
-
-    created() {
-        this.$watch(
-            () => this.$route.path,
-            (newValue) => {
-                if (newValue == '/cardindex' && this.isReader) {
-                    this.$router.replace({ path: '/reader' });
-                }
-            }
-        )
-    }
-
-    mounted() {
-        this.setTabByRoute(this.curRoute);
-    }
-
-    setTabByRoute(route) {
-        const t = _.indexOf(tab2Route, route);
-        if (t >= 0) {
-            if (t !== this.selectedTab)
-                this.selectedTab = t.toString();
-        } else {
-            if (route == selfRoute && lastActiveTab !== null)
-                this.setRouteByTab(lastActiveTab);
-        }
-    }
-
-    setRouteByTab(tab) {
-        const t = Number(tab);
-        if (tab2Route[t] !== this.curRoute) {
-            this.$router.replace(tab2Route[t]);
-        }
-    }
-
-    get mode() {
-        return this.$store.state.config.mode;
-    }
-
-    get curRoute() {
-        const m = this.$route.path.match(/^(\/[^/]*\/[^/]*).*$/i);
-        return (m ? m[1] : this.$route.path);
-    }
-
-    get isReader() {
-        return (this.mode !== null && (this.mode == 'reader' || this.mode == 'omnireader' || this.mode == 'liberama.top'));
-    }
-
-}
-
-export default vueComponent(CardIndex);
-//-----------------------------------------------------------------------------
-</script>
-
-<style scoped>
-</style>

+ 0 - 19
client/components/CardIndex/History/History.vue

@@ -1,19 +0,0 @@
-<template>
-    <div>
-        Раздел History в разработке
-    </div>
-</template>
-
-<script>
-//-----------------------------------------------------------------------------
-import vueComponent from '../../vueComponent.js';
-
-class History {
-    created() {
-    }
-
-}
-
-export default vueComponent(History);
-//-----------------------------------------------------------------------------
-</script>

+ 0 - 19
client/components/CardIndex/Search/Search.vue

@@ -1,19 +0,0 @@
-<template>
-    <div>
-        Раздел Search в разработке
-    </div>
-</template>
-
-<script>
-//-----------------------------------------------------------------------------
-import vueComponent from '../../vueComponent.js';
-
-class Search {
-    created() {
-    }
-
-}
-
-export default vueComponent(Search);
-//-----------------------------------------------------------------------------
-</script>

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

@@ -347,6 +347,7 @@ export default vueComponent(BookmarkSettings);
     padding: 0px 10px 10px 10px;
     overflow-x: auto;
     overflow-y: auto;
+    max-width: 520px;
 }
 
 .selected {

+ 79 - 23
client/components/ExternalLibs/ExternalLibs.vue

@@ -110,7 +110,7 @@
 
             <div ref="frameBox" class="col fit" style="position: relative;">
                 <div ref="frameWrap" class="overflow-hidden">
-                    <iframe v-if="frameVisible" ref="frame" :src="frameSrc" frameborder="0"></iframe>
+                    <iframe v-if="frameVisible" ref="frame" :src="frameSrc" frameborder="0" allow="clipboard-read; clipboard-write"></iframe>
                 </div>
                 <div v-show="transparentLayoutVisible" ref="transparentLayout" class="fit transparent-layout" @click="transparentLayoutClick"></div>
             </div>
@@ -304,6 +304,10 @@ class ExternalLibs {
     openInFrameOnAdd = false;
     frameScale = 1;
 
+    inpxReady = false;
+    inpxTitle = '';
+    inpxUrl = '';
+
     created() {
         this.oldStartLink = '';
         this.justOpened = true;
@@ -321,8 +325,6 @@ class ExternalLibs {
         this.debouncedGoToLink = _.debounce((link) => {
             this.goToLink(link);
         }, 100, {'maxWait':200});
-        //this.commit = this.$store.commit;
-        //this.commit('reader/setLibs', rstore.libsDefaults);
     }
 
     mounted() {
@@ -334,10 +336,7 @@ class ExternalLibs {
                 i++;
             }
 
-            if (this.mode != 'liberama.top') {
-                this.$router.replace('/404');
-                return;
-            }
+            this.libsDefaults = rstore.getLibsDefaults(this.mode);
 
             this.$refs.window.init();
 
@@ -348,17 +347,28 @@ class ExternalLibs {
             const openerOrigin2 = `https://${openerHost}`;
 
             window.addEventListener('message', (event) => {
+                //from inpx-web
+                if (_.isObject(event.data) && event.data.from === 'inpx-web') {
+                    //console.log(event);
+
+                    this.inpxOrigin = event.origin;
+
+                    this.recvInpxMessage(event.data);
+                    return;
+                }
+
+                //from parent
                 if (event.origin !== openerOrigin1 && event.origin !== openerOrigin2)
                     return;
+
                 if (!_.isObject(event.data) || event.data.from != 'LibsPage')
                     return;
                 if (event.origin == openerOrigin1)
                     this.opener = window.opener;
                 else
                     this.opener = event.source;
-                this.openerOrigin = event.origin;
 
-                //console.log(event);
+                this.openerOrigin = event.origin;
 
                 this.recvMessage(event.data);
             });
@@ -389,7 +399,8 @@ class ExternalLibs {
             }
         } else if (d.type == 'libs') {
             this.ready = true;
-            this.libs = _.cloneDeep(d.data);
+            if (d.data)
+                this.libs = _.cloneDeep(d.data);
         } else if (d.type == 'notify') {
             this.$root.notify.success(d.data, '', {position: 'bottom-right'});
         }
@@ -403,6 +414,30 @@ class ExternalLibs {
         })();
     }
 
+    recvInpxMessage(d) {
+        if (d.type == 'mes') {
+            switch(d.data) {
+                case 'hello-from-inpx-web':
+                    this.sendInpxMessage({type: 'mes', data: 'ready'});
+                    break;
+                case 'ready':
+                    this.inpxReady = true;
+                    break;
+            }
+        } else if (d.type == 'submitUrl') {
+            this.submitUrl(d.data);
+        } else if (d.type == 'titleChange') {
+            this.inpxTitle = d.data;
+        } else if (d.type == 'urlChange') {
+            this.inpxUrl = d.data;
+        }
+    }
+
+    sendInpxMessage(d) {
+        if (this.$refs.frame && this.inpxOrigin)
+            this.$refs.frame.contentWindow.postMessage(Object.assign({}, {from: 'ExternalLibs'}, d), this.inpxOrigin);
+    }
+
     async checkOpener() {
         if (this.opener.closed) {
             await this.$root.stdDialog.alert('Потеряна связь с читалкой. Окно будет закрыто', 'Ошибка');
@@ -461,7 +496,10 @@ class ExternalLibs {
     get header() {
         let result = (this.ready ? 'Сетевая библиотека' : 'Загрузка...');
         if (this.ready && this.selectedLink) {
-            result += ` | ${(this.libs.comment ? this.libs.comment + ' ': '') + lu.removeProtocol(this.libs.startLink)}`;
+            let title = `${(this.libs.comment ? this.libs.comment + ' ': '') + lu.removeProtocol(this.libs.startLink)}`;
+            if (this.inpxReady && this.inpxTitle)
+                title = `${this.inpxTitle} ${lu.removeProtocol(this.inpxUrl)}`;
+            result += ` | ${title}`;
         }
         this.$root.setAppTitle(result);
         return result;
@@ -532,7 +570,7 @@ class ExternalLibs {
     get defaultRootLinkOptions() {
         let result = [];
 
-        rstore.libsDefaults.groups.forEach(group => {
+        this.libsDefaults.groups.forEach(group => {
             result.push({label: lu.removeProtocol(group.r), value: group.r});
         });
 
@@ -561,6 +599,11 @@ class ExternalLibs {
     }
 
     goToLink(link) {
+        this.inpxReady = false;
+        this.inpxTitle = '';
+        this.inpxUrl = '';
+        this.inpxOrigin = false;
+
         if (!this.ready || !link)
             return;
 
@@ -576,6 +619,7 @@ class ExternalLibs {
             this.frameVisible = true;
             this.$nextTick(() => {
                 if (this.$refs.frame) {
+                    this.$refs.frame.contentWindow.location.reload(true);
                     this.$refs.frame.contentWindow.focus();
                     this.frameResize();
                 }
@@ -648,13 +692,17 @@ class ExternalLibs {
         this.updateStartLink(true);
     }
 
-    submitUrl() {
-        if (this.bookUrl) {
+    submitUrl(url) {
+        if (!url) {
+            url = this.bookUrl;
+            this.bookUrl = '';
+        }
+
+        if (url) {
             this.sendMessage({type: 'submitUrl', data: {
-                url: this.bookUrl,
+                url,
                 force: true
             }});
-            this.bookUrl = '';
             if (this.closeAfterSubmit)
                 this.close();
         }
@@ -668,6 +716,12 @@ class ExternalLibs {
         } else {
             this.bookmarkLink = this.bookUrl;
             this.bookmarkDesc = '';
+
+            if (!this.bookmarkLink && this.inpxReady && this.inpxUrl) {
+                this.bookmarkLink = this.inpxUrl;
+                if (this.inpxTitle)
+                    this.bookmarkDesc = this.inpxTitle;
+            }
         }
 
         this.addBookmarkMode = mode;
@@ -679,10 +733,10 @@ class ExternalLibs {
     }
 
     updateBookmarkLink() {
-        const index = lu.getSafeRootIndexByUrl(rstore.libsDefaults.groups, this.defaultRootLink);
+        const index = lu.getSafeRootIndexByUrl(this.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);
+            this.bookmarkLink = this.libsDefaults.groups[index].s;
+            this.bookmarkDesc = this.getCommentByLink(this.libsDefaults.groups[index].list, this.bookmarkLink);
         } else {
             this.bookmarkLink = '';
             this.bookmarkDesc = '';
@@ -837,20 +891,22 @@ class ExternalLibs {
 <p>Окно 'Сетевая библиотека' позволяет открывать ссылки в читалке без переключения между окнами,
 что особенно актуально для мобильных устройств. Имеется возможность управлять закладками
 на понравившиеся ресурсы, книги или страницы авторов. Открытие ссылок и навигация происходят во фрейме, но,
-к сожалению, в нем открываются не все страницы.</p>
+к сожалению, в нем открываются не все страницы.</p>` + 
 
-<p>Доступ к сайтам <span style="color: blue">http://flibusta.is</span> и <span style="color: blue">http://fantasy-worlds.org</span> работает через прокси.
+(this.mode === 'liberama' ?
+`<p>Доступ к сайтам <span style="color: blue">http://flibusta.is</span> и <span style="color: blue">http://fantasy-worlds.org</span> работает через прокси.
 
 <br><span style="color: red"><b>ПРЕДУПРЕЖДЕНИЕ!</b></span>
 Доступ предназначен только для просмотра и скачивания книг. Авторизоваться на этих сайтах
 из фрейма категорически не рекомендуется, т.к. ваше подключение не защищено и данные могут попасть 
 к третьим лицам.
 </p>
+`
+: '') + 
 
-<p>Из-за проблем с безопасностью, навигация 'вперед-назад' во фрейме осуществляется с помощью контекстного меню правой кнопкой мыши.
+`<p>Из-за проблем с безопасностью, навигация 'вперед-назад' во фрейме осуществляется с помощью контекстного меню правой кнопкой мыши.
 На мобильных устройствах для этого служит системная клавиша 'Назад (стрелка влево)' и опция 'Вперед (стрелка вправо)' в меню браузера. 
 </p>
-
 <p>Приятного пользования ;-)
 </p>
             `, 'Справка', {iconName: 'la la-info-circle'});

+ 0 - 19
client/components/Help/Help.vue

@@ -1,19 +0,0 @@
-<template>
-    <div>
-        Раздел Help в разработке
-    </div>
-</template>
-
-<script>
-//-----------------------------------------------------------------------------
-import vueComponent from '../vueComponent.js';
-
-class Help {
-    created() {
-    }
-
-}
-
-export default vueComponent(Help);
-//-----------------------------------------------------------------------------
-</script>

+ 0 - 19
client/components/Income/Income.vue

@@ -1,19 +0,0 @@
-<template>
-    <div>
-        Раздел Income в разработке
-    </div>
-</template>
-
-<script>
-//-----------------------------------------------------------------------------
-import vueComponent from '../vueComponent.js';
-
-class Income {
-    created() {
-    }
-
-}
-
-export default vueComponent(Income);
-//-----------------------------------------------------------------------------
-</script>

+ 0 - 19
client/components/NotFound404/NotFound404.vue

@@ -1,19 +0,0 @@
-<template>
-    <div>
-        Страница не найдена
-    </div>
-</template>
-
-<script>
-//-----------------------------------------------------------------------------
-import vueComponent from '../vueComponent.js';
-
-class NotFound404 {
-    created() {
-    }
-
-}
-
-export default vueComponent(NotFound404);
-//-----------------------------------------------------------------------------
-</script>

+ 1 - 1
client/components/Reader/HelpPage/CommonHelpPage/CommonHelpPage.vue

@@ -24,7 +24,7 @@
         </p>
         <p>Поддерживаемые форматы: <b>fb2, fb2.zip, html, txt</b> и другие.</p>
 
-        <div v-show="mode == 'omnireader' || mode == 'liberama.top'">
+        <div v-show="mode == 'omnireader' || mode == 'liberama'">
             <p>
                 Вы можете добавить в свой браузер закладку, указав в ее свойствах вместо адреса следующий код:
                 <br><strong>{{ bookmarkText }}</strong>

BIN
client/components/Reader/HelpPage/DonateHelpPage/assets/bitcoin.png


BIN
client/components/Reader/HelpPage/DonateHelpPage/assets/litecoin.png


BIN
client/components/Reader/HelpPage/DonateHelpPage/assets/monero.png


+ 1 - 1
client/components/Reader/HelpPage/HotkeysHelpPage/HotkeysHelpPage.vue

@@ -19,7 +19,7 @@
 //-----------------------------------------------------------------------------
 import vueComponent from '../../../vueComponent.js';
 
-import UserHotKeys from '../../SettingsPage/UserHotKeys/UserHotKeys.vue';
+import UserHotKeys from '../../SettingsPage/KeysTab/UserHotKeys/UserHotKeys.vue';
 
 const componentOptions = {
     components: {

+ 1 - 1
client/components/Reader/HelpPage/MouseHelpPage/MouseHelpPage.vue

@@ -13,7 +13,7 @@
             <li>Жесты для тачскрина:</li>
             <ul>
                 <li style="list-style-type: square">
-                    от центра вверх: на весь экран
+                    от центра вверх/двойной тап по центру: на весь экран
                 </li>
                 <li style="list-style-type: square">
                     от центра вниз: плавный скроллинг

+ 9 - 4
client/components/Reader/LibsPage/LibsPage.vue

@@ -8,7 +8,7 @@ import vueComponent from '../../vueComponent.js';
 
 import Window from '../../share/Window.vue';
 import * as utils from '../../../share/utils';
-//import rstore from '../../../store/modules/reader';
+import rstore from '../../../store/modules/reader';
 import _ from 'lodash';
 
 const componentOptions = {
@@ -28,13 +28,18 @@ class LibsPage {
         this.popupWindow = null;
         this.commit = this.$store.commit;
         this.messageListener = null;
-        //this.commit('reader/setLibs', rstore.libsDefaults);
     }
 
-    init() {
-        if (this.mode != 'liberama.top')
+    async init() {
+        if (!this.mode)
             return;
 
+        //TODO: убрать второе условие в 24г
+        if (!this.libs || (this.mode === 'omnireader' && this.libs.mode !== this.mode)) {
+            const defaults = rstore.getLibsDefaults(this.mode);
+            this.commit('reader/setLibs', defaults);
+        }
+
         this.childReady = false;
         const subdomain = (window.location.protocol != 'http:' ? 'b.' : '');
         this.origin = `http://${subdomain}${window.location.host}`;

+ 2 - 23
client/components/Reader/LoaderPage/LoaderPage.vue

@@ -1,6 +1,6 @@
 <template>
     <div ref="main" class="column no-wrap" style="min-height: 500px">
-        <div v-if="mode != 'liberama.top'" class="relative-position">
+        <div v-if="mode != 'liberama'" class="relative-position">
             <GithubCorner url="https://github.com/bookpauk/liberama" corner-color="#1B695F" git-color="#EBE2C9"></GithubCorner>
         </div>
         <div class="col column justify-center items-center no-wrap overflow-hidden" style="min-height: 230px">
@@ -55,7 +55,6 @@
         </div>
 
         <div class="col column justify-end items-center no-wrap overflow-hidden">
-            <span v-if="mode == 'omnireader'" class="bottom-span clickable" @click="findBook">Найти книгу</span>
             <span class="bottom-span clickable" @click="openHelp">Справка</span>
             <span class="bottom-span clickable" @click="openDonate">Помочь проекту</span>
 
@@ -64,18 +63,6 @@
         </div>
 
         <PasteTextPage v-if="pasteTextActive" ref="pasteTextPage" @paste-text-toggle="pasteTextToggle" @load-buffer="loadBuffer"></PasteTextPage>
-
-        <Dialog ref="dialog1" v-model="findBookVisible">
-            <template #header>
-                Подсказка ;-)
-            </template>
-
-            <div style="word-break: normal">
-                Если вы хотите найти определенную книгу, добро пожаловать в
-                раздел "Сетевая библиотека" (кнопка <q-icon name="la la-sitemap" size="32px" />) на сайте читалки
-                <a href="https://liberama.top" target="_blank">liberama.top</a>
-            </div>
-        </Dialog>
     </div>
 </template>
 
@@ -103,7 +90,6 @@ class LoaderPage {
     bookUrl = null;
     loadPercent = 0;
     pasteTextActive = false;
-    findBookVisible = false;
 
     created() {
         this.commit = this.$store.commit;
@@ -122,7 +108,7 @@ class LoaderPage {
     get title() {
         if (this.mode == 'omnireader')
             return 'Omni Reader - браузерная онлайн-читалка.';
-        if (this.mode == 'liberama.top')
+        if (this.mode == 'liberama')
             return 'Liberama Reader - браузерная онлайн-читалка.';
         return 'Универсальная читалка книг и ресурсов интернета.';
 
@@ -193,10 +179,6 @@ class LoaderPage {
         this.$emit('do-action', {action: 'donate'});
     }
     
-    findBook() {
-        this.findBookVisible = true;
-    }
-
     openComments() {
         window.open('http://samlib.ru/comment/b/bookpauk/bookpauk_reader', '_blank');
     }
@@ -213,9 +195,6 @@ class LoaderPage {
     }
 
     keyHook(event) {
-        if (this.$refs.dialog1.active)
-            return true;
-
         if (this.pasteTextActive) {
             return this.$refs.pasteTextPage.keyHook(event);
         }

+ 1 - 1
client/components/Reader/LoaderPage/PasteTextPage/PasteTextPage.vue

@@ -60,7 +60,7 @@ class PasteTextPage {
 
     calcTitle(event) {
         if (this.bookTitle == '') {
-            this.bookTitle = `Из буфера обмена ${utils.formatDate(new Date(), 'noDate')}`;
+            this.bookTitle = `Из буфера обмена ${utils.dateFormat(new Date())}`;
             if (event) {
                 let text = event.clipboardData.getData('text');
                 this.bookTitle += ': ' + _.compact([

+ 149 - 156
client/components/Reader/Reader.vue

@@ -1,139 +1,138 @@
 <template>
     <div class="column no-wrap">
         <div v-show="toolBarActive" ref="header" class="header">
-            <div ref="buttons" class="row justify-between no-wrap">
-                <div class="row no-wrap">
-                    <button ref="loader" v-ripple class="tool-button" :class="buttonActiveClass('loader')" @click="buttonClick('loader')">
-                        <q-icon name="la la-arrow-left" size="32px" />
-                        <q-tooltip :delay="1500" anchor="bottom right" content-style="font-size: 80%">
-                            {{ rstore.readerActions['loader'] }}
-                        </q-tooltip>
-                    </button>
-                    <button v-show="showToolButton['loadFile']" ref="loadFile" v-ripple class="tool-button" :class="buttonActiveClass('loadFile')" @click="buttonClick('loadFile')">
-                        <q-icon name="la la-caret-square-up" size="32px" />
-                        <q-tooltip :delay="1500" anchor="bottom right" content-style="font-size: 80%">
-                            {{ rstore.readerActions['loadFile'] }}
-                        </q-tooltip>
-                    </button>
-                    <button v-show="showToolButton['loadBuffer']" ref="loadBuffer" v-ripple class="tool-button" :class="buttonActiveClass('loadBuffer')" @click="buttonClick('loadBuffer')">
-                        <q-icon name="la la-comment" size="32px" />
-                        <q-tooltip :delay="1500" anchor="bottom right" content-style="font-size: 80%">
-                            {{ rstore.readerActions['loadBuffer'] }}
-                        </q-tooltip>
-                    </button>
-                    <button v-show="showToolButton['help']" ref="help" v-ripple class="tool-button" :class="buttonActiveClass('help')" @click="buttonClick('help')">
-                        <q-icon name="la la-question" size="32px" />
-                        <q-tooltip :delay="1500" anchor="bottom right" content-style="font-size: 80%">
-                            {{ rstore.readerActions['help'] }}
-                        </q-tooltip>
-                    </button>
-                </div>
-
-                <div class="row no-wrap">
-                    <div class="space"></div>
-                    <button v-show="showToolButton['undoAction']" ref="undoAction" v-ripple class="tool-button" :class="buttonActiveClass('undoAction')" @click="buttonClick('undoAction')">
-                        <q-icon name="la la-angle-left" size="32px" />
-                        <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
-                            {{ rstore.readerActions['undoAction'] }}
-                        </q-tooltip>
-                    </button>
-                    <button v-show="showToolButton['redoAction']" ref="redoAction" v-ripple class="tool-button" :class="buttonActiveClass('redoAction')" @click="buttonClick('redoAction')">
-                        <q-icon name="la la-angle-right" size="32px" />
-                        <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
-                            {{ rstore.readerActions['redoAction'] }}
-                        </q-tooltip>
-                    </button>
-                    <div class="space"></div>
-                    <button v-show="showToolButton['fullScreen']" ref="fullScreen" v-ripple class="tool-button" :class="buttonActiveClass('fullScreen')" @click="buttonClick('fullScreen')">
-                        <q-icon :name="(fullScreenActive ? 'la la-compress-arrows-alt': 'la la-expand-arrows-alt')" size="32px" />
-                        <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
-                            {{ rstore.readerActions['fullScreen'] }}
-                        </q-tooltip>
-                    </button>
-                    <button v-show="showToolButton['scrolling']" ref="scrolling" v-ripple class="tool-button" :class="buttonActiveClass('scrolling')" @click="buttonClick('scrolling')">
-                        <q-icon name="la la-film" size="32px" />
-                        <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
-                            {{ rstore.readerActions['scrolling'] }}
-                        </q-tooltip>
-                    </button>
-                    <button v-show="showToolButton['setPosition']" ref="setPosition" v-ripple class="tool-button" :class="buttonActiveClass('setPosition')" @click="buttonClick('setPosition')">
-                        <q-icon name="la la-angle-double-right" size="32px" />
-                        <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
-                            {{ rstore.readerActions['setPosition'] }}
-                        </q-tooltip>
-                    </button>
-                    <button v-show="showToolButton['search']" ref="search" v-ripple class="tool-button" :class="buttonActiveClass('search')" @click="buttonClick('search')">
-                        <q-icon name="la la-search" size="32px" />
-                        <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
-                            {{ rstore.readerActions['search'] }}
-                        </q-tooltip>
-                    </button>
-                    <button v-show="showToolButton['copyText']" ref="copyText" v-ripple class="tool-button" :class="buttonActiveClass('copyText')" @click="buttonClick('copyText')">
-                        <q-icon name="la la-copy" size="32px" />
-                        <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
-                            {{ rstore.readerActions['copyText'] }}
-                        </q-tooltip>
-                    </button>
-                    <button v-show="showToolButton['convOptions']" ref="convOptions" v-ripple class="tool-button" :class="buttonActiveClass('convOptions')" @click="buttonClick('convOptions')">
-                        <q-icon name="la la-magic" size="32px" />
-                        <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
-                            {{ rstore.readerActions['convOptions'] }}
-                        </q-tooltip>
-                    </button>
-                    <button v-show="showToolButton['refresh']" ref="refresh" v-ripple class="tool-button" :class="buttonActiveClass('refresh')" @click="buttonClick('refresh')">
-                        <q-icon name="la la-sync" size="32px" :class="{clear: !showRefreshIcon}" />
-                        <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
-                            {{ rstore.readerActions['refresh'] }}
-                        </q-tooltip>
-                    </button>
-                    <div class="space"></div>
-                    <button v-show="showToolButton['contents']" ref="contents" v-ripple class="tool-button" :class="buttonActiveClass('contents')" @click="buttonClick('contents')">
-                        <q-icon name="la la-list" size="32px" />
-                        <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
-                            {{ rstore.readerActions['contents'] }}
-                        </q-tooltip>
-                    </button>
-                    <button v-show="mode == 'liberama.top' && showToolButton['libs']" ref="libs" v-ripple class="tool-button" :class="buttonActiveClass('libs')" @click="buttonClick('libs')">
-                        <q-icon name="la la-sitemap" size="32px" />
-                        <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
-                            {{ rstore.readerActions['libs'] }}
-                        </q-tooltip>
-                    </button>
-                    <button v-show="showToolButton['recentBooks']" ref="recentBooks" v-ripple class="tool-button" :class="buttonActiveClass('recentBooks')" @click="buttonClick('recentBooks')">
-                        <div v-show="bothBucEnabled && needBookUpdateCount > 0" style="position: absolute">
-                            <div class="need-book-update-count">
-                                {{ needBookUpdateCount }}
-                            </div>
+            <div ref="buttons" class="row" :class="{'no-wrap': !toolBarMultiLine}">
+                <button ref="loader" v-ripple class="tool-button" :class="buttonActiveClass('loader')" @click="buttonClick('loader')">
+                    <q-icon name="la la-arrow-left" size="32px" />
+                    <q-tooltip :delay="1500" anchor="bottom right" content-style="font-size: 80%">
+                        {{ rstore.readerActions['loader'] }}
+                    </q-tooltip>
+                </button>
+                <button v-show="showToolButton['loadFile']" ref="loadFile" v-ripple class="tool-button" :class="buttonActiveClass('loadFile')" @click="buttonClick('loadFile')">
+                    <q-icon name="la la-caret-square-up" size="32px" />
+                    <q-tooltip :delay="1500" anchor="bottom right" content-style="font-size: 80%">
+                        {{ rstore.readerActions['loadFile'] }}
+                    </q-tooltip>
+                </button>
+                <button v-show="showToolButton['loadBuffer']" ref="loadBuffer" v-ripple class="tool-button" :class="buttonActiveClass('loadBuffer')" @click="buttonClick('loadBuffer')">
+                    <q-icon name="la la-comment" size="32px" />
+                    <q-tooltip :delay="1500" anchor="bottom right" content-style="font-size: 80%">
+                        {{ rstore.readerActions['loadBuffer'] }}
+                    </q-tooltip>
+                </button>
+                <button v-show="showToolButton['help']" ref="help" v-ripple class="tool-button" :class="buttonActiveClass('help')" @click="buttonClick('help')">
+                    <q-icon name="la la-question" size="32px" />
+                    <q-tooltip :delay="1500" anchor="bottom right" content-style="font-size: 80%">
+                        {{ rstore.readerActions['help'] }}
+                    </q-tooltip>
+                </button>
+
+                <div class="col"></div>
+
+                <div class="space"></div>
+                <button v-show="showToolButton['undoAction']" ref="undoAction" v-ripple class="tool-button" :class="buttonActiveClass('undoAction')" @click="buttonClick('undoAction')">
+                    <q-icon name="la la-angle-left" size="32px" />
+                    <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
+                        {{ rstore.readerActions['undoAction'] }}
+                    </q-tooltip>
+                </button>
+                <button v-show="showToolButton['redoAction']" ref="redoAction" v-ripple class="tool-button" :class="buttonActiveClass('redoAction')" @click="buttonClick('redoAction')">
+                    <q-icon name="la la-angle-right" size="32px" />
+                    <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
+                        {{ rstore.readerActions['redoAction'] }}
+                    </q-tooltip>
+                </button>
+                <div class="space"></div>
+                <button v-show="showToolButton['fullScreen']" ref="fullScreen" v-ripple class="tool-button" :class="buttonActiveClass('fullScreen')" @click="buttonClick('fullScreen')">
+                    <q-icon :name="(fullScreenActive ? 'la la-compress-arrows-alt': 'la la-expand-arrows-alt')" size="32px" />
+                    <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
+                        {{ rstore.readerActions['fullScreen'] }}
+                    </q-tooltip>
+                </button>
+                <button v-show="showToolButton['scrolling']" ref="scrolling" v-ripple class="tool-button" :class="buttonActiveClass('scrolling')" @click="buttonClick('scrolling')">
+                    <q-icon name="la la-film" size="32px" />
+                    <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
+                        {{ rstore.readerActions['scrolling'] }}
+                    </q-tooltip>
+                </button>
+                <button v-show="showToolButton['setPosition']" ref="setPosition" v-ripple class="tool-button" :class="buttonActiveClass('setPosition')" @click="buttonClick('setPosition')">
+                    <q-icon name="la la-angle-double-right" size="32px" />
+                    <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
+                        {{ rstore.readerActions['setPosition'] }}
+                    </q-tooltip>
+                </button>
+                <button v-show="showToolButton['search']" ref="search" v-ripple class="tool-button" :class="buttonActiveClass('search')" @click="buttonClick('search')">
+                    <q-icon name="la la-search" size="32px" />
+                    <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
+                        {{ rstore.readerActions['search'] }}
+                    </q-tooltip>
+                </button>
+                <button v-show="showToolButton['copyText']" ref="copyText" v-ripple class="tool-button" :class="buttonActiveClass('copyText')" @click="buttonClick('copyText')">
+                    <q-icon name="la la-copy" size="32px" />
+                    <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
+                        {{ rstore.readerActions['copyText'] }}
+                    </q-tooltip>
+                </button>
+                <button v-show="showToolButton['convOptions']" ref="convOptions" v-ripple class="tool-button" :class="buttonActiveClass('convOptions')" @click="buttonClick('convOptions')">
+                    <q-icon name="la la-magic" size="32px" />
+                    <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
+                        {{ rstore.readerActions['convOptions'] }}
+                    </q-tooltip>
+                </button>
+                <button v-show="showToolButton['refresh']" ref="refresh" v-ripple class="tool-button" :class="buttonActiveClass('refresh')" @click="buttonClick('refresh')">
+                    <q-icon name="la la-sync" size="32px" :class="{clear: !showRefreshIcon}" />
+                    <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
+                        {{ rstore.readerActions['refresh'] }}
+                    </q-tooltip>
+                </button>
+                <div v-show="showToolButton['libs']" class="space"></div>
+                <button v-show="showToolButton['libs']" ref="libs" v-ripple class="tool-button" :class="buttonActiveClass('libs')" @click="buttonClick('libs')">
+                    <q-icon name="la la-sitemap" size="32px" />
+                    <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
+                        {{ rstore.readerActions['libs'] }}
+                    </q-tooltip>
+                </button>
+                <div class="space"></div>
+                <button v-show="showToolButton['contents']" ref="contents" v-ripple class="tool-button" :class="buttonActiveClass('contents')" @click="buttonClick('contents')">
+                    <q-icon name="la la-list" size="32px" />
+                    <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
+                        {{ rstore.readerActions['contents'] }}
+                    </q-tooltip>
+                </button>
+                <button v-show="showToolButton['recentBooks']" ref="recentBooks" v-ripple class="tool-button" :class="buttonActiveClass('recentBooks')" @click="buttonClick('recentBooks')">
+                    <div v-show="bothBucEnabled && needBookUpdateCount > 0" style="position: absolute">
+                        <div class="need-book-update-count">
+                            {{ needBookUpdateCount }}
                         </div>
-
-                        <q-icon name="la la-book-open" size="32px" />
-                        <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
-                            {{ rstore.readerActions['recentBooks'] }}
-                        </q-tooltip>
-                    </button>
-                    <div class="space"></div>
-                </div>
-
-                <div class="row no-wrap">
-                    <button v-show="showToolButton['clickControl']" ref="clickControl" v-ripple class="tool-button" :class="buttonActiveClass('clickControl')" @click="buttonClick('clickControl')">
-                        <q-icon name="la la-mouse" size="32px" />
-                        <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
-                            {{ rstore.readerActions['clickControl'] }}
-                        </q-tooltip>
-                    </button>
-                    <button v-show="showToolButton['offlineMode']" ref="offlineMode" v-ripple class="tool-button" :class="buttonActiveClass('offlineMode')" @click="buttonClick('offlineMode')">
-                        <q-icon name="la la-unlink" size="32px" />
-                        <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
-                            {{ rstore.readerActions['offlineMode'] }}
-                        </q-tooltip>
-                    </button>
-                    <button ref="settings" v-ripple class="tool-button" :class="buttonActiveClass('settings')" @click="buttonClick('settings')">
-                        <q-icon name="la la-cog" size="32px" />
-                        <q-tooltip :delay="1500" anchor="bottom left" content-style="font-size: 80%">
-                            {{ rstore.readerActions['settings'] }}
-                        </q-tooltip>
-                    </button>
-                </div>
+                    </div>
+
+                    <q-icon name="la la-book-open" size="32px" />
+                    <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
+                        {{ rstore.readerActions['recentBooks'] }}
+                    </q-tooltip>
+                </button>
+                <div class="space"></div>
+
+                <div class="col"></div>
+
+                <button v-show="showToolButton['clickControl']" ref="clickControl" v-ripple class="tool-button" :class="buttonActiveClass('clickControl')" @click="buttonClick('clickControl')">
+                    <q-icon name="la la-mouse" size="32px" />
+                    <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
+                        {{ rstore.readerActions['clickControl'] }}
+                    </q-tooltip>
+                </button>
+                <button v-show="showToolButton['offlineMode']" ref="offlineMode" v-ripple class="tool-button" :class="buttonActiveClass('offlineMode')" @click="buttonClick('offlineMode')">
+                    <q-icon name="la la-unlink" size="32px" />
+                    <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
+                        {{ rstore.readerActions['offlineMode'] }}
+                    </q-tooltip>
+                </button>
+                <button ref="settings" v-ripple class="tool-button" :class="buttonActiveClass('settings')" @click="buttonClick('settings')">
+                    <q-icon name="la la-cog" size="32px" />
+                    <q-tooltip :delay="1500" anchor="bottom left" content-style="font-size: 80%">
+                        {{ rstore.readerActions['settings'] }}
+                    </q-tooltip>
+                </button>
             </div>
         </div>
 
@@ -304,6 +303,8 @@ class Reader {
     showRefreshIcon = true;
     mostRecentBookReactive = null;
     showToolButton = {};
+    toolBarHideOnScroll = false;
+    toolBarMultiLine = false;
 
     actionList = [];
     actionCur = -1;
@@ -466,6 +467,7 @@ class Reader {
         this.blinkCachedLoad = settings.blinkCachedLoad;
         this.showToolButton = settings.showToolButton;
         this.toolBarHideOnScroll = settings.toolBarHideOnScroll;
+        this.toolBarMultiLine = settings.toolBarMultiLine;
         this.enableSitesFilter = settings.enableSitesFilter;
         this.showNeedUpdateNotify = settings.showNeedUpdateNotify;
         this.splitToPara = settings.splitToPara;
@@ -543,9 +545,7 @@ class Reader {
 
             //обновим settings, если загружали обои из /upload/
             if (updated) {
-                const newSettings = _.cloneDeep(this.settings);
-                newSettings.needUpdateSettingsView = (newSettings.needUpdateSettingsView < 10 ? newSettings.needUpdateSettingsView + 1 : 0);
-                this.commit('reader/setSettings', newSettings);
+                this.commit('reader/setSettings', {});
             }
 
             dynamicCss.replace('wallpapers', newCss);
@@ -807,7 +807,7 @@ class Reader {
     }
 
     get offlineModeActive() {
-        return this.reader.offlineModeActive;        
+        return this.reader.offlineModeActive;
     }
 
     mostRecentBook() {
@@ -840,8 +840,7 @@ class Reader {
     }
 
     fullScreenToggle() {
-        this.fullScreenActive = !this.fullScreenActive;
-        if (this.fullScreenActive) {
+        if (!this.$q.fullscreen.isActive) {
             this.$q.fullscreen.request();
         } else {
             this.$q.fullscreen.exit();
@@ -1009,7 +1008,7 @@ class Reader {
     libsToogle() {
         this.libsActive = !this.libsActive;
         if (this.libsActive) {
-            this.$refs.libsPage.init();
+            this.$refs.libsPage.init();//no await
         } else {
             this.$refs.libsPage.done();
         }
@@ -1023,7 +1022,6 @@ class Reader {
 
     offlineModeToggle() {
         this.commit('reader/setOfflineModeActive', !this.offlineModeActive);
-        this.$refs.serverStorage.offlineModeActive = this.offlineModeActive;
     }
 
     settingsToggle() {
@@ -1652,33 +1650,27 @@ export default vueComponent(Reader);
 
 <style scoped>
 .header {
-    height: 50px;
-    padding-left: 5px;
-    padding-right: 5px;
+    padding: 5px 5px 0px 5px;
     background-color: #1B695F;
     color: #000;
     overflow-x: auto;
     overflow-y: hidden;
-    scrollbar-color: #c49a60 #e4e4e4;
+    scrollbar-color: #c4aa60 #e4e4e4;
 }
 
 .header::-webkit-scrollbar {
-    height: 10px;
+    height: 5px;
 }
  
 .header::-webkit-scrollbar-track {
-    background-color: #e4e4e4;
-    border-radius: 4px;
+    background-color: #1B695F;
+    border-radius: 1px;
 }
  
 .header::-webkit-scrollbar-thumb {
-    background-color: #c49a60;
-    border-radius: 4px;
-    border: 2px solid #e4e4e4;
-}
-
-.header::-webkit-scrollbar-thumb:hover {
-    background-color: #b48a50;
+    background-color: #c4aa60;
+    border-radius: 1px;
+    border: 1px solid #1B695F;
 }
 
 .main {
@@ -1687,11 +1679,12 @@ export default vueComponent(Reader);
 }
 
 .tool-button {
-    margin: 0px 2px 0 2px;
+    margin: 0px 2px 7px 2px;
     padding: 0;
     color: #3E843E;
     background-color: #E6EDF4;
-    margin-top: 5px;
+    min-height: 38px;
+    min-width: 38px;
     height: 38px;
     width: 38px;
     border: 0;

+ 30 - 23
client/components/Reader/ReaderDialogs/ReaderDialogs.vue

@@ -20,11 +20,11 @@
 
         <q-dialog ref="dialog2" v-model="donationVisible" style="z-index: 100" no-route-dismiss no-esc-dismiss no-backdrop-dismiss>
             <div class="column bg-white no-wrap q-pa-md">
-                <div class="row justify-center q-mb-md" style="font-size: 110%">
+                <div class="row justify-center q-mb-md">
                     Здравствуйте, дорогие читатели!
                 </div>
 
-                <div class="q-mx-md column" style="word-break: normal">
+                <div class="q-mx-md column" style="font-size: 90%; word-break: normal">
                     <div>
                         Вот уже много лет мы все вместе пользуемся нашей любимой читалкой.<br><br>
 
@@ -43,19 +43,31 @@
                         Однако на оплату хостинга читалки и сервера обновлений автор тратит свои 
                         собственные средства, а также тратит свое время и силы на улучшение проекта.
                         <br><br>
-                        Поддержим же материально наш ресурс, чтобы и дальше спокойно существовать и развиваться:
+                        Давайте поддержим наш ресурс, чтобы и дальше спокойно существовать и развиваться:
                     </div>
 
-                    <q-btn style="margin: 10px 50px 10px 50px" color="green-8" size="14px" no-caps @click="makeDonation">
+                    <q-btn style="margin: 10px 20px 10px 20px" color="green-8" no-caps @click="makeDonation">
                         <q-icon class="q-mr-xs" name="la la-donate" size="24px" />
                         Поддержать проект
                     </q-btn>
 
-                    <q-btn style="margin: 0 50px 20px 50px" size="14px" no-caps @click="donationDialogRemind">
-                        Напомнить в следующем месяце
-                    </q-btn>
+                    <div class="row justify-center q-mt-sm">
+                        Напомнить снова через:
+                    </div>
+
+                    <div class="row justify-between" style="margin: 0 20px 10px 20px">
+                        <q-btn style="width: 140px; margin-top: 5px" no-caps @click="donationDialogRemindLater(30)">
+                            1 месяц
+                        </q-btn>
+                        <q-btn style="width: 140px; margin-top: 5px" no-caps @click="donationDialogRemindLater(60)">
+                            2 месяца
+                        </q-btn>
+                        <q-btn style="width: 140px; margin-top: 5px" no-caps @click="donationDialogRemindLater(90)">
+                            3 месяца
+                        </q-btn>
+                    </div>
 
-                    <div class="row justify-center">
+                    <div class="row justify-center q-mt-md">
                         <div class="q-px-sm clickable" style="font-size: 80%" @click="openDonate">
                             Помочь проекту можно в любое время
                         </div>
@@ -71,12 +83,7 @@
             </template>
 
             <div style="word-break: normal">
-                Если вы хотите найти определенную книгу и открыть в читалке, добро пожаловать в
-                раздел "Сетевая библиотека" (кнопка <q-icon name="la la-sitemap" size="32px" />) на сайте
-                <a href="https://liberama.top" target="_blank">liberama.top</a>
-
-                <br><br>
-                Если же вы пытаетесь вставить текст в читалку из буфера обмена, пожалуйста воспользуйтесь кнопкой
+                Если вы пытаетесь вставить текст в читалку из буфера обмена, пожалуйста воспользуйтесь кнопкой
                 <q-btn no-caps dense class="q-px-sm" color="primary" size="13px" @click="loadBufferClick">
                     <q-icon class="q-mr-xs" name="la la-comment" size="24px" />
                     Из буфера обмена
@@ -94,6 +101,7 @@ import vueComponent from '../../vueComponent.js';
 import Dialog from '../../share/Dialog.vue';
 import * as utils from '../../../share/utils';
 import {versionHistory} from '../versionHistory';
+import rstore from '../../../store/modules/reader';
 
 const componentOptions = {
     components: {
@@ -135,7 +143,7 @@ class ReaderDialogs {
     async showWhatsNew() {
         const whatsNew = versionHistory[0];
         if (this.showWhatsNewDialog &&
-            whatsNew.showUntil >= utils.formatDate(new Date(), 'coDate') &&
+            whatsNew.showUntil >= utils.dateFormat(new Date(), 'YYYY-MM-DD') &&
             this.whatsNewHeader != this.whatsNewContentHash) {
             await utils.sleep(2000);
             this.whatsNewContent = 'Версия ' + this.whatsNewHeader + whatsNew.content;
@@ -144,9 +152,7 @@ class ReaderDialogs {
     }
 
     async showDonation() {
-        const today = utils.formatDate(new Date(), 'coMonth');
-
-        if ((this.mode == 'omnireader' || this.mode == 'liberama.top') && this.showDonationDialog && this.donationRemindDate != today) {
+        if ((this.mode == 'omnireader' || this.mode == 'liberama') && this.showDonationDialog && this.donationNextPopup <= Date.now()) {
             await utils.sleep(3000);
             this.donationVisible = true;
         }
@@ -161,14 +167,15 @@ class ReaderDialogs {
         this.urlHelpVisible = false;
     }
 
-    donationDialogRemind() {
+    donationDialogRemindLater(remindAfter = 30) {
         this.donationVisible = false;
-        this.commit('reader/setDonationRemindDate', utils.formatDate(new Date(), 'coMonth'));
+
+        this.commit('reader/setDonationNextPopup', Date.now() + rstore.dayMs*remindAfter);
     }
 
     makeDonation() {
         utils.makeDonation();
-        this.donationDialogRemind();
+        this.donationDialogRemindLater();
     }
 
     openDonate() {
@@ -209,8 +216,8 @@ class ReaderDialogs {
         return this.$store.state.reader.whatsNewContentHash;
     }
 
-    get donationRemindDate() {
-        return this.$store.state.reader.donationRemindDate;
+    get donationNextPopup() {
+        return this.$store.state.reader.donationNextPopup;
     }
 
     keyHook() {

+ 2 - 2
client/components/Reader/RecentBooksPage/RecentBooksPage.vue

@@ -367,10 +367,10 @@ class RecentBooksPage {
 
                 let d = new Date();
                 d.setTime(book.touchTime);
-                const touchTime = utils.formatDate(d);
+                const touchTime = utils.dateFormat(d, 'DD.MM.YYYY HH:mm');
                 const loadTimeRaw = (book.loadTime ? book.loadTime : 0);//book.addTime);
                 d.setTime(loadTimeRaw);
-                const loadTime = utils.formatDate(d);
+                const loadTime = utils.dateFormat(d, 'DD.MM.YYYY HH:mm');
 
                 let readPart = 0;
                 let perc = '';

+ 12 - 5
client/components/Reader/SearchPage/SearchPage.vue

@@ -20,10 +20,10 @@
             </div>
             <q-btn-group v-show="!initStep" class="button-group row no-wrap">
                 <q-btn class="button" dense stretch @click="showNext">
-                    <q-icon style="top: -6px" name="la la-angle-down" dense size="22px" />
+                    <q-icon style="top: -2px" name="la la-angle-down" dense size="22px" />
                 </q-btn>
                 <q-btn class="button" dense stretch @click="showPrev">
-                    <q-icon style="top: -4px" class="icon" name="la la-angle-up" dense size="22px" />
+                    <q-icon name="la la-angle-up" dense size="22px" />
                 </q-btn>
             </q-btn-group>
         </div>
@@ -108,10 +108,15 @@ class SearchPage {
 
         this.header = 'Поиск в тексте';
         await this.$nextTick();
-        this.$refs.input.focus();
+        this.focusInput();
         this.$refs.input.select();
     }
 
+    focusInput() {
+        if (!this.$root.isMobileDevice)
+            this.$refs.input.focus();
+    }
+
     get foundText() {
         if (this.foundList.length && this.foundCur >= 0)
             return `${this.foundCur + 1}/${this.foundList.length}`;
@@ -149,7 +154,8 @@ class SearchPage {
         } else {
             this.$emit('stop-text-search');
         }
-        this.$refs.input.focus();
+
+        this.focusInput();
     }
 
     showPrev() {
@@ -165,7 +171,8 @@ class SearchPage {
         } else {
             this.$emit('stop-text-search');
         }
-        this.$refs.input.focus();
+
+        this.focusInput();
     }
 
     close() {

+ 8 - 1
client/components/Reader/ServerStorage/ServerStorage.vue

@@ -49,6 +49,7 @@ class ServerStorage {
         this.keyInited = false;
         this.commit = this.$store.commit;
         this.prevServerStorageKey = null;
+        this.identity = utils.randomHexString(20);
         this.lock = new LockQueue(100);
 
         this.$root.generateNewServerStorageKey = () => {this.generateNewServerStorageKey()};
@@ -204,6 +205,10 @@ class ServerStorage {
         return this.$store.state.reader.libsRev;
     }
 
+    get offlineModeActive() {
+        return this.$store.state.reader.offlineModeActive;
+    }
+
     checkCurrentProfile() {
         if (!this.profiles[this.currentProfile]) {
             this.commit('reader/setCurrentProfile', '');
@@ -643,6 +648,8 @@ class ServerStorage {
                     await this.setCachedRecentPatch(newRecentPatch);
                 if (needSaveRecentMod && newRecentMod.rev)
                     await this.setCachedRecentMod(newRecentMod);
+            } else {
+                this.prevItemKey = null;
             }
         } finally {
             this.lock.ret();
@@ -665,7 +672,7 @@ class ServerStorage {
     }
 
     async storageApi(action, items, force) {
-        const request = {action, items};
+        const request = {action, identity: this.identity, items};
         if (force)
             request.force = true;
         const encodedRequest = await this.encodeStorageItems(request);

+ 0 - 87
client/components/Reader/SettingsPage/ConvertTab.inc

@@ -1,87 +0,0 @@
-<!---------------------------------------------->
-<div class="q-mt-sm column items-center">
-    <span>Настройки конвертирования применяются ко всем</span>
-    <span>вновь загружаемым или обновляемым файлам</span>
-</div>
-
-<!---------------------------------------------->
-<div class="part-header">HTML, XML, TXT</div>
-
-<div class="item row">
-    <div class="label-7">Текст</div>
-    <div class="col row">
-        <q-checkbox v-model="splitToPara" size="xs" label="Попытаться разбить текст на параграфы">
-            <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
-                Опция принудительно включает эвристику разбиения текста на<br>
-                параграфы в случае, если формат файла определен как html,<br>
-                xml или txt. Возможна нечитабельная разметка текста.
-            </q-tooltip>
-        </q-checkbox>
-    </div>
-</div>
-
-<div class="item row">
-    <div class="label-7">Сайты</div>
-    <div class="col row">
-        <q-checkbox v-model="enableSitesFilter" size="xs" label="Включить html-фильтр для сайтов">
-            <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
-                Html-фильтр вырезает лишние элементы со<br>
-                страницы для определенных сайтов, таких как:<br>
-                samlib.ru<br>
-                www.fanfiction.net<br>
-                archiveofourown.org<br>
-                и других
-            </q-tooltip>
-        </q-checkbox>
-    </div>
-</div>
-
-<!---------------------------------------------->
-<div v-if="isExternalConverter">
-    <div class="part-header">PDF</div>
-
-    <div class="item row">
-        <div class="label-7">Формат</div>
-        <div class="col row">
-            <q-checkbox v-model="pdfAsText" size="xs" label="Извлекать текст из PDF">
-                <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
-                    Пытается извлечь текст из pdf-файла и переразбить на параграфы.<br>
-                    Размер получаемого fb2-файла при этом относительно небольшой.<br>
-                    При отключении этой опции, pdf будет представлен как набор<br>
-                    изображений (аналогично ковертированию djvu).
-                </q-tooltip>
-            </q-checkbox>
-        </div>
-    </div>
-
-    <div class="item row">
-        <div class="label-7">Качество</div>
-        <div class="col row">
-            <NumInput class="col-5" v-model="pdfQuality" :min="10" :max="100" :disable="pdfAsText" >
-                <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
-                    Качество конвертирования Pdf в Fb2. Чем значение выше, тем больше<br>
-                    размер итогового файла. Если сервер отказывается конвертировать<br>
-                    слишком большой файл, то попробуйте понизить качество.
-                </q-tooltip>
-            </NumInput>
-        </div>
-    </div>
-</div>
-
-<!---------------------------------------------->
-<div v-if="isExternalConverter">
-    <div class="part-header">DJVU</div>
-
-    <div class="item row">
-        <div class="label-7">Качество</div>
-        <div class="col row">
-            <NumInput class="col-5" v-model="djvuQuality" :min="10" :max="100">
-                <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
-                    Качество конвертирования Djvu в Fb2. Чем значение выше, тем больше<br>
-                    размер итогового файла. Если сервер отказывается конвертировать<br>
-                    слишком большой файл, то попробуйте понизить качество.
-                </q-tooltip>
-            </NumInput>
-        </div>
-    </div>
-</div>

+ 145 - 0
client/components/Reader/SettingsPage/ConvertTab/ConvertTab.vue

@@ -0,0 +1,145 @@
+<template>
+    <div class="fit sets-tab-panel">
+        <!---------------------------------------------->
+        <div class="q-mt-sm column items-center">
+            <span>Настройки конвертирования применяются ко всем</span>
+            <span>вновь загружаемым или обновляемым файлам</span>
+        </div>
+
+        <!---------------------------------------------->
+        <div class="sets-part-header">
+            HTML, XML, TXT
+        </div>
+
+        <div class="sets-item row">
+            <div class="sets-label label">
+                Текст
+            </div>
+            <div class="col row">
+                <q-checkbox v-model="form.splitToPara" size="xs" label="Попытаться разбить текст на параграфы">
+                    <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
+                        Опция принудительно включает эвристику разбиения текста на<br>
+                        параграфы в случае, если формат файла определен как html,<br>
+                        xml или txt. Возможна нечитабельная разметка текста.
+                    </q-tooltip>
+                </q-checkbox>
+            </div>
+        </div>
+
+        <div class="sets-item row">
+            <div class="sets-label label">
+                Сайты
+            </div>
+            <div class="col row">
+                <q-checkbox v-model="form.enableSitesFilter" size="xs" label="Включить html-фильтр для сайтов">
+                    <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
+                        Html-фильтр вырезает лишние элементы со<br>
+                        страницы для определенных сайтов, таких как:<br>
+                        samlib.ru<br>
+                        www.fanfiction.net<br>
+                        archiveofourown.org<br>
+                        и других
+                    </q-tooltip>
+                </q-checkbox>
+            </div>
+        </div>
+
+        <!---------------------------------------------->
+        <div v-if="isExternalConverter">
+            <div class="sets-part-header">
+                PDF
+            </div>
+
+            <div class="sets-item row">
+                <div class="sets-label label">
+                    Формат
+                </div>
+                <div class="col row">
+                    <q-checkbox v-model="form.pdfAsText" size="xs" label="Извлекать текст из PDF">
+                        <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
+                            Пытается извлечь текст из pdf-файла и переразбить на параграфы.<br>
+                            Размер получаемого fb2-файла при этом относительно небольшой.<br>
+                            При отключении этой опции, pdf будет представлен как набор<br>
+                            изображений (аналогично ковертированию djvu).
+                        </q-tooltip>
+                    </q-checkbox>
+                </div>
+            </div>
+
+            <div v-if="!form.pdfAsText" class="sets-item row">
+                <div class="sets-label label">
+                    Качество
+                </div>
+                <div class="col row">
+                    <NumInput v-model="form.pdfQuality" class="col-5" :min="10" :max="100">
+                        <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
+                            Качество конвертирования Pdf в Fb2. Чем значение выше, тем больше<br>
+                            размер итогового файла. Если сервер отказывается конвертировать<br>
+                            слишком большой файл, то попробуйте понизить качество.
+                        </q-tooltip>
+                    </NumInput>
+                </div>
+            </div>
+        </div>
+
+        <!---------------------------------------------->
+        <div v-if="isExternalConverter">
+            <div class="sets-part-header">
+                DJVU
+            </div>
+
+            <div class="sets-item row">
+                <div class="sets-label label">
+                    Качество
+                </div>
+                <div class="col row">
+                    <NumInput v-model="form.djvuQuality" class="col-5" :min="10" :max="100">
+                        <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
+                            Качество конвертирования Djvu в Fb2. Чем значение выше, тем больше<br>
+                            размер итогового файла. Если сервер отказывается конвертировать<br>
+                            слишком большой файл, то попробуйте понизить качество.
+                        </q-tooltip>
+                    </NumInput>
+                </div>
+            </div>
+        </div>
+    </div>
+</template>
+
+<script>
+//-----------------------------------------------------------------------------
+import vueComponent from '../../../vueComponent.js';
+import NumInput from '../../../share/NumInput.vue';
+
+const componentOptions = {
+    components: {
+        NumInput
+    },
+};
+class ConvertTab {
+    _options = componentOptions;
+    _props = {
+        form: Object,
+    };
+
+    created() {
+    }
+
+    mounted() {
+    }
+
+    get isExternalConverter() {
+        return this.$store.state.config.useExternalBookConverter;
+    }
+}
+
+export default vueComponent(ConvertTab);
+//-----------------------------------------------------------------------------
+</script>
+
+<style scoped>
+.label {
+    width: 75px;
+}
+
+</style>

+ 0 - 33
client/components/Reader/SettingsPage/KeysTab.inc

@@ -1,33 +0,0 @@
-<div class="bg-grey-3 row">
-    <q-tabs
-        v-model="selectedKeysTab"
-        active-color="black"
-        active-bg-color="white"
-        indicator-color="white"
-        dense
-        no-caps
-        class="no-mp bg-grey-4 text-grey-7"
-    >
-        <q-tab name="mouse" label="Мышь/тачскрин" />
-        <q-tab name="keyboard" label="Клавиатура" />
-    </q-tabs>
-</div>
-
-<div class="q-mb-sm"/>
-
-<div class="col tab-panel">
-    <div v-if="selectedKeysTab == 'mouse'">
-        <div class="item row">
-            <div class="label-4"></div>
-            <div class="col row">
-                <q-checkbox size="xs" v-model="clickControl" label="Включить управление кликом" />
-            </div>
-        </div>
-    </div>
-
-    <div v-if="selectedKeysTab == 'keyboard'">
-        <div class="item row">
-            <UserHotKeys v-model="userHotKeys" />
-        </div>
-    </div>
-</div>

+ 78 - 0
client/components/Reader/SettingsPage/KeysTab/KeysTab.vue

@@ -0,0 +1,78 @@
+<template>
+    <div class="fit column">
+        <div class="bg-grey-3 row">
+            <q-tabs
+                v-model="selectedTab"
+                active-color="black"
+                active-bg-color="white"
+                indicator-color="white"
+                dense
+                no-caps
+                class="bg-grey-4 text-grey-7"
+            >
+                <q-tab name="mouse" label="Мышь/тачскрин" />
+                <q-tab name="keyboard" label="Клавиатура" />
+            </q-tabs>
+        </div>
+
+        <div class="q-mb-sm" />
+
+        <div class="col sets-tab-panel">
+            <div v-if="selectedTab == 'mouse'">
+                <div class="sets-item row">
+                    <div class="sets-label label"></div>
+                    <div class="col row">
+                        <q-checkbox v-model="form.clickControl" size="xs" label="Включить управление кликом" />
+                    </div>
+                </div>
+            </div>
+
+            <div v-if="selectedTab == 'keyboard'">
+                <div class="sets-item row">
+                    <UserHotKeys v-model="form.userHotKeys" />
+                </div>
+            </div>
+        </div>
+    </div>
+</template>
+
+<script>
+//-----------------------------------------------------------------------------
+import vueComponent from '../../../vueComponent.js';
+
+import UserHotKeys from './UserHotKeys/UserHotKeys.vue';
+
+const componentOptions = {
+    components: {
+        UserHotKeys,
+    },
+};
+class KeysTab {
+    _options = componentOptions;
+    _props = {
+        form: Object,
+    };
+
+    selectedTab = 'mouse';
+
+    created() {
+    }
+
+    mounted() {
+    }
+
+    get mode() {
+        return this.$store.state.config.mode;
+    }
+}
+
+export default vueComponent(KeysTab);
+//-----------------------------------------------------------------------------
+</script>
+
+<style scoped>
+.label {
+    width: 110px;
+}
+
+</style>

+ 3 - 4
client/components/Reader/SettingsPage/UserHotKeys/UserHotKeys.vue → client/components/Reader/SettingsPage/KeysTab/UserHotKeys/UserHotKeys.vue

@@ -73,10 +73,9 @@
 
 <script>
 //-----------------------------------------------------------------------------
-import vueComponent from '../../../vueComponent.js';
+import vueComponent from '../../../../vueComponent.js';
 
-import rstore from '../../../../store/modules/reader';
-//import * as utils from '../../share/utils';
+import rstore from '../../../../../store/modules/reader';
 
 const componentOptions = {
     watch: {
@@ -116,7 +115,7 @@ class UserHotKeys {
     }
 
     updateTableData() {
-        let result = rstore.hotKeys.map(hk => hk.name).filter(name => (this.mode == 'liberama.top' || name != 'libs'));
+        let result = rstore.hotKeys.map(hk => hk.name);
 
         const search = this.search.toLowerCase();
         const codesIncludeSearch = (action) => {

+ 0 - 91
client/components/Reader/SettingsPage/OthersTab.inc

@@ -1,91 +0,0 @@
-<!---------------------------------------------->
-<div class="part-header">Подсказки, уведомления</div>
-
-<div class="item row no-wrap">
-    <div class="label-6">Подсказка</div>
-    <q-checkbox size="xs" v-model="showClickMapPage" label="Показывать области управления кликом" :disable="!clickControl" >
-        <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
-            Показывать или нет подсказку при каждой загрузке книги
-        </q-tooltip>
-    </q-checkbox>
-</div>
-
-<div class="item row">
-    <div class="label-6">Подсказка</div>
-    <q-checkbox size="xs" v-model="blinkCachedLoad" label="Предупреждать о загрузке из кэша">
-        <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
-            Мерцать сообщением в строке статуса и на кнопке<br>
-            обновления при загрузке книги из кэша
-        </q-tooltip>
-    </q-checkbox>
-</div>
-
-<div class="item row no-wrap">
-    <div class="label-6">Уведомление</div>
-    <q-checkbox size="xs" v-model="showServerStorageMessages" label="Показывать сообщения синхронизации">
-        <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
-            Показывать уведомления и ошибки от<br>
-            синхронизатора данных с сервером
-        </q-tooltip>
-    </q-checkbox>
-</div>
-
-<div class="item row">
-    <div class="label-6">Уведомление</div>
-    <q-checkbox size="xs" v-model="showWhatsNewDialog">
-        Показывать уведомление "Что нового"
-        <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
-            Показывать уведомления "Что нового"<br>
-            при появлении новой версии читалки
-        </q-tooltip>
-    </q-checkbox>
-</div>
-
-<div class="item row">
-    <div class="label-6">Уведомление</div>
-    <q-checkbox size="xs" v-model="showDonationDialog">
-        Показывать форму доната
-        <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
-            Показывать диалог для сбора пожертвований
-        </q-tooltip>
-    </q-checkbox>
-</div>
-
-<!---------------------------------------------->
-<div class="part-header">Другое</div>
-
-<div class="item row">
-    <div class="label-6">Обработка</div>
-    <q-checkbox size="xs" v-model="lazyParseEnabled" label="Предварительная подготовка текста">
-        <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
-            Включение этой опции позволяет делать предварительную<br>
-            подготовку всего текста в ленивом режиме сразу после<br>
-            загрузки книги. Это может повысить отзывчивость читалки,<br>
-            но нагружает процессор каждый раз при открытии книги.
-        </q-tooltip>
-    </q-checkbox>
-</div>
-
-<div class="item row">
-    <div class="label-6">Парам. в URL</div>
-    <q-checkbox size="xs" v-model="allowUrlParamBookPos">
-        Добавлять параметр "__p"
-        <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
-            Добавление параметра "__p" в строке браузера<br>
-            позволяет передавать ссылку на книгу в читалке<br>
-            без потери текущей позиции. Однако в этом случае<br>
-            при листании забивается история браузера, т.к. на<br>
-            каждое изменение позиции происходит смена URL.
-        </q-tooltip>
-    </q-checkbox>
-</div>
-
-<div class="item row">
-    <div class="label-6">Копирование</div>
-    <q-checkbox size="xs" v-model="copyFullText" label="Загружать весь текст">
-        <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
-            Загружать весь текст в окно<br>
-            копирования текста со страницы
-        </q-tooltip>
-    </q-checkbox>
-</div>

+ 148 - 0
client/components/Reader/SettingsPage/OthersTab/OthersTab.vue

@@ -0,0 +1,148 @@
+<template>
+    <div class="fit sets-tab-panel">
+        <!---------------------------------------------->
+        <div class="sets-part-header">
+            Подсказки, уведомления
+        </div>
+
+        <div class="sets-item row no-wrap">
+            <div class="sets-label label">
+                Подсказка
+            </div>
+            <q-checkbox v-model="form.showClickMapPage" size="xs" label="Показывать области управления кликом" :disable="!form.clickControl">
+                <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
+                    Показывать или нет подсказку при каждой загрузке книги
+                </q-tooltip>
+            </q-checkbox>
+        </div>
+
+        <div class="sets-item row">
+            <div class="sets-label label">
+                Подсказка
+            </div>
+            <q-checkbox v-model="form.blinkCachedLoad" size="xs" label="Предупреждать о загрузке из кэша">
+                <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
+                    Мерцать сообщением в строке статуса и на кнопке<br>
+                    обновления при загрузке книги из кэша
+                </q-tooltip>
+            </q-checkbox>
+        </div>
+
+        <div class="sets-item row no-wrap">
+            <div class="sets-label label">
+                Уведомление
+            </div>
+            <q-checkbox v-model="form.showServerStorageMessages" size="xs" label="Показывать сообщения синхронизации">
+                <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
+                    Показывать уведомления и ошибки от<br>
+                    синхронизатора данных с сервером
+                </q-tooltip>
+            </q-checkbox>
+        </div>
+
+        <div class="sets-item row">
+            <div class="sets-label label">
+                Уведомление
+            </div>
+            <q-checkbox v-model="form.showWhatsNewDialog" size="xs">
+                Показывать уведомление "Что нового"
+                <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
+                    Показывать уведомления "Что нового"<br>
+                    при появлении новой версии читалки
+                </q-tooltip>
+            </q-checkbox>
+        </div>
+
+        <div class="sets-item row">
+            <div class="sets-label label">
+                Уведомление
+            </div>
+            <q-checkbox v-model="form.showDonationDialog" size="xs">
+                Показывать форму доната
+                <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
+                    Показывать диалог для сбора пожертвований
+                </q-tooltip>
+            </q-checkbox>
+        </div>
+
+        <!---------------------------------------------->
+        <div class="sets-part-header">
+            Другое
+        </div>
+
+        <div class="sets-item row">
+            <div class="sets-label label">
+                Обработка
+            </div>
+            <q-checkbox v-model="form.lazyParseEnabled" size="xs" label="Предварительная подготовка текста">
+                <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
+                    Включение этой опции позволяет делать предварительную<br>
+                    подготовку всего текста в ленивом режиме сразу после<br>
+                    загрузки книги. Это может повысить отзывчивость читалки,<br>
+                    но нагружает процессор каждый раз при открытии книги.
+                </q-tooltip>
+            </q-checkbox>
+        </div>
+
+        <div class="sets-item row">
+            <div class="sets-label label">
+                Парам. в URL
+            </div>
+            <q-checkbox v-model="form.allowUrlParamBookPos" size="xs">
+                Добавлять параметр "__p"
+                <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
+                    Добавление параметра "__p" в строке браузера<br>
+                    позволяет передавать ссылку на книгу в читалке<br>
+                    без потери текущей позиции. Однако в этом случае<br>
+                    при листании забивается история браузера, т.к. на<br>
+                    каждое изменение позиции происходит смена URL.
+                </q-tooltip>
+            </q-checkbox>
+        </div>
+
+        <div class="sets-item row">
+            <div class="sets-label label">
+                Копирование
+            </div>
+            <q-checkbox v-model="form.copyFullText" size="xs" label="Загружать весь текст">
+                <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
+                    Загружать весь текст в окно<br>
+                    копирования текста со страницы
+                </q-tooltip>
+            </q-checkbox>
+        </div>
+    </div>
+</template>
+
+<script>
+//-----------------------------------------------------------------------------
+import vueComponent from '../../../vueComponent.js';
+
+const componentOptions = {
+    components: {
+    },
+};
+class OthersTab {
+    _options = componentOptions;
+    _props = {
+        form: Object,
+    };
+
+    created() {
+    }
+
+    mounted() {
+    }
+
+}
+
+export default vueComponent(OthersTab);
+//-----------------------------------------------------------------------------
+</script>
+
+<style scoped>
+.label {
+    width: 100px;
+}
+
+</style>

+ 0 - 28
client/components/Reader/SettingsPage/PageMoveTab.inc

@@ -1,28 +0,0 @@
-<!---------------------------------------------->
-<div class="part-header">Анимация</div>
-
-<div class="item row">
-    <div class="label-5">Тип</div>
-    <q-select class="col-left" v-model="pageChangeAnimation" :options="pageChangeAnimationOptions"
-        dropdown-icon="la la-angle-down la-sm"
-        outlined dense emit-value map-options
-    />
-</div>
-
-<div class="item row">
-    <div class="label-5">Скорость</div>
-    <NumInput class="col-left" v-model="pageChangeAnimationSpeed" :min="0" :max="100" :disable="pageChangeAnimation == ''"/>
-</div>
-
-<!---------------------------------------------->
-<div class="part-header">Другое</div>
-
-<div class="item row">
-    <div class="label-5">Страница</div>
-    <q-checkbox v-model="keepLastToFirst" size="xs" label="Переносить последнюю строку">
-        <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
-            Переносить последнюю строку страницы<br>
-            в начало следующей при листании
-        </q-tooltip>
-    </q-checkbox>
-</div>

+ 96 - 0
client/components/Reader/SettingsPage/PageMoveTab/PageMoveTab.vue

@@ -0,0 +1,96 @@
+<template>
+    <div class="fit sets-tab-panel">
+        <!---------------------------------------------->
+        <div class="sets-part-header">
+            Анимация
+        </div>
+
+        <div class="sets-item row">
+            <div class="sets-label label">
+                Тип
+            </div>
+            <q-select
+                v-model="form.pageChangeAnimation" class="col-left" :options="pageChangeAnimationOptions"
+                dropdown-icon="la la-angle-down la-sm"
+                outlined dense emit-value map-options
+            />
+        </div>
+
+        <div class="sets-item row">
+            <div class="sets-label label">
+                Скорость
+            </div>
+            <NumInput v-model="form.pageChangeAnimationSpeed" class="col-left" :min="0" :max="100" :disable="form.pageChangeAnimation == ''" />
+        </div>
+
+        <!---------------------------------------------->
+        <div class="sets-part-header">
+            Другое
+        </div>
+
+        <div class="sets-item row">
+            <div class="sets-label label">
+                Страница
+            </div>
+            <q-checkbox v-model="form.keepLastToFirst" size="xs" label="Переносить последнюю строку">
+                <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
+                    Переносить последнюю строку страницы<br>
+                    в начало следующей при листании
+                </q-tooltip>
+            </q-checkbox>
+        </div>
+    </div>
+</template>
+
+<script>
+//-----------------------------------------------------------------------------
+import vueComponent from '../../../vueComponent.js';
+import NumInput from '../../../share/NumInput.vue';
+
+const componentOptions = {
+    components: {
+        NumInput,
+    },
+};
+class PageMoveTab {
+    _options = componentOptions;
+    _props = {
+        form: Object,
+    };
+
+    created() {
+    }
+
+    mounted() {
+    }
+
+    get pageChangeAnimationOptions() {
+        let result = [
+            {label: 'Нет', value: ''},
+            {label: 'Вверх-вниз', value: 'downShift'},
+            (!this.form.dualPageMode ? {label: 'Вправо-влево', value: 'rightShift'} : null),
+            {label: 'Протаивание', value: 'thaw'},
+            {label: 'Мерцание', value: 'blink'},
+            {label: 'Вращение', value: 'rotate'},
+            (this.form.wallpaper == '' && !this.form.dualPageMode ? {label: 'Листание', value: 'flip'} : null),
+        ];        
+
+        result = result.filter(v => v);
+
+        return result;
+    }
+}
+
+export default vueComponent(PageMoveTab);
+//-----------------------------------------------------------------------------
+</script>
+
+<style scoped>
+.label {
+    width: 110px;
+}
+
+.col-left {
+    width: 150px;
+}
+</style>

+ 0 - 101
client/components/Reader/SettingsPage/ProfilesTab.inc

@@ -1,101 +0,0 @@
-<div class="part-header">Управление синхронизацией данных</div>
-
-<div class="item row">
-    <div class="label-1"></div>
-    <q-checkbox class="col" v-model="serverSyncEnabled" size="xs" label="Включить синхронизацию с сервером" />
-</div>
-
-<div v-show="serverSyncEnabled">
-    <!---------------------------------------------->
-    <div class="part-header">Профили устройств</div>
-
-    <div class="item row">
-        <div class="label-1"></div>
-        <div class="text col">
-            Выберите или добавьте профиль устройства, чтобы начать синхронизацию настроек с сервером.
-            <br>При выборе "Нет" синхронизация настроек (но не книг) отключается.
-        </div>
-    </div>
-     <div class="item row">
-        <div class="label-1">Устройство</div>
-        <div class="col">
-            <q-select v-model="currentProfile" :options="currentProfileOptions"
-                style="width: 275px"
-                dropdown-icon="la la-angle-down la-sm"
-                outlined dense emit-value map-options display-value-sanitize options-sanitize
-            />
-        </div>
-    </div>
-    <div class="item row">
-        <div class="label-1"></div>
-        <q-btn class="button" dense no-caps @click="addProfile">Добавить</q-btn>
-        <q-btn class="button" dense no-caps @click="delProfile">Удалить</q-btn>
-        <q-btn class="button" dense no-caps @click="delAllProfiles">Удалить все</q-btn>
-    </div>
-
-    <!---------------------------------------------->
-    <div class="part-header">Ключ доступа</div>
-    
-    <div class="item row">
-        <div class="label-1"></div>
-        <div class="text col">
-            Ключ доступа позволяет восстановить профили с настройками и список читаемых книг.
-            Для этого необходимо передать ключ на новое устройство через почту, мессенджер или другим способом.
-        </div>
-    </div>
-
-    <div class="item row">
-        <div class="label-1"></div>
-        <q-btn class="button" style="width: 250px" dense no-caps @click="showServerStorageKey">
-                <span v-show="serverStorageKeyVisible">Скрыть</span>
-                <span v-show="!serverStorageKeyVisible">Показать</span>
-                &nbsp;ключ доступа
-         </q-btn>
-    </div>
-
-    <div class="item row">
-        <div class="label-1"></div>
-        <div v-if="!serverStorageKeyVisible" class="col">
-            <hr/>
-            <b>{{ partialStorageKey }}</b> (часть вашего ключа)
-            <hr/>
-        </div>
-        <div v-else class="col" style="line-height: 100%">
-            <hr/>
-            <div style="width: 300px; padding-top: 5px; overflow-wrap: break-word;">
-                <b>{{ serverStorageKey }}</b>
-                <q-icon class="copy-icon" name="la la-copy" @click="copyToClip(serverStorageKey, 'Ключ')">
-                    <q-tooltip :delay="1000" anchor="top middle" self="center middle" content-style="font-size: 80%">Скопировать</q-tooltip>                    
-                </q-icon>            
-            </div>
-            <div v-if="mode == 'omnireader' || mode == 'liberama.top'">
-                <br>Переход по ссылке позволит автоматически ввести ключ доступа:
-                <br><div class="text-center" style="margin-top: 5px">
-                    <a :href="setStorageKeyLink" target="_blank">Ссылка для ввода ключа</a>
-                    <q-icon class="copy-icon" name="la la-copy" @click="copyToClip(setStorageKeyLink, 'Ссылка')">
-                        <q-tooltip :delay="1000" anchor="top middle" self="center middle" content-style="font-size: 80%">Скопировать</q-tooltip>                    
-                    </q-icon>            
-                </div>
-            </div>
-            <hr/>
-        </div>
-    </div>
-
-    <div class="item row">
-        <div class="label-1"></div>
-        <q-btn class="button" style="width: 250px" dense no-caps @click="enterServerStorageKey">Ввести ключ доступа</q-btn>
-    </div>
-    <div class="item row">
-        <div class="label-1"></div>
-        <q-btn class="button" style="width: 250px" dense no-caps @click="generateServerStorageKey">Сгенерировать новый ключ</q-btn>
-    </div>
-    <div class="item row">
-        <div class="label-1"></div>
-        <div class="text col">
-            Рекомендуется сохранить ключ в надежном месте, чтобы всегда иметь возможность восстановить настройки,
-            например, после переустановки ОС или чистки/смены браузера.<br>
-            <b>ПРЕДУПРЕЖДЕНИЕ!</b> При утере ключа, НИКТО не сможет восстановить ваши данные, т.к. они сжимаются
-            и шифруются ключом доступа перед отправкой на сервер.
-        </div>
-    </div>
-</div>

+ 362 - 0
client/components/Reader/SettingsPage/ProfilesTab/ProfilesTab.vue

@@ -0,0 +1,362 @@
+<template>
+    <div class="fit sets-tab-panel">
+        <div class="sets-part-header">
+            Управление синхронизацией данных
+        </div>
+
+        <div class="sets-item row">
+            <div class="sets-label label"></div>
+            <q-checkbox v-model="serverSyncEnabled" class="col" size="xs" label="Включить синхронизацию с сервером" />
+        </div>
+
+        <div v-show="serverSyncEnabled">
+            <!---------------------------------------------->
+            <div class="sets-part-header">
+                Профили устройств
+            </div>
+
+            <div class="sets-item row">
+                <div class="sets-label label"></div>
+                <div class="text col">
+                    Выберите или добавьте профиль устройства, чтобы начать синхронизацию настроек с сервером.
+                    <br>При выборе "Нет" синхронизация настроек (но не книг) отключается.
+                </div>
+            </div>
+            <div class="sets-item row">
+                <div class="sets-label label">
+                    Устройство
+                </div>
+                <div class="col">
+                    <q-select
+                        v-model="currentProfile" :options="currentProfileOptions"
+                        style="width: 275px"
+                        dropdown-icon="la la-angle-down la-sm"
+                        outlined dense emit-value map-options display-value-sanitize options-sanitize
+                    />
+                </div>
+            </div>
+            <div class="sets-item row">
+                <div class="sets-label label"></div>
+                <q-btn class="sets-button" dense no-caps @click="addProfile">
+                    Добавить
+                </q-btn>
+                <q-btn class="sets-button" dense no-caps @click="delProfile">
+                    Удалить
+                </q-btn>
+                <q-btn class="sets-button" dense no-caps @click="delAllProfiles">
+                    Удалить все
+                </q-btn>
+            </div>
+
+            <!---------------------------------------------->
+            <div class="sets-part-header">
+                Ключ доступа
+            </div>
+            
+            <div class="sets-item row">
+                <div class="sets-label label"></div>
+                <div class="text col">
+                    Ключ доступа позволяет восстановить профили с настройками и список читаемых книг.
+                    Для этого необходимо передать ключ на новое устройство через почту, мессенджер или другим способом.
+                </div>
+            </div>
+
+            <div class="sets-item row">
+                <div class="sets-label label"></div>
+                <q-btn class="sets-button" style="width: 250px" dense no-caps @click="showServerStorageKey">
+                    <span v-show="serverStorageKeyVisible">Скрыть</span>
+                    <span v-show="!serverStorageKeyVisible">Показать</span>
+                    &nbsp;ключ доступа
+                </q-btn>
+            </div>
+
+            <div class="sets-item row">
+                <div class="sets-label label"></div>
+                <div v-if="!serverStorageKeyVisible" class="col">
+                    <hr />
+                    <b>{{ partialStorageKey }}</b> (часть вашего ключа)
+                    <hr />
+                </div>
+                <div v-else class="col" style="line-height: 100%">
+                    <hr />
+                    <div style="width: 300px; padding-top: 5px; overflow-wrap: break-word;">
+                        <b>{{ serverStorageKey }}</b>
+                        <q-icon class="copy-icon" name="la la-copy" @click="copyToClip(serverStorageKey, 'Ключ')">
+                            <q-tooltip :delay="1000" anchor="top middle" self="center middle" content-style="font-size: 80%">
+                                Скопировать
+                            </q-tooltip>                    
+                        </q-icon>            
+                    </div>
+                    <div v-if="mode == 'omnireader' || mode == 'liberama'">
+                        <br>Переход по ссылке позволит автоматически ввести ключ доступа:
+                        <br><div class="text-center" style="margin-top: 5px">
+                            <a :href="setStorageKeyLink" target="_blank">Ссылка для ввода ключа</a>
+                            <q-icon class="copy-icon" name="la la-copy" @click="copyToClip(setStorageKeyLink, 'Ссылка')">
+                                <q-tooltip :delay="1000" anchor="top middle" self="center middle" content-style="font-size: 80%">
+                                    Скопировать
+                                </q-tooltip>                    
+                            </q-icon>            
+                        </div>
+                    </div>
+                    <hr />
+                </div>
+            </div>
+
+            <div class="sets-item row">
+                <div class="sets-label label"></div>
+                <q-btn class="sets-button" style="width: 250px" dense no-caps @click="enterServerStorageKey">
+                    Ввести ключ доступа
+                </q-btn>
+            </div>
+            <div class="sets-item row">
+                <div class="sets-label label"></div>
+                <q-btn class="sets-button" style="width: 250px" dense no-caps @click="generateServerStorageKey">
+                    Сгенерировать новый ключ
+                </q-btn>
+            </div>
+            <div class="sets-item row">
+                <div class="sets-label label"></div>
+                <div class="text col">
+                    Рекомендуется сохранить ключ в надежном месте, чтобы всегда иметь возможность восстановить настройки,
+                    например, после переустановки ОС или чистки/смены браузера.<br>
+                    <b>ПРЕДУПРЕЖДЕНИЕ!</b> При утере ключа, НИКТО не сможет восстановить ваши данные, т.к. они сжимаются
+                    и шифруются ключом доступа перед отправкой на сервер.
+                </div>
+            </div>
+        </div>
+    </div>
+</template>
+
+<script>
+//-----------------------------------------------------------------------------
+import vueComponent from '../../../vueComponent.js';
+
+import _ from 'lodash';
+
+import * as utils from '../../../../share/utils';
+import rstore from '../../../../store/modules/reader';
+
+const componentOptions = {
+    watch: {
+    },
+};
+class ProfilesTab {
+    _options = componentOptions;
+    _props = {
+        form: Object,
+    };
+
+    rstore = rstore;
+
+    serverStorageKeyVisible = false;
+
+    created() {
+        this.commit = this.$store.commit;
+    }
+
+    mounted() {
+    }
+
+    get mode() {
+        return this.$store.state.config.mode;
+    }
+
+    get serverSyncEnabled() {
+        return this.$store.state.reader.serverSyncEnabled;
+    }
+
+    set serverSyncEnabled(newValue) {
+        this.commit('reader/setServerSyncEnabled', newValue);
+    }
+
+    get currentProfile() {
+        return this.$store.state.reader.currentProfile;
+    }
+
+    set currentProfile(newValue) {
+        this.commit('reader/setCurrentProfile', newValue);
+    }
+
+    get profiles() {
+        return this.$store.state.reader.profiles;
+    }
+
+    get currentProfileOptions() {
+        const profNames = Object.keys(this.profiles)
+        profNames.sort();
+
+        let result = [{label: 'Нет', value: ''}];
+        profNames.forEach(name => {
+            result.push({label: name, value: name});
+        });
+        return result;
+    }
+
+    get partialStorageKey() {
+        return this.serverStorageKey.substr(0, 7) + '***';
+    }
+
+    get serverStorageKey() {
+        return this.$store.state.reader.serverStorageKey;
+    }
+
+    get setStorageKeyLink() {
+        return `https://${window.location.host}/#/reader?setStorageAccessKey=${utils.toBase58(this.serverStorageKey)}`;
+    }
+
+    async addProfile() {
+        try {
+            if (Object.keys(this.profiles).length >= 100) {
+                this.$root.stdDialog.alert('Достигнут предел количества профилей', 'Ошибка');
+                return;
+            }
+            const result = await this.$root.stdDialog.prompt('Введите произвольное название для профиля устройства:', ' ', {
+                inputValidator: (str) => { if (!str) return 'Название не должно быть пустым'; else if (str.length > 50) return 'Слишком длинное название'; else return true; },
+            });
+            if (result && result.value) {
+                if (this.profiles[result.value]) {
+                    this.$root.stdDialog.alert('Такой профиль уже существует', 'Ошибка');
+                } else {
+                    const newProfiles = Object.assign({}, this.profiles, {[result.value]: 1});
+                    this.commit('reader/setAllowProfilesSave', true);
+                    await this.$nextTick();//ждем обработчики watch
+                    this.commit('reader/setProfiles', newProfiles);
+                    await this.$nextTick();//ждем обработчики watch
+                    this.commit('reader/setAllowProfilesSave', false);
+                    this.currentProfile = result.value;
+                }
+            }
+        } catch (e) {
+            //
+        }
+    }
+
+    async delProfile() {
+        if (!this.currentProfile)
+            return;
+
+        try {
+            const result = await this.$root.stdDialog.prompt(`<b>Предупреждение!</b> Удаление профиля '${this.$root.sanitize(this.currentProfile)}' необратимо.` +
+                    `<br>Все настройки профиля будут потеряны, однако список читаемых книг сохранится.` +
+                    `<br><br>Введите 'да' для подтверждения удаления:`, ' ', {
+                inputValidator: (str) => { if (str && str.toLowerCase() === 'да') return true; else return 'Удаление не подтверждено'; },
+            });
+
+            if (result && result.value && result.value.toLowerCase() == 'да') {
+                if (this.profiles[this.currentProfile]) {
+                    const newProfiles = Object.assign({}, this.profiles);
+                    delete newProfiles[this.currentProfile];
+                    this.commit('reader/setAllowProfilesSave', true);
+                    await this.$nextTick();//ждем обработчики watch
+                    this.commit('reader/setProfiles', newProfiles);
+                    await this.$nextTick();//ждем обработчики watch
+                    this.commit('reader/setAllowProfilesSave', false);
+                    this.currentProfile = '';
+                }
+            }
+        } catch (e) {
+            //
+        }
+    }
+
+    async delAllProfiles() {
+        if (!Object.keys(this.profiles).length)
+            return;
+
+        try {
+            const result = await this.$root.stdDialog.prompt(`<b>Предупреждение!</b> Удаление ВСЕХ профилей с настройками необратимо.` +
+                    `<br><br>Введите 'да' для подтверждения удаления:`, ' ', {
+                inputValidator: (str) => { if (str && str.toLowerCase() === 'да') return true; else return 'Удаление не подтверждено'; },
+            });
+
+            if (result && result.value && result.value.toLowerCase() == 'да') {
+                this.commit('reader/setAllowProfilesSave', true);
+                await this.$nextTick();//ждем обработчики watch
+                this.commit('reader/setProfiles', {});
+                await this.$nextTick();//ждем обработчики watch
+                this.commit('reader/setAllowProfilesSave', false);
+                this.currentProfile = '';
+            }
+        } catch (e) {
+            //
+        }
+    }
+
+    async showServerStorageKey() {
+        this.serverStorageKeyVisible = !this.serverStorageKeyVisible;
+    }
+
+    async enterServerStorageKey(key) {
+        try {
+            const result = await this.$root.stdDialog.prompt(`<b>Предупреждение!</b> Изменение ключа доступа приведет к замене всех профилей и читаемых книг в читалке.` +
+                    `<br><br>Введите новый ключ доступа:`, ' ', {
+                inputValidator: (str) => {
+                    try {
+                        if (str && utils.fromBase58(str).length == 32) {
+                            return true;
+                        }
+                    } catch (e) {
+                        //
+                    }
+                    return 'Неверный формат ключа'; 
+                },
+                inputValue: (key && _.isString(key) ? key : null),
+            });
+
+            if (result && result.value && utils.fromBase58(result.value).length == 32) {
+                this.commit('reader/setServerStorageKey', result.value);
+            }
+        } catch (e) {
+            //
+        }
+    }
+
+    async generateServerStorageKey() {
+        try {
+            const result = await this.$root.stdDialog.prompt(`<b>Предупреждение!</b> Генерация нового ключа доступа приведет к удалению всех профилей и читаемых книг в читалке.` +
+                    `<br><br>Введите 'да' для подтверждения генерации нового ключа:`, ' ', {
+                inputValidator: (str) => { if (str && str.toLowerCase() === 'да') return true; else return 'Генерация не подтверждена'; },
+            });
+
+            if (result && result.value && result.value.toLowerCase() == 'да') {
+                if (this.$root.generateNewServerStorageKey)
+                    this.$root.generateNewServerStorageKey();
+            }
+        } catch (e) {
+            //
+        }
+
+    }
+
+    async copyToClip(text, prefix) {
+        const result = await utils.copyTextToClipboard(text);
+        const suf = (prefix.substr(-1) == 'а' ? 'а' : '');
+        const msg = (result ? `${prefix} успешно скопирован${suf} в буфер обмена` : 'Копирование не удалось');
+        if (result)
+            this.$root.notify.success(msg);
+        else
+            this.$root.notify.error(msg);
+    }
+}
+
+export default vueComponent(ProfilesTab);
+//-----------------------------------------------------------------------------
+</script>
+
+<style scoped>
+.label {
+    width: 75px;
+}
+
+.text {
+    font-size: 90%;
+    line-height: 130%;
+}
+
+.copy-icon {
+    margin-left: 5px;
+    cursor: pointer;
+    font-size: 120%;
+    color: blue;
+}
+</style>

+ 0 - 3
client/components/Reader/SettingsPage/ResetTab.inc

@@ -1,3 +0,0 @@
-<div class="item row">
-    <q-btn class="col q-ma-sm" dense no-caps @click="setDefaults">Установить по умолчанию</q-btn>
-</div>

+ 41 - 0
client/components/Reader/SettingsPage/ResetTab/ResetTab.vue

@@ -0,0 +1,41 @@
+<template>
+    <div class="fit sets-tab-panel">
+        <div class="sets-item row">
+            <q-btn class="col q-ma-sm" dense no-caps @click="setDefaults">
+                Установить по умолчанию
+            </q-btn>
+        </div>
+    </div>
+</template>
+
+<script>
+//-----------------------------------------------------------------------------
+import vueComponent from '../../../vueComponent.js';
+
+const componentOptions = {
+    components: {
+    },
+};
+class ResetTab {
+    _options = componentOptions;
+    _props = {
+        form: Object,
+    };
+
+    created() {
+    }
+
+    mounted() {
+    }
+
+    setDefaults() {
+        this.$emit('tab-event', {action: 'set-defaults'});
+    }
+}
+
+export default vueComponent(ResetTab);
+//-----------------------------------------------------------------------------
+</script>
+
+<style scoped>
+</style>

+ 89 - 649
client/components/Reader/SettingsPage/SettingsPage.vue

@@ -5,13 +5,12 @@
         </template>
 
         <div class="col row">
-            <a ref="download" style="display: none;" target="_blank"></a>
-
             <div class="full-height">
                 <q-tabs
                     ref="tabs"
                     v-model="selectedTab"
-                    class="bg-grey-3 text-black"
+                    class="bg-grey-3 text-grey-9"
+                    style="max-width: 130px"
                     
                     left-icon="la la-caret-up"
                     right-icon="la la-caret-down"
@@ -23,95 +22,34 @@
                     stretch
                     inline-label
                 >
-                    <div v-show="tabsScrollable" class="q-pt-lg" />
-                    <q-tab class="tab" name="profiles" icon="la la-users" label="Профили" />
-                    <q-tab class="tab" name="view" icon="la la-eye" label="Вид" />
-                    <q-tab class="tab" name="toolbar" icon="la la-grip-horizontal" label="Панель" />
-                    <q-tab class="tab" name="keys" icon="la la-gamepad" label="Управление" />
-                    <q-tab class="tab" name="pagemove" icon="la la-school" label="Листание" />
-                    <q-tab class="tab" name="convert" icon="la la-magic" label="Конвертир." />
-                    <q-tab class="tab" name="update" icon="la la-sync" label="Обновление" />
-                    <q-tab class="tab" name="others" icon="la la-list-ul" label="Прочее" />
-                    <q-tab class="tab" name="reset" icon="la la-broom" label="Сброс" />
-                    <div v-show="tabsScrollable" class="q-pt-lg" />
+                    <q-tab v-for="item in tabs" :key="item.name" class="tab row items-center" :name="item.name">
+                        <q-icon :name="item.icon" :color="selectedTab == item.name ? 'yellow' : 'teal-7'" size="24px" />
+                        <div class="q-ml-xs" style="font-size: 90%">
+                            {{ item.label }}
+                        </div>
+                    </q-tab>
                 </q-tabs>
             </div>
 
             <div class="col fit">
                 <!-- Профили --------------------------------------------------------------------->
-                <div v-if="selectedTab == 'profiles'" class="fit tab-panel">
-                    @@include('./ProfilesTab.inc');
-                </div>
+                <ProfilesTab v-if="selectedTab == 'profiles'" :form="form" />
                 <!-- Вид ------------------------------------------------------------------------->                    
-                <div v-if="selectedTab == 'view'" class="fit column">
-                    <q-tabs
-                        v-model="selectedViewTab"
-                        active-color="black"
-                        active-bg-color="white"
-                        indicator-color="white"
-                        dense
-                        no-caps
-                        class="no-mp bg-grey-4 text-grey-7"
-                    >
-                        <q-tab name="mode" label="Режим" />
-                        <q-tab name="color" label="Цвет" />
-                        <q-tab name="font" label="Шрифт" />
-                        <q-tab name="text" label="Текст" />
-                        <q-tab name="status" label="Строка статуса" />
-                    </q-tabs>
-
-                    <div class="q-mb-sm" />
-
-                    <div class="col tab-panel">
-                        <div v-if="selectedViewTab == 'mode'">
-                            @@include('./ViewTab/Mode.inc');
-                        </div>
-
-                        <div v-if="selectedViewTab == 'color'">
-                            @@include('./ViewTab/Color.inc');
-                        </div>
-
-                        <div v-if="selectedViewTab == 'font'">
-                            @@include('./ViewTab/Font.inc');
-                        </div>
-
-                        <div v-if="selectedViewTab == 'text'">
-                            @@include('./ViewTab/Text.inc');
-                        </div>
-
-                        <div v-if="selectedViewTab == 'status'">
-                            @@include('./ViewTab/Status.inc');
-                        </div>
-                    </div>
-                </div>
+                <ViewTab v-if="selectedTab == 'view'" :form="form" />
                 <!-- Кнопки ---------------------------------------------------------------------->
-                <div v-if="selectedTab == 'toolbar'" class="fit tab-panel">
-                    @@include('./ToolBarTab.inc');
-                </div>
+                <ToolBarTab v-if="selectedTab == 'toolbar'" :form="form" />
                 <!-- Управление ------------------------------------------------------------------>
-                <div v-if="selectedTab == 'keys'" class="fit column">
-                    @@include('./KeysTab.inc');
-                </div>
+                <KeysTab v-if="selectedTab == 'keys'" :form="form" />
                 <!-- Листание -------------------------------------------------------------------->
-                <div v-if="selectedTab == 'pagemove'" class="fit tab-panel">
-                    @@include('./PageMoveTab.inc');
-                </div>
+                <PageMoveTab v-if="selectedTab == 'pagemove'" :form="form" />
                 <!-- Конвертирование ------------------------------------------------------------->
-                <div v-if="selectedTab == 'convert'" class="fit tab-panel">
-                    @@include('./ConvertTab.inc');
-                </div>
+                <ConvertTab v-if="selectedTab == 'convert'" :form="form" />
                 <!-- Обновление ------------------------------------------------------------------>
-                <div v-if="selectedTab == 'update'" class="fit tab-panel">
-                    @@include('./UpdateTab.inc');
-                </div>
+                <UpdateTab v-if="selectedTab == 'update'" :form="form" />
                 <!-- Прочее ---------------------------------------------------------------------->
-                <div v-if="selectedTab == 'others'" class="fit tab-panel">
-                    @@include('./OthersTab.inc');
-                </div>
-                <!-- Сброс ----------------------------------------------------------------------->
-                <div v-if="selectedTab == 'reset'" class="fit tab-panel">
-                    @@include('./ResetTab.inc');
-                </div>
+                <OthersTab v-if="selectedTab == 'others'" :form="form" />
+                <!-- Сброс ----------------------------------------------------------------------->                
+                <ResetTab v-if="selectedTab == 'reset'" :form="form" @tab-event="tabEvent" />
             </div>
         </div>
     </Window>
@@ -119,152 +57,86 @@
 
 <script>
 //-----------------------------------------------------------------------------
-import { ref, watch } from 'vue';
 import vueComponent from '../../vueComponent.js';
+import { reactive } from 'vue';
 
 import _ from 'lodash';
 
-import * as utils from '../../../share/utils';
-import * as cryptoUtils from '../../../share/cryptoUtils';
+//stuff
 import Window from '../../share/Window.vue';
-import NumInput from '../../share/NumInput.vue';
-import UserHotKeys from './UserHotKeys/UserHotKeys.vue';
-import wallpaperStorage from '../share/wallpaperStorage';
 
-import readerApi from '../../../api/reader';
 import rstore from '../../../store/modules/reader';
-import defPalette from './defPalette';
 
-const hex = /^#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$/;
+//pages
+import ProfilesTab from './ProfilesTab/ProfilesTab.vue';
+import ViewTab from './ViewTab/ViewTab.vue';
+import ToolBarTab from './ToolBarTab/ToolBarTab.vue';
+import KeysTab from './KeysTab/KeysTab.vue';
+import PageMoveTab from './PageMoveTab/PageMoveTab.vue';
+import ConvertTab from './ConvertTab/ConvertTab.vue';
+import UpdateTab from './UpdateTab/UpdateTab.vue';
+import OthersTab from './OthersTab/OthersTab.vue';
+import ResetTab from './ResetTab/ResetTab.vue';
 
 const componentOptions = {
     components: {
         Window,
-        NumInput,
-        UserHotKeys,
-    },
-    data: function() {
-        return Object.assign({}, rstore.settingDefaults);
+        //pages
+        ProfilesTab,
+        ViewTab,
+        ToolBarTab,
+        KeysTab,
+        PageMoveTab,
+        ConvertTab,
+        UpdateTab,
+        OthersTab,
+        ResetTab,
     },
     watch: {
         settings: function() {
-            this.settingsChanged();
-        },
-        form: function(newValue) {
-            if (this.inited) {
-                this.commit('reader/setSettings', _.cloneDeep(newValue));
-            }
-        },
-        fontBold: function(newValue) {
-            this.fontWeight = (newValue ? 'bold' : '');
-        },
-        fontItalic: function(newValue) {
-            this.fontStyle = (newValue ? 'italic' : '');
-        },
-        vertShift: function(newValue) {
-            const font = (this.webFontName ? this.webFontName : this.fontName);
-            if (this.fontShifts[font] != newValue || this.fontVertShift != newValue) {
-                this.fontShifts = Object.assign({}, this.fontShifts, {[font]: newValue});
-                this.fontVertShift = newValue;
-            }
-        },
-        fontName: function(newValue) {
-            const font = (this.webFontName ? this.webFontName : newValue);
-            this.vertShift = this.fontShifts[font] || 0;
-        },
-        webFontName: function(newValue) {
-            const font = (newValue ? newValue : this.fontName);
-            this.vertShift = this.fontShifts[font] || 0;
-        },
-        wallpaper: function(newValue) {
-            if (newValue != '' && this.pageChangeAnimation == 'flip')
-                this.pageChangeAnimation = '';
-        },
-        dualPageMode(newValue) {
-            if (newValue && this.pageChangeAnimation == 'flip' || this.pageChangeAnimation == 'rightShift')
-                this.pageChangeAnimation = '';
-        },
-        textColor: function(newValue) {
-            this.textColorFiltered = newValue;
-        },
-        textColorFiltered: function(newValue) {
-            if (hex.test(newValue))
-                this.textColor = newValue;
+            this.settingsChanged();//no await
         },
-        backgroundColor: function(newValue) {
-            this.bgColorFiltered = newValue;
-        },
-        bgColorFiltered: function(newValue) {
-            if (hex.test(newValue))
-                this.backgroundColor = newValue;
-        },
-        dualDivColor(newValue) {
-            this.dualDivColorFiltered = newValue;
-        },
-        dualDivColorFiltered(newValue) {
-            if (hex.test(newValue))
-                this.dualDivColor = newValue;
-        },
-        statusBarColor(newValue) {
-            this.statusBarColorFiltered = newValue;
-        },
-        statusBarColorFiltered(newValue) {
-            if (hex.test(newValue))
-                this.statusBarColor = newValue;
+        form: {
+            handler() {
+                if (this.inited && !this.isSetsChanged) {
+                    this.debouncedCommitSettings();
+                }
+            },
+            deep: true,
         },
     },
 };
 class SettingsPage {
     _options = componentOptions;
 
+    form = {};
+
+    tabs = [
+        { name: 'profiles', icon: 'la la-users', label: 'Профили' },
+        { name: 'view', icon: 'la la-eye', label: 'Вид'},
+        { name: 'toolbar', icon: 'la la-grip-horizontal', label: 'Панель'},
+        { name: 'keys', icon: 'la la-gamepad', label: 'Управление'},
+        { name: 'pagemove', icon: 'la la-school', label: 'Листание'},
+        { name: 'convert', icon: 'la la-magic', label: 'Конвертир.'},
+        { name: 'update', icon: 'la la-retweet', label: 'Обновление'},
+        { name: 'others', icon: 'la la-list-ul', label: 'Прочее'},
+        { name: 'reset', icon: 'la la-broom', label: 'Сброс'},
+    ];
     selectedTab = 'profiles';
-    selectedViewTab = 'mode';
-    selectedKeysTab = 'mouse';
-    fontBold = false;
-    fontItalic = false;
-    vertShift = 0;
-    tabsScrollable = false;
-    textColorFiltered = '';
-    bgColorFiltered = '';
-    dualDivColorFiltered = '';
-
-    webFonts = [];
-    fonts = [];
 
-    serverStorageKeyVisible = false;
-    toolButtons = [];
-    rstore = {};
-
-    setup() {
-        const settingsProps = { form: ref({}) };
-
-        for (let prop in rstore.settingDefaults) {
-            settingsProps[prop] = ref(_.cloneDeep(rstore.settingDefaults[prop]));
-            watch(settingsProps[prop], (newValue) => {
-                settingsProps.form.value = Object.assign({}, settingsProps.form.value, {[prop]: newValue});
-            }, {deep: true});
-        }
-
-        return settingsProps;
-    }
+    isSetsChanged = false;
 
     created() {
         this.commit = this.$store.commit;
-        this.reader = this.$store.state.reader;
 
-        this.form = {};
-        this.rstore = rstore;
-        this.toolButtons = rstore.toolButtons;
-        this.settingsChanged();
+        this.debouncedCommitSettings = _.debounce(() => {            
+            this.commit('reader/setSettings', _.cloneDeep(this.form));
+        }, 50);
+
+        this.settingsChanged();//no await
     }
 
     mounted() {
-        this.$watch(
-            '$refs.tabs.scrollable',
-            (newValue) => {
-                this.tabsScrollable = newValue && !this.$root.isMobileDevice;
-            }
-        );
     }
 
     init() {
@@ -272,194 +144,20 @@ class SettingsPage {
         this.inited = true;
     }
 
-    settingsChanged() {
-        if (_.isEqual(this.form, this.settings))
-            return;
-
-        this.form = Object.assign({}, this.settings);
-        for (const prop in rstore.settingDefaults) {
-            this[prop] = _.cloneDeep(this.form[prop]);
+    async settingsChanged() {
+        this.isSetsChanged = true;
+        try {
+            this.form = reactive(_.cloneDeep(this.settings));
+        } finally {
+            await this.$nextTick();
+            this.isSetsChanged = false;
         }
-
-        this.fontBold = (this.fontWeight == 'bold');
-        this.fontItalic = (this.fontStyle == 'italic');
-
-        this.fonts = rstore.fonts;
-        this.webFonts = rstore.webFonts;
-        const font = (this.webFontName ? this.webFontName : this.fontName);
-        this.vertShift = this.fontShifts[font] || 0;
-        this.textColorFiltered = this.textColor;
-        this.bgColorFiltered = this.backgroundColor;
-        this.dualDivColorFiltered = this.dualDivColor;
-        this.statusBarColorFiltered = this.statusBarColor;
-    }
-
-    get mode() {
-        return this.$store.state.config.mode;
-    }
-
-    get isExternalConverter() {
-        return this.$store.state.config.useExternalBookConverter;
     }
 
     get settings() {
         return this.$store.state.reader.settings;
     }
 
-    get serverSyncEnabled() {
-        return this.$store.state.reader.serverSyncEnabled;
-    }
-
-    set serverSyncEnabled(newValue) {
-        this.commit('reader/setServerSyncEnabled', newValue);
-    }
-
-    get profiles() {
-        return this.$store.state.reader.profiles;
-    }
-
-    get configBucEnabled() {
-        return this.$store.state.config.bucEnabled;
-    }
-
-    get currentProfileOptions() {
-        const profNames = Object.keys(this.profiles)
-        profNames.sort();
-
-        let result = [{label: 'Нет', value: ''}];
-        profNames.forEach(name => {
-            result.push({label: name, value: name});
-        });
-        return result;
-    }
-
-    get wallpaperOptions() {
-        let result = [{label: 'Нет', value: ''}];
-
-        const userWallpapers = _.cloneDeep(this.userWallpapers);
-        userWallpapers.sort((a, b) => a.label.localeCompare(b.label));
-
-        for (const wp of userWallpapers) {
-            if (wallpaperStorage.keyExists(wp.cssClass))
-                result.push({label: wp.label, value: wp.cssClass});
-        }
-
-        for (let i = 1; i <= 17; i++) {
-            result.push({label: i, value: `paper${i}`});
-        }
-
-        return result;
-    }
-
-    get fontsOptions() {
-        let result = [];
-        this.fonts.forEach(font => {
-            result.push({label: (font.label ? font.label : font.name), value: font.name});
-        });
-        return result;
-    }
-
-    get webFontsOptions() {
-        let result = [{label: 'Нет', value: ''}];
-        this.webFonts.forEach(font => {
-            result.push({label: font.name, value: font.name});
-        });
-        return result;
-    }
-
-    get pageChangeAnimationOptions() {
-        let result = [
-            {label: 'Нет', value: ''},
-            {label: 'Вверх-вниз', value: 'downShift'},
-            (!this.dualPageMode ? {label: 'Вправо-влево', value: 'rightShift'} : null),
-            {label: 'Протаивание', value: 'thaw'},
-            {label: 'Мерцание', value: 'blink'},
-            {label: 'Вращение', value: 'rotate'},
-            (this.wallpaper == '' && !this.dualPageMode ? {label: 'Листание', value: 'flip'} : null),
-        ];        
-
-        result = result.filter(v => v);
-
-        return result;
-    }
-
-    get currentProfile() {
-        return this.$store.state.reader.currentProfile;
-    }
-
-    set currentProfile(newValue) {
-        this.commit('reader/setCurrentProfile', newValue);
-    }
-
-    get partialStorageKey() {
-        return this.serverStorageKey.substr(0, 7) + '***';
-    }
-
-    get serverStorageKey() {
-        return this.$store.state.reader.serverStorageKey;
-    }
-
-    get setStorageKeyLink() {
-        return `https://${window.location.host}/#/reader?setStorageAccessKey=${utils.toBase58(this.serverStorageKey)}`;
-    }
-
-    get predefineTextColors() {
-        return defPalette.concat([
-          '#ffffff',
-          '#000000',
-          '#202020',
-          '#323232',
-          '#aaaaaa',
-          '#00c0c0',
-          '#ebe2c9',
-          '#cfdc99',
-          '#478355',
-          '#909080',
-        ]);
-    }
-
-    get predefineBackgroundColors() {
-        return defPalette.concat([
-          '#ffffff',
-          '#000000',
-          '#202020',
-          '#ebe2c9',
-          '#cfdc99',
-          '#478355',
-          '#a6caf0',
-          '#909080',
-          '#808080',
-          '#c8c8c8',
-        ]);
-    }
-
-    colorPanStyle(type) {
-        let result = 'width: 30px; height: 30px; border: 1px solid black; border-radius: 4px;';
-        switch (type) {
-            case 'text':
-                result += `background-color: ${this.textColor};`
-                break;
-            case 'bg':
-                result += `background-color: ${this.backgroundColor};`
-                break;
-            case 'div':
-                result += `background-color: ${this.dualDivColor};`
-                break;
-            case 'statusbar':
-                result += `background-color: ${this.statusBarColor};`
-                break;
-        }
-        return result;
-    }
-
-    needReload() {
-        this.$root.notify.warning('Необходимо обновить страницу (F5), чтобы изменения возымели эффект');
-    }
-
-    needTextReload() {
-        this.$root.notify.warning('Необходимо обновить книгу в обход кэша, чтобы изменения возымели эффект');
-    }
-
     close() {
         this.$emit('do-action', {action: 'settings'});
     }
@@ -467,242 +165,19 @@ class SettingsPage {
     async setDefaults() {
         try {
             if (await this.$root.stdDialog.confirm('Подтвердите установку настроек по умолчанию:', ' ')) {
-                this.form = Object.assign({}, rstore.settingDefaults);
-                for (let prop in rstore.settingDefaults) {
-                    this[prop] = this.form[prop];
-                }
-            }
-        } catch (e) {
-            //
-        }
-    }
-
-    async addProfile() {
-        try {
-            if (Object.keys(this.profiles).length >= 100) {
-                this.$root.stdDialog.alert('Достигнут предел количества профилей', 'Ошибка');
-                return;
-            }
-            const result = await this.$root.stdDialog.prompt('Введите произвольное название для профиля устройства:', ' ', {
-                inputValidator: (str) => { if (!str) return 'Название не должно быть пустым'; else if (str.length > 50) return 'Слишком длинное название'; else return true; },
-            });
-            if (result && result.value) {
-                if (this.profiles[result.value]) {
-                    this.$root.stdDialog.alert('Такой профиль уже существует', 'Ошибка');
-                } else {
-                    const newProfiles = Object.assign({}, this.profiles, {[result.value]: 1});
-                    this.commit('reader/setAllowProfilesSave', true);
-                    await this.$nextTick();//ждем обработчики watch
-                    this.commit('reader/setProfiles', newProfiles);
-                    await this.$nextTick();//ждем обработчики watch
-                    this.commit('reader/setAllowProfilesSave', false);
-                    this.currentProfile = result.value;
-                }
-            }
-        } catch (e) {
-            //
-        }
-    }
-
-    async delProfile() {
-        if (!this.currentProfile)
-            return;
-
-        try {
-            const result = await this.$root.stdDialog.prompt(`<b>Предупреждение!</b> Удаление профиля '${this.$root.sanitize(this.currentProfile)}' необратимо.` +
-                    `<br>Все настройки профиля будут потеряны, однако список читаемых книг сохранится.` +
-                    `<br><br>Введите 'да' для подтверждения удаления:`, ' ', {
-                inputValidator: (str) => { if (str && str.toLowerCase() === 'да') return true; else return 'Удаление не подтверждено'; },
-            });
-
-            if (result && result.value && result.value.toLowerCase() == 'да') {
-                if (this.profiles[this.currentProfile]) {
-                    const newProfiles = Object.assign({}, this.profiles);
-                    delete newProfiles[this.currentProfile];
-                    this.commit('reader/setAllowProfilesSave', true);
-                    await this.$nextTick();//ждем обработчики watch
-                    this.commit('reader/setProfiles', newProfiles);
-                    await this.$nextTick();//ждем обработчики watch
-                    this.commit('reader/setAllowProfilesSave', false);
-                    this.currentProfile = '';
-                }
+                this.form = _.cloneDeep(rstore.settingDefaults);
             }
         } catch (e) {
             //
         }
     }
 
-    async delAllProfiles() {
-        if (!Object.keys(this.profiles).length)
+    tabEvent(event) {
+        if (!event || !event.action)
             return;
 
-        try {
-            const result = await this.$root.stdDialog.prompt(`<b>Предупреждение!</b> Удаление ВСЕХ профилей с настройками необратимо.` +
-                    `<br><br>Введите 'да' для подтверждения удаления:`, ' ', {
-                inputValidator: (str) => { if (str && str.toLowerCase() === 'да') return true; else return 'Удаление не подтверждено'; },
-            });
-
-            if (result && result.value && result.value.toLowerCase() == 'да') {
-                this.commit('reader/setAllowProfilesSave', true);
-                await this.$nextTick();//ждем обработчики watch
-                this.commit('reader/setProfiles', {});
-                await this.$nextTick();//ждем обработчики watch
-                this.commit('reader/setAllowProfilesSave', false);
-                this.currentProfile = '';
-            }
-        } catch (e) {
-            //
-        }
-    }
-
-    async copyToClip(text, prefix) {
-        const result = await utils.copyTextToClipboard(text);
-        const suf = (prefix.substr(-1) == 'а' ? 'а' : '');
-        const msg = (result ? `${prefix} успешно скопирован${suf} в буфер обмена` : 'Копирование не удалось');
-        if (result)
-            this.$root.notify.success(msg);
-        else
-            this.$root.notify.error(msg);
-    }
-
-    async showServerStorageKey() {
-        this.serverStorageKeyVisible = !this.serverStorageKeyVisible;
-    }
-
-    async enterServerStorageKey(key) {
-        try {
-            const result = await this.$root.stdDialog.prompt(`<b>Предупреждение!</b> Изменение ключа доступа приведет к замене всех профилей и читаемых книг в читалке.` +
-                    `<br><br>Введите новый ключ доступа:`, ' ', {
-                inputValidator: (str) => {
-                    try {
-                        if (str && utils.fromBase58(str).length == 32) {
-                            return true;
-                        }
-                    } catch (e) {
-                        //
-                    }
-                    return 'Неверный формат ключа'; 
-                },
-                inputValue: (key && _.isString(key) ? key : null),
-            });
-
-            if (result && result.value && utils.fromBase58(result.value).length == 32) {
-                this.commit('reader/setServerStorageKey', result.value);
-            }
-        } catch (e) {
-            //
-        }
-    }
-
-    async generateServerStorageKey() {
-        try {
-            const result = await this.$root.stdDialog.prompt(`<b>Предупреждение!</b> Генерация нового ключа доступа приведет к удалению всех профилей и читаемых книг в читалке.` +
-                    `<br><br>Введите 'да' для подтверждения генерации нового ключа:`, ' ', {
-                inputValidator: (str) => { if (str && str.toLowerCase() === 'да') return true; else return 'Генерация не подтверждена'; },
-            });
-
-            if (result && result.value && result.value.toLowerCase() == 'да') {
-                if (this.$root.generateNewServerStorageKey)
-                    this.$root.generateNewServerStorageKey();
-            }
-        } catch (e) {
-            //
-        }
-
-    }
-
-    loadWallpaperFileClick() {
-        this.$refs.file.click();
-    }
-
-    loadWallpaperFile() {
-        const file = this.$refs.file.files[0];        
-        if (file.size > 10*1024*1024) {
-            this.$root.stdDialog.alert('Файл обоев не должен превышать в размере 10Mb', 'Ошибка');
-            return;
-        }
-
-        if (file.type != 'image/png' && file.type != 'image/jpeg') {
-            this.$root.stdDialog.alert('Файл обоев должен иметь тип PNG или JPEG', 'Ошибка');
-            return;
-        }
-
-        if (this.userWallpapers.length >= 100) {
-            this.$root.stdDialog.alert('Превышено максимальное количество пользовательских обоев.', 'Ошибка');
-            return;
-        }
-
-        this.$refs.file.value = '';
-        if (file) {
-            const reader = new FileReader();
-
-            reader.onload = (e) => {
-                (async() => {
-                    const data = e.target.result;
-                    const key = utils.toHex(cryptoUtils.sha256(data));
-                    const label = `#${key.substring(0, 4)}`;
-                    const cssClass = `user-paper${key}`;
-
-                    const newUserWallpapers = _.cloneDeep(this.userWallpapers);
-                    const index = _.findIndex(newUserWallpapers, (item) => (item.cssClass == cssClass));
-
-                    if (index < 0)
-                        newUserWallpapers.push({label, cssClass});
-                    if (!wallpaperStorage.keyExists(cssClass)) {
-                        await wallpaperStorage.setData(cssClass, data);
-                        //отправим data на сервер в файл `/upload/${key}`
-                        try {
-                            //const res = 
-                            await readerApi.uploadFileBuf(data);
-                            //console.log(res);
-                        } catch (e) {
-                            console.error(e);
-                        }
-                    }
-
-                    this.userWallpapers = newUserWallpapers;
-                    this.wallpaper = cssClass;
-                })();
-            }
-
-            reader.readAsDataURL(file);
-        }
-    }
-
-    async delWallpaper() {
-        if (this.wallpaper.indexOf('user-paper') == 0) {
-            const newUserWallpapers = [];
-            for (const wp of this.userWallpapers) {
-                if (wp.cssClass != this.wallpaper) {
-                    newUserWallpapers.push(wp);
-                }
-            }
-
-            await wallpaperStorage.removeData(this.wallpaper);
-
-            this.userWallpapers = newUserWallpapers;
-            this.wallpaper = '';
-        }
-    }
-
-    async downloadWallpaper() {
-        if (this.wallpaper.indexOf('user-paper') != 0)
-            return;
-
-        try {
-            const d = this.$refs.download;
-
-            const dataUrl = await wallpaperStorage.getData(this.wallpaper);
-
-            if (!dataUrl)
-                throw new Error('Файл обоев не найден');
-
-            d.href = dataUrl;
-            d.download = `wallpaper-#${this.wallpaper.replace('user-paper', '').substring(0, 4)}`;
-
-            d.click();
-        } catch (e) {
-            this.$root.stdDialog.alert(e.message, 'Ошибка', {color: 'negative'});
+        switch (event.action) {
+            case 'set-defaults': this.setDefaults(); break;
         }
     }
 
@@ -722,15 +197,17 @@ export default vueComponent(SettingsPage);
 .tab {
     justify-content: initial;
 }
+</style>
 
-.tab-panel {
+<style>
+.sets-tab-panel {
     overflow-x: hidden;
     overflow-y: auto;
     font-size: 90%;
     padding: 0 10px 15px 10px;
 }
 
-.part-header {
+.sets-part-header {
     border-top: 2px solid #bbbbbb;
     font-weight: bold;
     font-size: 110%;
@@ -738,25 +215,7 @@ export default vueComponent(SettingsPage);
     margin-bottom: 5px;
 }
 
-.item {
-    width: 100%;
-    margin-top: 5px;
-    margin-bottom: 5px;
-}
-
-.label-1, .label-3, .label-7 {
-    width: 75px;
-}
-
-.label-2, .label-4, .label-5 {
-    width: 110px;
-}
-
-.label-6 {
-    width: 100px;
-}
-
-.label-1, .label-2, .label-3, .label-4, .label-5, .label-6, .label-7 {
+.sets-label {
     display: flex;
     flex-direction: column;
     justify-content: center;
@@ -765,33 +224,14 @@ export default vueComponent(SettingsPage);
     overflow: hidden;
 }
 
-.text {
-    font-size: 90%;
-    line-height: 130%;
+.sets-item {
+    width: 100%;
+    margin-top: 5px;
+    margin-bottom: 5px;
 }
 
-.button {
+.sets-button {
     margin: 3px 15px 3px 0;
     padding: 0 5px 0 5px;
 }
-
-.copy-icon {
-    margin-left: 5px;
-    cursor: pointer;
-    font-size: 120%;
-    color: blue;
-}
-
-.input {
-    max-width: 150px;
-}
-
-.no-mp {
-    margin: 0;
-    padding: 0;
-}
-
-.col-left {
-    width: 150px;
-}
 </style>

+ 0 - 18
client/components/Reader/SettingsPage/ToolBarTab.inc

@@ -1,18 +0,0 @@
-<div class="part-header">Отображение</div>
-
-<div class="item row no-wrap">
-    <div class="label-3"></div>
-    <q-checkbox size="xs" v-model="toolBarHideOnScroll" label="Скрывать/показывать панель при прокрутке" >
-        <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
-            Скрывать/показывть панель при прокрутке текста вперед/назад
-        </q-tooltip>
-    </q-checkbox>
-</div>
-
-<div class="part-header">Показывать кнопки</div>
-
-<div class="item row no-wrap" v-for="item in toolButtons" :key="item.name" v-show="item.name != 'libs' || mode == 'liberama.top'">
-    <div class="label-3"></div>
-        <q-checkbox size="xs" v-model="showToolButton[item.name]" :label="rstore.readerActions[item.name]"
-        />
-</div>

+ 76 - 0
client/components/Reader/SettingsPage/ToolBarTab/ToolBarTab.vue

@@ -0,0 +1,76 @@
+<template>
+    <div class="fit sets-tab-panel">
+        <div class="sets-part-header">
+            Отображение
+        </div>
+
+        <div class="item row no-wrap">
+            <div class="sets-label label"></div>
+            <q-checkbox v-model="form.toolBarMultiLine" size="xs" label="Многострочная панель">
+                <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
+                    Размещать кнопки на панели в несколько рядов, если они не помещаются в одну строку
+                </q-tooltip>
+            </q-checkbox>
+        </div>
+
+        <div class="item row no-wrap">
+            <div class="sets-label label"></div>
+            <q-checkbox v-model="form.toolBarHideOnScroll" size="xs" label="Скрывать/показывать панель при прокрутке">
+                <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
+                    Скрывать/показывть панель при прокрутке текста вперед/назад
+                </q-tooltip>
+            </q-checkbox>
+        </div>
+
+        <div class="sets-part-header">
+            Показывать кнопки
+        </div>
+
+        <div v-for="item in rstore.toolButtons" :key="item.name">
+            <div class="sets-item row no-wrap">
+                <div class="sets-label label"></div>
+                <q-checkbox v-model="form.showToolButton[item.name]" size="xs" :label="rstore.readerActions[item.name]" />
+            </div>
+        </div>
+    </div>
+</template>
+
+<script>
+//-----------------------------------------------------------------------------
+import vueComponent from '../../../vueComponent.js';
+
+import rstore from '../../../../store/modules/reader';
+
+const componentOptions = {
+    watch: {
+    },
+};
+class ToolBarTab {
+    _options = componentOptions;
+    _props = {
+        form: Object,
+    };
+
+    rstore = rstore;
+
+    created() {
+    }
+
+    mounted() {
+    }
+
+    get mode() {
+        return this.$store.state.config.mode;
+    }
+}
+
+export default vueComponent(ToolBarTab);
+//-----------------------------------------------------------------------------
+</script>
+
+<style scoped>
+.label {
+    width: 75px;
+}
+
+</style>

+ 0 - 76
client/components/Reader/SettingsPage/UpdateTab.inc

@@ -1,76 +0,0 @@
-<!---------------------------------------------->
-<div class="part-header">Обновление читалки</div>
-<div class="item row">
-    <div class="label-6"></div>
-    <q-checkbox size="xs" v-model="showNeedUpdateNotify">
-        Проверять наличие новой версии
-        <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
-            Напоминать о необходимости обновления страницы<br>
-            при появлении новой версии читалки
-        </q-tooltip>
-    </q-checkbox>
-</div>
-
-<!---------------------------------------------->
-<div class="part-header">Обновление книг</div>
-<div v-show="!configBucEnabled" class="item row">
-    <div class="label-6"></div>
-    <div>Сервер обновлений временно не работает</div>
-</div>
-
-<div v-show="configBucEnabled" class="item row">
-    <div class="label-6"></div>
-    <q-checkbox size="xs" v-model="bucEnabled">
-        Проверять обновления книг
-    </q-checkbox>
-</div>
-
-<div v-show="configBucEnabled && bucEnabled" class="item row">
-    <div class="label-6"></div>
-    <div class="col-5 column justify-center items-end q-pr-xs">Разница размеров</div>
-    <div class="col row">
-        <NumInput class="col-left" v-model="bucSizeDiff" />
-
-        <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
-            Уведомлять о наличии обновления книги в списке загруженных<br>
-            при указанной разнице в размерах старого и нового файлов.<br>
-            Разница указывается в байтах и может быть отрицательной.
-        </q-tooltip>
-    </div>
-</div>
-
-<div v-show="configBucEnabled && bucEnabled" class="item row">
-    <div class="label-6"></div>
-    <q-checkbox size="xs" v-model="bucSetOnNew">
-        Автопроверка для вновь загружаемых
-        <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
-            Автоматически устанавливать флаг проверки<br>
-            обновлений для всех вновь загружаемых книг
-        </q-tooltip>
-    </q-checkbox>
-</div>
-
-<div v-show="configBucEnabled && bucEnabled" class="item row">
-    <div class="label-6"></div>
-    <q-checkbox size="xs" v-model="bucCancelEnabled">
-        Отменять проверку через {{ bucCancelDays }} дней{{ (bucCancelEnabled ? ':' : '') }}
-        <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
-            Снимать флаг проверки с книги, если не было<br>
-            обновлений в течение {{ bucCancelDays }} дней
-        </q-tooltip>
-    </q-checkbox>
-</div>
-
-<div v-show="configBucEnabled && bucEnabled && bucCancelEnabled" class="item row">
-    <div class="label-6"></div>
-    <div class="col-5"></div>
-    <div class="col row">
-        <NumInput class="col-left" v-model="bucCancelDays" :min="1" :max="10000"/>
-
-        <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
-            Снимать флаг проверки с книги, если не было<br>
-            обновлений в течение {{ bucCancelDays }} дней
-        </q-tooltip>
-    </div>
-</div>
-

+ 122 - 0
client/components/Reader/SettingsPage/UpdateTab/UpdateTab.vue

@@ -0,0 +1,122 @@
+<template>
+    <div class="fit sets-tab-panel">
+        <!---------------------------------------------->
+        <div class="sets-part-header">
+            Обновление читалки
+        </div>
+        <div class="sets-item row">
+            <div class="sets-label label"></div>
+            <q-checkbox v-model="form.showNeedUpdateNotify" size="xs">
+                Проверять наличие новой версии
+                <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
+                    Напоминать о необходимости обновления страницы<br>
+                    при появлении новой версии читалки
+                </q-tooltip>
+            </q-checkbox>
+        </div>
+
+        <!---------------------------------------------->
+        <div class="sets-part-header">
+            Обновление книг
+        </div>
+        <div v-show="!configBucEnabled" class="sets-item row">
+            <div class="sets-label label"></div>
+            <div>Сервер обновлений временно не работает</div>
+        </div>
+
+        <div v-show="configBucEnabled" class="sets-item row">
+            <div class="sets-label label"></div>
+            <q-checkbox v-model="form.bucEnabled" size="xs">
+                Проверять обновления книг
+            </q-checkbox>
+        </div>
+
+        <div v-show="configBucEnabled && form.bucEnabled" class="sets-item row">
+            <div class="sets-label label"></div>
+            <div class="col-4 column justify-center items-end q-pr-xs">
+                Разница размеров
+            </div>
+            <div class="col row">
+                <NumInput v-model="form.bucSizeDiff" style="width: 200px" />
+
+                <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
+                    Уведомлять о наличии обновления книги в списке загруженных<br>
+                    при указанной разнице в размерах старого и нового файлов.<br>
+                    Разница указывается в байтах и может быть отрицательной.
+                </q-tooltip>
+            </div>
+        </div>
+
+        <div v-show="configBucEnabled && form.bucEnabled" class="sets-item row">
+            <div class="sets-label label"></div>
+            <q-checkbox v-model="form.bucSetOnNew" size="xs">
+                Автопроверка для вновь загружаемых
+                <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
+                    Автоматически устанавливать флаг проверки<br>
+                    обновлений для всех вновь загружаемых книг
+                </q-tooltip>
+            </q-checkbox>
+        </div>
+
+        <div v-show="configBucEnabled && form.bucEnabled" class="sets-item row">
+            <div class="sets-label label"></div>
+            <q-checkbox v-model="form.bucCancelEnabled" size="xs">
+                Отменять проверку через {{ form.bucCancelDays }} дней{{ (form.bucCancelEnabled ? ':' : '') }}
+                <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
+                    Снимать флаг проверки с книги, если не было<br>
+                    обновлений в течение {{ form.bucCancelDays }} дней
+                </q-tooltip>
+            </q-checkbox>
+        </div>
+
+        <div v-show="configBucEnabled && form.bucEnabled && form.bucCancelEnabled" class="sets-item row">
+            <div class="sets-label label"></div>
+            <div class="col-4"></div>
+            <div class="col row">
+                <NumInput v-model="form.bucCancelDays" :min="1" :max="10000" />
+
+                <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
+                    Снимать флаг проверки с книги, если не было<br>
+                    обновлений в течение {{ form.bucCancelDays }} дней
+                </q-tooltip>
+            </div>
+        </div>
+    </div>
+</template>
+
+<script>
+//-----------------------------------------------------------------------------
+import vueComponent from '../../../vueComponent.js';
+import NumInput from '../../../share/NumInput.vue';
+
+const componentOptions = {
+    components: {
+        NumInput
+    },
+};
+class UpdateTab {
+    _options = componentOptions;
+    _props = {
+        form: Object,
+    };
+
+    created() {
+    }
+
+    mounted() {
+    }
+
+    get configBucEnabled() {
+        return this.$store.state.config.bucEnabled;
+    }
+}
+
+export default vueComponent(UpdateTab);
+//-----------------------------------------------------------------------------
+</script>
+
+<style scoped>
+.label {
+    width: 100px;
+}
+</style>

+ 0 - 121
client/components/Reader/SettingsPage/ViewTab/Color.inc

@@ -1,121 +0,0 @@
-                            <!---------------------------------------------->
-                            <div class="hidden part-header">
-                                Цвет
-                            </div>
-
-                            <div class="item row">
-                                <div class="label-2">
-                                    Текст
-                                </div>
-                                <div class="col row">
-                                    <q-input
-                                        v-model="textColorFiltered"
-                                        class="col-left no-mp"
-                                        outlined dense
-                                        
-                                        :rules="['hexColor']"
-                                        style="max-width: 150px"
-                                    >
-                                        <template #prepend>
-                                            <q-icon name="la la-angle-down la-xs" class="cursor-pointer text-white" :style="colorPanStyle('text')">
-                                                <q-popup-proxy anchor="bottom middle" self="top middle">
-                                                    <div>
-                                                        <q-color
-                                                            v-model="textColor"
-                                                            no-header default-view="palette" :palette="predefineTextColors"
-                                                        />
-                                                    </div>
-                                                </q-popup-proxy>
-                                            </q-icon>
-                                        </template>
-                                    </q-input>
-                                </div>
-                            </div>
-
-                            <div class="q-mt-md" />
-                            <div class="item row">
-                                <div class="label-2">
-                                    Фон
-                                </div>
-                                <div class="col row">
-                                    <q-input 
-                                        v-model="bgColorFiltered"
-                                        class="col-left no-mp"
-                                        outlined dense
-                                        
-                                        :rules="['hexColor']"
-                                        style="max-width: 150px"
-                                    >
-                                        <template #prepend>
-                                            <q-icon name="la la-angle-down la-xs" class="cursor-pointer text-white" :style="colorPanStyle('bg')">
-                                                <q-popup-proxy anchor="bottom middle" self="top middle">
-                                                    <div>
-                                                        <q-color v-model="backgroundColor" no-header default-view="palette" :palette="predefineBackgroundColors" />
-                                                    </div>
-                                                </q-popup-proxy>
-                                            </q-icon>
-                                        </template>
-                                    </q-input>
-                                </div>
-                            </div>
-
-                            <div class="q-mt-md" />
-                            <div class="item row">
-                                <div class="label-2">
-                                    Обои
-                                </div>
-                                <div class="col row items-center">
-                                    <q-select 
-                                        v-model="wallpaper"
-                                        class="col-left no-mp"
-                                        :options="wallpaperOptions"
-                                        dropdown-icon="la la-angle-down la-sm"
-                                        outlined dense emit-value map-options
-                                    >
-                                        <template #selected-item="scope">
-                                            <div>
-                                                {{ scope.opt.label }}
-                                            </div>
-                                            <div v-show="scope.opt.value" class="q-ml-sm" :class="scope.opt.value" style="width: 40px; height: 28px;"></div>
-                                        </template>
-
-                                        <template #option="scope">
-                                            <q-item
-                                                v-bind="scope.itemProps"
-                                            >
-                                                <q-item-section style="min-width: 50px;">
-                                                    <q-item-label v-html="scope.opt.label" />
-                                                </q-item-section>
-                                                <q-item-section v-show="scope.opt.value" :class="scope.opt.value" style="min-width: 70px; min-height: 50px;" />
-                                            </q-item>
-                                        </template>
-                                    </q-select>
-
-                                    <div class="q-px-xs" />
-                                    <q-btn class="q-ml-sm" round dense color="blue" icon="la la-plus" @click.stop="loadWallpaperFileClick">
-                                        <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
-                                            Добавить файл обоев
-                                        </q-tooltip>
-                                    </q-btn>
-                                    <q-btn v-show="wallpaper.indexOf('user-paper') === 0" class="q-ml-sm" round dense color="blue" icon="la la-minus" @click.stop="delWallpaper">
-                                        <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
-                                            Удалить выбранные обои
-                                        </q-tooltip>
-                                    </q-btn>
-                                    <q-btn v-show="wallpaper.indexOf('user-paper') === 0" class="q-ml-sm" round dense color="blue" icon="la la-file-download" @click.stop="downloadWallpaper">
-                                        <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
-                                            Скачать выбранные обои
-                                        </q-tooltip>
-                                    </q-btn>
-                                </div>
-                            </div>
-
-                            <div class="q-mt-sm" />
-                            <div class="item row">
-                                <div class="label-2"></div>
-                                <div class="col row items-center">
-                                    <q-checkbox v-model="wallpaperIgnoreStatusBar" size="xs" label="Не включать строку статуса в обои" />
-                                </div>
-                            </div>
-
-                            <input ref="file" type="file" style="display: none;" @change="loadWallpaperFile" />

+ 329 - 0
client/components/Reader/SettingsPage/ViewTab/Color/Color.vue

@@ -0,0 +1,329 @@
+<template>
+    <div>
+        <!---------------------------------------------->
+        <div class="hidden sets-part-header">
+            Цвет
+        </div>
+
+        <div class="sets-item row">
+            <div class="sets-label label">
+                Текст
+            </div>
+            <div class="col row">
+                <q-input
+                    v-model="textColorFiltered"
+                    class="col-left no-mp"
+                    outlined dense
+                    
+                    :rules="['hexColor']"
+                    style="max-width: 150px"
+                >
+                    <template #prepend>
+                        <q-icon name="la la-angle-down la-xs" class="cursor-pointer text-white" :style="helper.colorPanStyle(form.textColor)">
+                            <q-popup-proxy anchor="bottom middle" self="top middle">
+                                <div>
+                                    <q-color
+                                        v-model="form.textColor"
+                                        no-header default-view="palette" :palette="defPalette.predefineTextColors"
+                                    />
+                                </div>
+                            </q-popup-proxy>
+                        </q-icon>
+                    </template>
+                </q-input>
+            </div>
+        </div>
+
+        <div class="q-mt-md" />
+        <div class="sets-item row">
+            <div class="sets-label label">
+                Фон
+            </div>
+            <div class="col row">
+                <q-input 
+                    v-model="bgColorFiltered"
+                    class="col-left no-mp"
+                    outlined dense
+                    
+                    :rules="['hexColor']"
+                    style="max-width: 150px"
+                >
+                    <template #prepend>
+                        <q-icon name="la la-angle-down la-xs" class="cursor-pointer text-white" :style="helper.colorPanStyle(form.backgroundColor)">
+                            <q-popup-proxy anchor="bottom middle" self="top middle">
+                                <div>
+                                    <q-color v-model="form.backgroundColor" no-header default-view="palette" :palette="defPalette.predefineBackgroundColors" />
+                                </div>
+                            </q-popup-proxy>
+                        </q-icon>
+                    </template>
+                </q-input>
+            </div>
+        </div>
+
+        <div class="q-mt-md" />
+        <div class="sets-item row">
+            <div class="sets-label label">
+                Обои
+            </div>
+            <div class="col row items-center">
+                <q-select 
+                    v-model="form.wallpaper"
+                    class="col-left no-mp"
+                    :options="wallpaperOptions"
+                    dropdown-icon="la la-angle-down la-sm"
+                    outlined dense emit-value map-options
+                >
+                    <template #selected-item="scope">
+                        <div>
+                            {{ scope.opt.label }}
+                        </div>
+                        <div v-show="scope.opt.value" class="q-ml-sm" :class="scope.opt.value" style="width: 40px; height: 28px;"></div>
+                    </template>
+
+                    <template #option="scope">
+                        <q-item
+                            v-bind="scope.itemProps"
+                        >
+                            <q-item-section style="min-width: 50px;">
+                                <q-item-label>
+                                    {{ scope.opt.label }}
+                                </q-item-label>
+                            </q-item-section>
+                            <q-item-section v-show="scope.opt.value" :class="scope.opt.value" style="min-width: 70px; min-height: 50px;" />
+                        </q-item>
+                    </template>
+                </q-select>
+
+                <div class="q-px-xs" />
+                <q-btn class="q-ml-sm" round dense color="blue" icon="la la-plus" @click.stop="loadWallpaperFileClick">
+                    <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
+                        Добавить файл обоев
+                    </q-tooltip>
+                </q-btn>
+                <q-btn v-show="form.wallpaper.indexOf('user-paper') === 0" class="q-ml-sm" round dense color="blue" icon="la la-minus" @click.stop="delWallpaper">
+                    <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
+                        Удалить выбранные обои
+                    </q-tooltip>
+                </q-btn>
+                <q-btn v-show="form.wallpaper.indexOf('user-paper') === 0" class="q-ml-sm" round dense color="blue" icon="la la-file-download" @click.stop="downloadWallpaper">
+                    <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
+                        Скачать выбранные обои
+                    </q-tooltip>
+                </q-btn>
+            </div>
+        </div>
+
+        <div class="q-mt-sm" />
+        <div class="sets-item row">
+            <div class="sets-label label"></div>
+            <div class="col row items-center">
+                <q-checkbox v-model="form.wallpaperIgnoreStatusBar" size="xs" label="Не включать строку статуса в обои" />
+            </div>
+        </div>
+
+        <input ref="file" type="file" style="display: none;" @change="loadWallpaperFile" />
+        <a ref="download" style="display: none;" target="_blank"></a>
+    </div>
+</template>
+
+<script>
+//-----------------------------------------------------------------------------
+import vueComponent from '../../../../vueComponent.js';
+
+import _ from 'lodash';
+
+import * as helper from '../helper';
+import defPalette from '../defPalette';
+
+import * as utils from '../../../../../share/utils';
+import * as cryptoUtils from '../../../../../share/cryptoUtils';
+import wallpaperStorage from '../../../share/wallpaperStorage';
+import readerApi from '../../../../../api/reader';
+
+const componentOptions = {
+    components: {
+    },
+    watch: {
+        form: {
+            handler() {
+                this.formChanged();//no await
+            },
+            deep: true,
+        },
+        textColorFiltered(newValue) {
+            if (!this.isFormChanged && this.helper.isHexColor(newValue))
+                this.form.textColor = newValue;
+        },
+        bgColorFiltered(newValue) {
+            if (!this.isFormChanged && this.helper.isHexColor(newValue))
+                this.form.backgroundColor = newValue;
+        },
+    },
+};
+class Color {
+    _options = componentOptions;
+    _props = {
+        form: Object,
+    };
+
+    helper = helper;
+    defPalette = defPalette;
+
+    isFormChanged = false;
+    textColorFiltered = '';
+    bgColorFiltered = '';
+
+    created() {
+        this.formChanged();//no await
+    }
+
+    mounted() {
+    }
+
+    async formChanged() {
+        this.isFormChanged = true;
+        try {
+            this.textColorFiltered = this.form.textColor;
+            this.bgColorFiltered = this.form.backgroundColor;
+
+            if (this.form.wallpaper != '' && this.form.pageChangeAnimation == 'flip')
+                this.form.pageChangeAnimation = '';
+        } finally {
+            await this.$nextTick();
+            this.isFormChanged = false;
+        }
+    }
+
+    get wallpaperOptions() {
+        let result = [{label: 'Нет', value: ''}];
+
+        const userWallpapers = _.cloneDeep(this.form.userWallpapers);
+        userWallpapers.sort((a, b) => a.label.localeCompare(b.label));
+
+        for (const wp of userWallpapers) {
+            if (wallpaperStorage.keyExists(wp.cssClass))
+                result.push({label: wp.label, value: wp.cssClass});
+        }
+
+        for (let i = 1; i <= 17; i++) {
+            result.push({label: i, value: `paper${i}`});
+        }
+
+        return result;
+    }
+
+    loadWallpaperFileClick() {
+        this.$refs.file.click();
+    }
+
+    loadWallpaperFile() {
+        const file = this.$refs.file.files[0];        
+        if (file.size > 10*1024*1024) {
+            this.$root.stdDialog.alert('Файл обоев не должен превышать в размере 10Mb', 'Ошибка');
+            return;
+        }
+
+        if (file.type != 'image/png' && file.type != 'image/jpeg') {
+            this.$root.stdDialog.alert('Файл обоев должен иметь тип PNG или JPEG', 'Ошибка');
+            return;
+        }
+
+        if (this.form.userWallpapers.length >= 100) {
+            this.$root.stdDialog.alert('Превышено максимальное количество пользовательских обоев.', 'Ошибка');
+            return;
+        }
+
+        this.$refs.file.value = '';
+        if (file) {
+            const reader = new FileReader();
+
+            reader.onload = (e) => {
+                (async() => {
+                    const data = e.target.result;
+                    const key = utils.toHex(cryptoUtils.sha256(data));
+                    const label = `#${key.substring(0, 4)}`;
+                    const cssClass = `user-paper${key}`;
+
+                    const newUserWallpapers = _.cloneDeep(this.form.userWallpapers);
+                    const index = _.findIndex(newUserWallpapers, (item) => (item.cssClass == cssClass));
+
+                    if (index < 0)
+                        newUserWallpapers.push({label, cssClass});
+                    if (!wallpaperStorage.keyExists(cssClass)) {
+                        await wallpaperStorage.setData(cssClass, data);
+                        //отправим data на сервер в файл `/upload/${key}`
+                        try {
+                            //const res = 
+                            await readerApi.uploadFileBuf(data);
+                            //console.log(res);
+                        } catch (e) {
+                            console.error(e);
+                        }
+                    }
+
+                    this.form.userWallpapers = newUserWallpapers;
+                    this.form.wallpaper = cssClass;
+                })();
+            }
+
+            reader.readAsDataURL(file);
+        }
+    }
+
+    async delWallpaper() {
+        if (this.form.wallpaper.indexOf('user-paper') == 0) {
+            const newUserWallpapers = [];
+            for (const wp of this.form.userWallpapers) {
+                if (wp.cssClass != this.form.wallpaper) {
+                    newUserWallpapers.push(wp);
+                }
+            }
+
+            await wallpaperStorage.removeData(this.form.wallpaper);
+
+            this.form.userWallpapers = newUserWallpapers;
+            this.form.wallpaper = '';
+        }
+    }
+
+    async downloadWallpaper() {
+        if (this.form.wallpaper.indexOf('user-paper') != 0)
+            return;
+
+        try {
+            const d = this.$refs.download;
+
+            const dataUrl = await wallpaperStorage.getData(this.form.wallpaper);
+
+            if (!dataUrl)
+                throw new Error('Файл обоев не найден');
+
+            d.href = dataUrl;
+            d.download = `wallpaper-#${this.form.wallpaper.replace('user-paper', '').substring(0, 4)}`;
+
+            d.click();
+        } catch (e) {
+            this.$root.stdDialog.alert(e.message, 'Ошибка', {color: 'negative'});
+        }
+    }
+}
+
+export default vueComponent(Color);
+//-----------------------------------------------------------------------------
+</script>
+
+<style scoped>
+.label {
+    width: 110px;
+}
+
+.col-left {
+    width: 145px;
+}
+
+.no-mp {
+    margin: 0;
+    padding: 0;
+}
+</style>

+ 0 - 56
client/components/Reader/SettingsPage/ViewTab/Font.inc

@@ -1,56 +0,0 @@
-<!---------------------------------------------->
-<div class="hidden part-header">Шрифт</div>
-
-<div class="item row">
-    <div class="label-2">Локальный/веб</div>
-    <div class="col row">
-        <q-select class="col-left" v-model="fontName" :options="fontsOptions" :disable="webFontName != ''"
-            dropdown-icon="la la-angle-down la-sm"
-            outlined dense emit-value map-options
-        />
-
-        <div class="q-px-sm"/>
-        <q-select class="col" v-model="webFontName" :options="webFontsOptions"
-            dropdown-icon="la la-angle-down la-sm"
-            outlined dense emit-value map-options
-        >
-            <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
-                Веб шрифты дают большое разнообразие,<br>
-                однако есть шанс, что шрифт будет загружаться<br>
-                очень медленно или вовсе не загрузится
-            </q-tooltip>
-        </q-select>
-    </div>
-</div>
-
-<div class="item row">
-    <div class="label-2">Размер</div>
-    <div class="col row">
-        <NumInput class="col-left" v-model="fontSize" :min="5" :max="200"/>
-
-        <div class="col q-pt-xs text-right">
-            <a href="https://fonts.google.com/?subset=cyrillic" target="_blank">Примеры</a>
-        </div>
-    </div>
-</div>
-
-<div class="item row">
-    <div class="label-2">Сдвиг</div>
-    <div class="col row">
-        <NumInput class="col-left" v-model="vertShift" :min="-100" :max="100">
-            <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
-                Сдвиг шрифта по вертикали в процентах от размера.<br>
-                Отрицательное значение сдвигает вверх, положительное -<br>
-                вниз. Значение зависит от метрики шрифта.
-            </q-tooltip>
-        </NumInput>
-    </div>
-</div>
-
-<div class="item row">
-    <div class="label-2">Стиль</div>
-    <div class="col row">
-        <q-checkbox v-model="fontBold" size="xs" label="Жирный" />
-        <q-checkbox class="q-ml-sm" v-model="fontItalic" size="xs" label="Курсив" />
-    </div>
-</div>

+ 176 - 0
client/components/Reader/SettingsPage/ViewTab/Font/Font.vue

@@ -0,0 +1,176 @@
+<template>
+    <div>
+        <!---------------------------------------------->
+        <div class="hidden sets-part-header">
+            Шрифт
+        </div>
+
+        <div class="sets-item row">
+            <div class="sets-label label">
+                Локальный/веб
+            </div>
+            <div class="col row">
+                <q-select
+                    v-model="form.fontName" class="col-left" :options="fontsOptions" :disable="form.webFontName != ''"
+                    dropdown-icon="la la-angle-down la-sm"
+                    outlined dense emit-value map-options
+                />
+
+                <div class="q-px-sm" />
+                <q-select
+                    v-model="form.webFontName" class="col" :options="webFontsOptions"
+                    dropdown-icon="la la-angle-down la-sm"
+                    outlined dense emit-value map-options
+                >
+                    <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
+                        Веб шрифты дают большое разнообразие,<br>
+                        однако есть шанс, что шрифт будет загружаться<br>
+                        очень медленно или вовсе не загрузится
+                    </q-tooltip>
+                </q-select>
+            </div>
+        </div>
+
+        <div class="sets-item row">
+            <div class="sets-label label">
+                Размер
+            </div>
+            <div class="col row">
+                <NumInput v-model="form.fontSize" class="col-left" :min="5" :max="200" />
+
+                <div class="col q-pt-xs text-right">
+                    <a href="https://fonts.google.com/?subset=cyrillic" target="_blank">Примеры</a>
+                </div>
+            </div>
+        </div>
+
+        <div class="sets-item row">
+            <div class="sets-label label">
+                Сдвиг
+            </div>
+            <div class="col row">
+                <NumInput v-model="vertShift" class="col-left" :min="-100" :max="100">
+                    <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
+                        Сдвиг шрифта по вертикали в процентах от размера.<br>
+                        Отрицательное значение сдвигает вверх, положительное -<br>
+                        вниз. Значение зависит от метрики шрифта.
+                    </q-tooltip>
+                </NumInput>
+            </div>
+        </div>
+
+        <div class="sets-item row">
+            <div class="sets-label label">
+                Стиль
+            </div>
+            <div class="col row">
+                <q-checkbox v-model="fontBold" size="xs" label="Жирный" />
+                <q-checkbox v-model="fontItalic" class="q-ml-sm" size="xs" label="Курсив" />
+            </div>
+        </div>
+    </div>
+</template>
+
+<script>
+//-----------------------------------------------------------------------------
+import vueComponent from '../../../../vueComponent.js';
+
+import NumInput from '../../../../share/NumInput.vue';
+import rstore from '../../../../../store/modules/reader';
+
+const componentOptions = {
+    components: {
+        NumInput,
+    },
+    watch: {
+        form: {
+            handler() {
+                this.formChanged();//no await
+            },
+            deep: true,
+        },
+        fontBold: function(newValue) {
+            if (!this.isFormChanged)
+                this.form.fontWeight = (newValue ? 'bold' : '');
+        },
+        fontItalic: function(newValue) {
+            if (!this.isFormChanged)
+                this.form.fontStyle = (newValue ? 'italic' : '');
+        },
+        vertShift: function(newValue) {
+            if (!this.isFormChanged) {
+                const font = (this.form.webFontName ? this.form.webFontName : this.form.fontName);
+                if (this.form.fontShifts[font] != newValue || this.form.fontVertShift != newValue) {
+                    this.form.fontShifts = Object.assign({}, this.form.fontShifts, {[font]: newValue});
+                    this.form.fontVertShift = newValue;
+                }
+            }
+        },
+    },
+};
+class Font {
+    _options = componentOptions;
+    _props = {
+        form: Object,
+    };
+
+    fontBold = false;
+    fontItalic = false;
+    vertShift = 0;
+    webFonts = [];
+    fonts = [];    
+
+    created() {
+        this.formChanged();//no await
+    }
+
+    mounted() {
+    }
+
+    async formChanged() {
+        this.isFormChanged = true;
+        try {
+            this.fontBold = (this.form.fontWeight == 'bold');
+            this.fontItalic = (this.form.fontStyle == 'italic');
+
+            this.fonts = rstore.fonts;
+            this.webFonts = rstore.webFonts;
+            const font = (this.form.webFontName ? this.form.webFontName : this.form.fontName);
+            this.vertShift = this.form.fontShifts[font] || 0;
+        } finally {
+            await this.$nextTick();
+            this.isFormChanged = false;
+        }
+    }
+
+    get fontsOptions() {
+        let result = [];
+        this.fonts.forEach(font => {
+            result.push({label: (font.label ? font.label : font.name), value: font.name});
+        });
+        return result;
+    }
+
+    get webFontsOptions() {
+        let result = [{label: 'Нет', value: ''}];
+        this.webFonts.forEach(font => {
+            result.push({label: font.name, value: font.name});
+        });
+        return result;
+    }
+
+}
+
+export default vueComponent(Font);
+//-----------------------------------------------------------------------------
+</script>
+
+<style scoped>
+.label {
+    width: 110px;
+}
+
+.col-left {
+    width: 145px;
+}
+</style>

+ 0 - 124
client/components/Reader/SettingsPage/ViewTab/Mode.inc

@@ -1,124 +0,0 @@
-<!---------------------------------------------->
-<div class="hidden part-header">Режим</div>
-
-<div class="item row">
-    <div class="label-2"></div>
-    <div class="col row">
-        <q-checkbox v-model="dualPageMode" size="xs" label="Двухстраничный режим" />
-    </div>
-</div>
-
-<div class="part-header">Страницы</div>
-<div class="item row">
-    <div class="label-2">Отступ границ</div>
-    <div class="col row">
-        <NumInput class="col-left" v-model="indentLR" :min="0" :max="2000">
-            <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
-                Слева/справа от края экрана
-            </q-tooltip>
-        </NumInput>
-        <div class="q-px-sm"/>
-        <NumInput class="col" v-model="indentTB" :min="0" :max="2000">
-            <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
-                Сверху/снизу от края экрана
-            </q-tooltip>
-        </NumInput>
-    </div>
-</div>
-
-<div v-show="dualPageMode" class="item row">
-    <div class="label-2">Отступ внутри</div>
-    <div class="col row">
-        <NumInput class="col-left" v-model="dualIndentLR" :min="0" :max="2000">
-            <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
-                Слева/справа внутри страницы
-            </q-tooltip>
-        </NumInput>
-    </div>
-</div>
-
-<div v-show="dualPageMode">
-    <div class="part-header">Разделитель</div>
-
-    <div class="item row no-wrap">
-        <div class="label-2">Цвет</div>
-        <div class="col-left row">
-            <q-input class="col-left no-mp"
-                outlined dense
-                v-model="dualDivColorFiltered"
-                :rules="['hexColor']"
-                style="max-width: 150px"
-                :disable="dualDivColorAsText"
-            >
-                <template v-slot:prepend>
-                    <q-icon name="la la-angle-down la-xs" class="cursor-pointer text-white" :style="colorPanStyle('div')">
-                        <q-popup-proxy anchor="bottom middle" self="top middle">
-                            <div>
-                                <q-color v-model="dualDivColor"
-                                    no-header default-view="palette" :palette="predefineTextColors"
-                                />
-                            </div>
-                        </q-popup-proxy>
-                    </q-icon>
-                </template>
-            </q-input>
-        </div>
-        
-        <div class="q-px-xs"/>
-        <q-checkbox v-model="dualDivColorAsText" size="xs" label="Как у текста" />
-    </div>
-
-    <div class="item row">
-        <div class="label-2">Прозрачность</div>
-        <div class="col row">
-            <NumInput class="col-left" v-model="dualDivColorAlpha" :min="0" :max="1" :digits="2" :step="0.1"/>
-        </div>
-    </div>
-
-    <div class="item row">
-        <div class="label-2">Ширина (px)</div>
-        <div class="col row">
-            <NumInput class="col-left" v-model="dualDivWidth" :min="0" :max="100">
-                <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
-                    Ширина разделителя
-                </q-tooltip>
-            </NumInput>
-        </div>
-    </div>
-
-    <div class="item row">
-        <div class="label-2">Высота (%)</div>
-        <div class="col row">
-            <NumInput class="col-left" v-model="dualDivHeight" :min="0" :max="100">
-                <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
-                    Высота разделителя
-                </q-tooltip>
-            </NumInput>
-        </div>
-    </div>
-
-    <div class="item row">
-        <div class="label-2">Пунктир</div>
-        <div class="col row">
-            <NumInput class="col-left" v-model="dualDivStrokeFill" :min="0" :max="2000">
-                <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
-                    Заполнение пунктира
-                </q-tooltip>
-            </NumInput>
-            <div class="q-px-sm"/>
-            <NumInput class="col" v-model="dualDivStrokeGap" :min="0" :max="2000">
-                <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
-                    Промежуток пунктира
-                </q-tooltip>
-            </NumInput>
-        </div>
-    </div>
-
-    <div class="item row">
-        <div class="label-2">Ширина тени</div>
-        <div class="col row">
-            <NumInput class="col-left" v-model="dualDivShadowWidth" :min="0" :max="100"/>
-        </div>
-    </div>    
-
-</div>

+ 229 - 0
client/components/Reader/SettingsPage/ViewTab/Mode/Mode.vue

@@ -0,0 +1,229 @@
+<template>
+    <div>
+        <!---------------------------------------------->
+        <div class="hidden sets-part-header">
+            Режим
+        </div>
+
+        <div class="sets-item row">
+            <div class="sets-label label"></div>
+            <div class="col row">
+                <q-checkbox v-model="form.dualPageMode" size="xs" label="Двухстраничный режим" />
+            </div>
+        </div>
+
+        <div class="sets-part-header">
+            Страницы
+        </div>
+        <div class="sets-item row">
+            <div class="sets-label label">
+                Отступ границ
+            </div>
+            <div class="col row">
+                <NumInput v-model="form.indentLR" class="col-left" :min="0" :max="2000">
+                    <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
+                        Слева/справа от края экрана
+                    </q-tooltip>
+                </NumInput>
+                <div class="q-px-sm" />
+                <NumInput v-model="form.indentTB" class="col" :min="0" :max="2000">
+                    <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
+                        Сверху/снизу от края экрана
+                    </q-tooltip>
+                </NumInput>
+            </div>
+        </div>
+
+        <div v-show="form.dualPageMode" class="sets-item row">
+            <div class="sets-label label">
+                Отступ внутри
+            </div>
+            <div class="col row">
+                <NumInput v-model="form.dualIndentLR" class="col-left" :min="0" :max="2000">
+                    <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
+                        Слева/справа внутри страницы
+                    </q-tooltip>
+                </NumInput>
+            </div>
+        </div>
+
+        <div v-show="form.dualPageMode">
+            <div class="sets-part-header">
+                Разделитель
+            </div>
+
+            <div class="sets-item row no-wrap">
+                <div class="sets-label label">
+                    Цвет
+                </div>
+                <div class="col-left row">
+                    <q-input 
+                        v-model="dualDivColorFiltered"
+                        class="col-left no-mp"
+                        outlined dense
+                        :rules="['hexColor']"
+                        style="max-width: 150px"
+                        :disable="form.dualDivColorAsText"
+                    >
+                        <template #prepend>
+                            <q-icon name="la la-angle-down la-xs" class="cursor-pointer text-white" :style="helper.colorPanStyle(form.dualDivColor)">
+                                <q-popup-proxy anchor="bottom middle" self="top middle">
+                                    <div>
+                                        <q-color
+                                            v-model="form.dualDivColor"
+                                            no-header default-view="palette" :palette="defPalette.predefineTextColors"
+                                        />
+                                    </div>
+                                </q-popup-proxy>
+                            </q-icon>
+                        </template>
+                    </q-input>
+                </div>
+                
+                <div class="q-px-xs" />
+                <q-checkbox v-model="form.dualDivColorAsText" size="xs" label="Как у текста" />
+            </div>
+
+            <div class="sets-item row">
+                <div class="sets-label label">
+                    Прозрачность
+                </div>
+                <div class="col row">
+                    <NumInput v-model="form.dualDivColorAlpha" class="col-left" :min="0" :max="1" :digits="2" :step="0.1" />
+                </div>
+            </div>
+
+            <div class="sets-item row">
+                <div class="sets-label label">
+                    Ширина (px)
+                </div>
+                <div class="col row">
+                    <NumInput v-model="form.dualDivWidth" class="col-left" :min="0" :max="100">
+                        <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
+                            Ширина разделителя
+                        </q-tooltip>
+                    </NumInput>
+                </div>
+            </div>
+
+            <div class="sets-item row">
+                <div class="sets-label label">
+                    Высота (%)
+                </div>
+                <div class="col row">
+                    <NumInput v-model="form.dualDivHeight" class="col-left" :min="0" :max="100">
+                        <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
+                            Высота разделителя
+                        </q-tooltip>
+                    </NumInput>
+                </div>
+            </div>
+
+            <div class="sets-item row">
+                <div class="sets-label label">
+                    Пунктир
+                </div>
+                <div class="col row">
+                    <NumInput v-model="form.dualDivStrokeFill" class="col-left" :min="0" :max="2000">
+                        <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
+                            Заполнение пунктира
+                        </q-tooltip>
+                    </NumInput>
+                    <div class="q-px-sm" />
+                    <NumInput v-model="form.dualDivStrokeGap" class="col" :min="0" :max="2000">
+                        <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
+                            Промежуток пунктира
+                        </q-tooltip>
+                    </NumInput>
+                </div>
+            </div>
+
+            <div class="sets-item row">
+                <div class="sets-label label">
+                    Ширина тени
+                </div>
+                <div class="col row">
+                    <NumInput v-model="form.dualDivShadowWidth" class="col-left" :min="0" :max="100" />
+                </div>
+            </div>
+        </div>
+    </div>
+</template>
+
+<script>
+//-----------------------------------------------------------------------------
+import vueComponent from '../../../../vueComponent.js';
+import NumInput from '../../../../share/NumInput.vue';
+import * as helper from '../helper';
+import defPalette from '../defPalette';
+
+const componentOptions = {
+    components: {
+        NumInput
+    },
+    watch: {
+        form: {
+            handler() {
+                this.formChanged();//no await
+            },
+            deep: true,
+        },
+        dualDivColorFiltered(newValue) {
+            if (!this.isFormChanged && this.helper.isHexColor(newValue))
+                this.form.dualDivColor = newValue;
+        },
+    }
+};
+class Mode {
+    _options = componentOptions;
+    _props = {
+        form: Object,
+    };
+
+    helper = helper;
+    defPalette = defPalette;
+
+    isFormChanged = false;
+    dualDivColorFiltered = '';
+
+    created() {
+        this.formChanged();//no await
+    }
+
+    mounted() {
+    }
+
+    async formChanged() {
+        this.isFormChanged = true;
+        try {
+            this.dualDivColorFiltered = this.form.dualDivColor;
+
+            if (this.form.dualPageMode 
+                && (this.form.pageChangeAnimation == 'flip' || this.form.pageChangeAnimation == 'rightShift')
+                )
+                this.form.pageChangeAnimation = '';
+        } finally {
+            await this.$nextTick();
+            this.isFormChanged = false;
+        }
+    }
+}
+
+export default vueComponent(Mode);
+//-----------------------------------------------------------------------------
+</script>
+
+<style scoped>
+.label {
+    width: 110px;
+}
+
+.col-left {
+    width: 145px;
+}
+
+.no-mp {
+    margin: 0;
+    padding: 0;
+}
+</style>

+ 0 - 64
client/components/Reader/SettingsPage/ViewTab/Status.inc

@@ -1,64 +0,0 @@
-<!---------------------------------------------->
-<div class="hidden part-header">Строка статуса</div>
-
-<div class="item row">
-    <div class="label-2">Статус</div>
-    <div class="col row">
-        <q-checkbox v-model="showStatusBar" size="xs" label="Показывать" />
-        <q-checkbox v-show="showStatusBar" class="q-ml-sm" v-model="statusBarTop" size="xs" label="Вверху/внизу" />
-    </div>
-</div>
-
-<div v-show="showStatusBar" class="item row no-wrap">
-    <div class="label-2">Цвет</div>
-    <div class="col-left row">
-        <q-input class="col-left no-mp"
-            outlined dense
-            v-model="statusBarColorFiltered"
-            :rules="['hexColor']"
-            style="max-width: 150px"
-            :disable="statusBarColorAsText"
-        >
-            <template v-slot:prepend>
-                <q-icon name="la la-angle-down la-xs" class="cursor-pointer text-white" :style="colorPanStyle('statusbar')">
-                    <q-popup-proxy anchor="bottom middle" self="top middle">
-                        <div>
-                            <q-color v-model="statusBarColor"
-                                no-header default-view="palette" :palette="predefineTextColors"
-                            />
-                        </div>
-                    </q-popup-proxy>
-                </q-icon>
-            </template>
-        </q-input>
-    </div>
-    
-    <div class="q-px-xs"/>
-    <q-checkbox v-model="statusBarColorAsText" size="xs" label="Как у текста"/>
-</div>
-
-<div v-show="showStatusBar" class="item row">
-    <div class="label-2">Прозрачность</div>
-    <div class="col row">
-        <NumInput class="col-left" v-model="statusBarColorAlpha" :min="0" :max="1" :digits="2" :step="0.1"/>
-    </div>
-</div>
-
-<div v-show="showStatusBar" class="item row">
-    <div class="label-2">Высота</div>
-    <div class="col row">
-        <NumInput class="col-left" v-model="statusBarHeight" :min="5" :max="100"/>
-    </div>
-</div>
-
-<div v-show="showStatusBar" class="item row">
-    <div class="label-2"></div>
-    <div class="col row">
-        <q-checkbox v-model="statusBarClickOpen" size="xs" label="Открывать оригинал по клику">
-            <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
-                По клику на автора-название в строке статуса<br>
-                открывать оригинал произведения в новой вкладке
-            </q-tooltip>
-        </q-checkbox>
-    </div>
-</div>

+ 153 - 0
client/components/Reader/SettingsPage/ViewTab/Status/Status.vue

@@ -0,0 +1,153 @@
+<template>
+    <div>
+        <!---------------------------------------------->
+        <div class="hidden sets-part-header">
+            Строка статуса
+        </div>
+
+        <div class="sets-item row">
+            <div class="sets-label label">
+                Статус
+            </div>
+            <div class="col row">
+                <q-checkbox v-model="form.showStatusBar" size="xs" label="Показывать" />
+                <q-checkbox v-show="form.showStatusBar" v-model="form.statusBarTop" class="q-ml-sm" size="xs" label="Вверху/внизу" />
+            </div>
+        </div>
+
+        <div v-show="form.showStatusBar" class="sets-item row no-wrap">
+            <div class="sets-label label">
+                Цвет
+            </div>
+            <div class="col-left row">
+                <q-input
+                    v-model="statusBarColorFiltered"
+                    class="col-left no-mp"
+                    outlined dense
+                    :rules="['hexColor']"
+                    style="max-width: 150px"
+                    :disable="form.statusBarColorAsText"
+                >
+                    <template #prepend>
+                        <q-icon name="la la-angle-down la-xs" class="cursor-pointer text-white" :style="helper.colorPanStyle(form.statusBarColor)">
+                            <q-popup-proxy anchor="bottom middle" self="top middle">
+                                <div>
+                                    <q-color
+                                        v-model="form.statusBarColor"
+                                        no-header default-view="palette" :palette="defPalette.predefineTextColors"
+                                    />
+                                </div>
+                            </q-popup-proxy>
+                        </q-icon>
+                    </template>
+                </q-input>
+            </div>
+            
+            <div class="q-px-xs" />
+            <q-checkbox v-model="form.statusBarColorAsText" size="xs" label="Как у текста" />
+        </div>
+
+        <div v-show="form.showStatusBar" class="sets-item row">
+            <div class="sets-label label">
+                Прозрачность
+            </div>
+            <div class="col row">
+                <NumInput v-model="form.statusBarColorAlpha" class="col-left" :min="0" :max="1" :digits="2" :step="0.1" />
+            </div>
+        </div>
+
+        <div v-show="form.showStatusBar" class="sets-item row">
+            <div class="sets-label label">
+                Высота
+            </div>
+            <div class="col row">
+                <NumInput v-model="form.statusBarHeight" class="col-left" :min="5" :max="100" />
+            </div>
+        </div>
+
+        <div v-show="form.showStatusBar" class="sets-item row">
+            <div class="sets-label label"></div>
+            <div class="col row">
+                <q-checkbox v-model="form.statusBarClickOpen" size="xs" label="Открывать оригинал по клику">
+                    <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
+                        По клику на автора-название в строке статуса<br>
+                        открывать оригинал произведения в новой вкладке
+                    </q-tooltip>
+                </q-checkbox>
+            </div>
+        </div>
+    </div>
+</template>
+
+<script>
+//-----------------------------------------------------------------------------
+import vueComponent from '../../../../vueComponent.js';
+import NumInput from '../../../../share/NumInput.vue';
+import * as helper from '../helper';
+import defPalette from '../defPalette';
+
+const componentOptions = {
+    components: {
+        NumInput,
+    },
+    watch: {
+        form: {
+            handler() {
+                this.formChanged();//no await
+            },
+            deep: true,
+        },
+        statusBarColorFiltered(newValue) {
+            if (!this.isFormChanged && this.helper.isHexColor(newValue))
+                this.form.statusBarColor = newValue;
+        },
+    },
+};
+class Text {
+    _options = componentOptions;
+    _props = {
+        form: Object,
+    };
+
+    helper = helper;
+    defPalette = defPalette;
+
+    statusBarColorFiltered = '';
+
+    created() {
+        this.formChanged();//no await
+    }
+
+    mounted() {
+    }
+
+    async formChanged() {
+        this.isFormChanged = true;
+        try {
+            this.statusBarColorFiltered = this.form.statusBarColor;
+        } finally {
+            await this.$nextTick();
+            this.isFormChanged = false;
+        }
+    }
+
+}
+
+export default vueComponent(Text);
+//-----------------------------------------------------------------------------
+</script>
+
+<style scoped>
+.label {
+    width: 110px;
+}
+
+.col-left {
+    width: 145px;
+}
+
+.no-mp {
+    margin: 0;
+    padding: 0;
+}
+</style>

+ 0 - 127
client/components/Reader/SettingsPage/ViewTab/Text.inc

@@ -1,127 +0,0 @@
-<!---------------------------------------------->
-<div class="hidden part-header">Текст</div>
-
-<div class="item row">
-    <div class="label-2">Интервал</div>
-    <div class="col row">
-        <NumInput class="col-left" v-model="lineInterval" :min="0" :max="200"/>
-    </div>
-</div>
-
-<div class="item row">
-    <div class="label-2">Параграф</div>
-    <div class="col row">
-        <NumInput class="col-left" v-model="p" :min="0" :max="2000"/>
-    </div>
-</div>
-
-<div class="item row">
-    <div class="label-2">Сдвиг</div>
-    <div class="col row">
-        <NumInput class="col-left" v-model="textVertShift" :min="-100" :max="100">
-            <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
-                Сдвиг текста по вертикали в процентах от размера шрифта.<br>
-                Отрицательное значение сдвигает вверх, положительное -<br>
-                вниз.
-            </q-tooltip>
-        </NumInput>
-    </div>
-</div>
-
-<div class="item row">
-    <div class="label-2">Скроллинг</div>
-    <div class="col row">
-        <NumInput class="col-left" v-model="scrollingDelay" :min="1" :max="10000">
-            <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
-                Замедление скроллинга в миллисекундах.<br>
-                Определяет время, за которое текст<br>
-                прокручивается на одну строку.
-            </q-tooltip>
-        </NumInput>
-
-        <div class="q-px-sm"/>
-        <q-select class="col" v-model="scrollingType" :options="['linear', 'ease', 'ease-in', 'ease-out', 'ease-in-out']"
-            dropdown-icon="la la-angle-down la-sm"
-            outlined dense emit-value map-options
-        >
-            <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
-                Вид скроллинга: линейный,<br>
-                ускорение-замедление и пр.
-            </q-tooltip>
-        </q-select>
-    </div>
-</div>
-
-<div class="item row">
-    <div class="label-2">Выравнивание</div>
-    <div class="col row">
-        <q-checkbox v-model="textAlignJustify" size="xs" label="По ширине" />
-        <q-checkbox class="q-ml-sm" v-model="wordWrap" size="xs" label="Перенос по слогам" />
-    </div>
-</div>
-
-<div class="item row">
-    <div class="label-2"></div>
-    <div class="col-left column justify-center text-right">
-        Компактность
-    </div>
-    <div class="q-px-sm"/>
-    <NumInput class="col" v-model="compactTextPerc" :min="0" :max="100">
-        <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
-            Степень компактности текста в процентах.<br>
-            Чем больше компактность, тем хуже выравнивание<br>
-            по правому краю.
-        </q-tooltip>
-    </NumInput>
-</div>
-
-<div class="item row">
-    <div class="label-2">Обработка</div>
-    <div class="col row">
-        <q-checkbox v-model="cutEmptyParagraphs" size="xs" label="Убирать пустые строки" />
-    </div>
-</div>
-
-<div class="item row">
-    <div class="label-2"></div>
-    <div class="col-left column justify-center text-right">
-        Добавлять пустые
-    </div>
-    <div class="q-px-sm"/>
-    <NumInput class="col" v-model="addEmptyParagraphs" :min="0" :max="2"/>
-</div>
-
-<div class="item row">
-    <div class="label-2">Изображения</div>
-    <div class="col row">
-        <q-checkbox v-model="showImages" size="xs" label="Показывать" />
-        <q-checkbox class="q-ml-sm" v-model="showInlineImagesInCenter" @input="needReload" :disable="!showImages" size="xs" label="Инлайн в центр">
-            <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
-                Выносить все изображения в центр экрана
-            </q-tooltip>
-        </q-checkbox>
-    </div>
-</div>
-
-<div class="item row">
-    <div class="label-2"></div>
-    <div class="col row">
-        <q-checkbox v-model="imageFitWidth" size="xs" label="Ширина не более размера страницы" :disable="!showImages || dualPageMode"/>
-    </div>
-</div>
-
-<div class="item row">
-    <div class="label-2"></div>
-    <div class="col-left column justify-center text-right">
-        Высота не более
-    </div>
-    <div class="q-px-sm"/>
-    <NumInput class="col" v-model="imageHeightLines" :min="1" :max="100" :disable="!showImages">
-        <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
-            Определяет высоту изображения количеством строк.<br>
-            В случае превышения высоты, изображение будет<br>
-            уменьшено с сохранением пропорций так, чтобы<br>
-            помещаться в указанное количество строк.
-        </q-tooltip>
-    </NumInput>
-</div>

+ 210 - 0
client/components/Reader/SettingsPage/ViewTab/Text/Text.vue

@@ -0,0 +1,210 @@
+<template>
+    <div>
+        <!---------------------------------------------->
+        <div class="hidden sets-part-header">
+            Текст
+        </div>
+
+        <div class="sets-item row">
+            <div class="sets-label label">
+                Интервал
+            </div>
+            <div class="col row">
+                <NumInput v-model="form.lineInterval" class="col-left" :min="0" :max="200" />
+            </div>
+        </div>
+
+        <div class="sets-item row">
+            <div class="sets-label label">
+                Параграф
+            </div>
+            <div class="col row">
+                <NumInput v-model="form.p" class="col-left" :min="0" :max="2000" />
+            </div>
+        </div>
+
+        <div class="sets-item row">
+            <div class="sets-label label">
+                Сдвиг
+            </div>
+            <div class="col row">
+                <NumInput v-model="form.textVertShift" class="col-left" :min="-100" :max="100">
+                    <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
+                        Сдвиг текста по вертикали в процентах от размера шрифта.<br>
+                        Отрицательное значение сдвигает вверх, положительное -<br>
+                        вниз.
+                    </q-tooltip>
+                </NumInput>
+            </div>
+        </div>
+
+        <div class="sets-item row">
+            <div class="sets-label label">
+                Скроллинг
+            </div>
+            <div class="col row">
+                <NumInput v-model="form.scrollingDelay" class="col-left" :min="1" :max="10000">
+                    <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
+                        Замедление скроллинга в миллисекундах.<br>
+                        Определяет время, за которое текст<br>
+                        прокручивается на одну строку.
+                    </q-tooltip>
+                </NumInput>
+
+                <div class="q-px-sm" />
+                <q-select
+                    v-model="form.scrollingType" class="col" :options="['linear', 'ease', 'ease-in', 'ease-out', 'ease-in-out']"
+                    dropdown-icon="la la-angle-down la-sm"
+                    outlined dense emit-value map-options
+                >
+                    <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
+                        Вид скроллинга: линейный,<br>
+                        ускорение-замедление и пр.
+                    </q-tooltip>
+                </q-select>
+            </div>
+        </div>
+
+        <div class="sets-item row">
+            <div class="sets-label label">
+                Выравнивание
+            </div>
+            <div class="col row">
+                <q-checkbox v-model="form.textAlignJustify" size="xs" label="По ширине" />
+                <q-checkbox v-model="form.wordWrap" class="q-ml-sm" size="xs" label="Перенос по слогам" />
+            </div>
+        </div>
+
+        <div class="sets-item row">
+            <div class="sets-label label"></div>
+            <div class="col-left column justify-center text-right">
+                Компактность
+            </div>
+            <div class="q-px-sm" />
+            <NumInput v-model="form.compactTextPerc" class="col" :min="0" :max="100">
+                <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
+                    Степень компактности текста в процентах.<br>
+                    Чем больше компактность, тем хуже выравнивание<br>
+                    по правому краю.
+                </q-tooltip>
+            </NumInput>
+        </div>
+
+        <div class="sets-item row">
+            <div class="sets-label label">
+                Обработка
+            </div>
+            <div class="col row">
+                <q-checkbox v-model="form.cutEmptyParagraphs" size="xs" label="Убирать пустые строки" />
+            </div>
+        </div>
+
+        <div class="sets-item row">
+            <div class="sets-label label"></div>
+            <div class="col-left column justify-center text-right">
+                Добавлять пустые
+            </div>
+            <div class="q-px-sm" />
+            <NumInput v-model="form.addEmptyParagraphs" class="col" :min="0" :max="2" />
+        </div>
+
+        <div class="sets-item row">
+            <div class="sets-label label">
+                Изображения
+            </div>
+            <div class="col row">
+                <q-checkbox v-model="form.showImages" size="xs" label="Показывать" />
+                <q-checkbox v-model="form.showInlineImagesInCenter" class="q-ml-sm" :disable="!form.showImages" size="xs" label="Инлайн в центр" @update:modelValue="needReload">
+                    <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
+                        Выносить все изображения в центр экрана
+                    </q-tooltip>
+                </q-checkbox>
+            </div>
+        </div>
+
+        <div class="sets-item row">
+            <div class="sets-label label"></div>
+            <div class="col row">
+                <q-checkbox v-model="form.imageFitWidth" size="xs" label="Ширина не более размера страницы" :disable="!form.showImages || form.dualPageMode" />
+            </div>
+        </div>
+
+        <div class="sets-item row">
+            <div class="sets-label label"></div>
+            <div class="col-left column justify-center text-right">
+                Высота не более
+            </div>
+            <div class="q-px-sm" />
+            <NumInput v-model="form.imageHeightLines" class="col" :min="1" :max="100" :disable="!form.showImages">
+                <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
+                    Определяет высоту изображения количеством строк.<br>
+                    В случае превышения высоты, изображение будет<br>
+                    уменьшено с сохранением пропорций так, чтобы<br>
+                    помещаться в указанное количество строк.
+                </q-tooltip>
+            </NumInput>
+        </div>
+    </div>
+</template>
+
+<script>
+//-----------------------------------------------------------------------------
+import vueComponent from '../../../../vueComponent.js';
+import NumInput from '../../../../share/NumInput.vue';
+
+const componentOptions = {
+    components: {
+        NumInput,
+    },
+    watch: {
+        form: {
+            handler() {
+                this.formChanged();//no await
+            },
+            deep: true,
+        },
+    },
+};
+class Text {
+    _options = componentOptions;
+    _props = {
+        form: Object,
+    };
+
+    statusBarColorFiltered = '';
+
+    created() {
+        this.formChanged();//no await
+    }
+
+    mounted() {
+    }
+
+    async formChanged() {
+        this.isFormChanged = true;
+        try {
+            //
+        } finally {
+            await this.$nextTick();
+            this.isFormChanged = false;
+        }
+    }
+
+    needReload() {
+        this.$root.notify.warning('Необходимо обновить страницу (F5), чтобы изменения возымели эффект');
+    }
+}
+
+export default vueComponent(Text);
+//-----------------------------------------------------------------------------
+</script>
+
+<style scoped>
+.label {
+    width: 110px;
+}
+
+.col-left {
+    width: 145px;
+}
+</style>

+ 75 - 0
client/components/Reader/SettingsPage/ViewTab/ViewTab.vue

@@ -0,0 +1,75 @@
+<template>
+    <div class="fit column">
+        <q-tabs
+            v-model="selectedTab"
+            active-color="black"
+            active-bg-color="white"
+            indicator-color="white"
+            dense
+            no-caps
+            class="no-mp bg-grey-4 text-grey-7"
+        >
+            <q-tab name="mode" label="Режим" />
+            <q-tab name="color" label="Цвет" />
+            <q-tab name="font" label="Шрифт" />
+            <q-tab name="text" label="Текст" />
+            <q-tab name="status" label="Строка статуса" />
+        </q-tabs>
+
+        <div class="q-mb-sm" />
+
+        <div class="col sets-tab-panel">
+            <Mode v-if="selectedTab == 'mode'" :form="form" />
+            <Color v-if="selectedTab == 'color'" :form="form" />
+            <Font v-if="selectedTab == 'font'" :form="form" />
+            <Text v-if="selectedTab == 'text'" :form="form" />
+            <Status v-if="selectedTab == 'status'" :form="form" />
+        </div>
+    </div>
+</template>
+
+<script>
+//-----------------------------------------------------------------------------
+import vueComponent from '../../../vueComponent.js';
+
+import Mode from './Mode/Mode.vue';
+import Color from './Color/Color.vue';
+import Font from './Font/Font.vue';
+import Text from './Text/Text.vue';
+import Status from './Status/Status.vue';
+
+const componentOptions = {
+    components: {
+        Mode,
+        Color,
+        Font,
+        Text,
+        Status,
+    },
+};
+class ViewTab {
+    _options = componentOptions;
+    _props = {
+        form: Object,
+    };
+
+    selectedTab = 'mode';
+
+    created() {
+    }
+
+    mounted() {
+    }
+
+}
+
+export default vueComponent(ViewTab);
+//-----------------------------------------------------------------------------
+</script>
+
+<style scoped>
+.label {
+    width: 75px;
+}
+
+</style>

+ 29 - 1
client/components/Reader/SettingsPage/defPalette.js → client/components/Reader/SettingsPage/ViewTab/defPalette.js

@@ -14,4 +14,32 @@ const defPalette = [
     'rgb(255,255,255)', 'rgb(205,205,205)', 'rgb(178,178,178)', 'rgb(153,153,153)', 'rgb(127,127,127)', 'rgb(102,102,102)', 'rgb(76,76,76)', 'rgb(51,51,51)', 'rgb(25,25,25)', 'rgb(0,0,0)'
 ];
 
-export default defPalette;
+export default {
+    predefinePalette: defPalette,
+
+    predefineTextColors: defPalette.concat([
+        '#ffffff',
+        '#000000',
+        '#202020',
+        '#323232',
+        '#aaaaaa',
+        '#00c0c0',
+        '#ebe2c9',
+        '#cfdc99',
+        '#478355',
+        '#909080',
+    ]),
+
+    predefineBackgroundColors: defPalette.concat([
+        '#ffffff',
+        '#000000',
+        '#202020',
+        '#ebe2c9',
+        '#cfdc99',
+        '#478355',
+        '#a6caf0',
+        '#909080',
+        '#808080',
+        '#c8c8c8',
+    ]),
+};

+ 9 - 0
client/components/Reader/SettingsPage/ViewTab/helper.js

@@ -0,0 +1,9 @@
+const hex = /^#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$/;
+
+export function colorPanStyle(bgColor) {
+    return `width: 30px; height: 30px; border: 1px solid black; border-radius: 4px; background-color: ${bgColor}`;
+}
+
+export function isHexColor(value) {
+    return hex.test(value);
+}

+ 36 - 72
client/components/Reader/TextPage/TextPage.vue

@@ -81,9 +81,6 @@ const componentOptions = {
         settings: function() {
             this.debouncedLoadSettings();
         },
-        toggleLayout: function() {
-            this.updateLayout();
-        },
         inAnimation: function() {
             this.updateLayout();
         },
@@ -92,7 +89,6 @@ const componentOptions = {
 class TextPage {
     _options = componentOptions;
 
-    toggleLayout = false;
     showStatusBar = false;
     clickControl = true;
 
@@ -130,10 +126,6 @@ class TextPage {
             this.startClickRepeat(x, y);
         }, 800);
 
-        this.debouncedPrepareNextPage = _.debounce(() => {
-            this.prepareNextPage();
-        }, 100);
-
         this.debouncedDrawStatusBar = _.throttle(() => {
             this.drawStatusBar();
         }, 60);
@@ -147,17 +139,11 @@ class TextPage {
         }, 50);
 
         this.debouncedUpdatePage = _.debounce(async(lines) => {
-            if (!this.pageChangeAnimation)
-                this.toggleLayout = !this.toggleLayout;
-            else {
+            if (this.pageChangeAnimation) {
                 this.page2 = this.page1;
-                this.toggleLayout = true;
             }
 
-            if (this.toggleLayout)
-                this.page1 = this.drawHelper.drawPage(lines);
-            else
-                this.page2 = this.drawHelper.drawPage(lines);
+            this.page1 = this.drawHelper.drawPage(lines);
 
             await this.doPageAnimation();
         }, 10);
@@ -174,7 +160,12 @@ class TextPage {
     }
 
     hex2rgba(hex, alpha = 1) {
-        const [r, g, b] = hex.match(/\w\w/g).map(x => parseInt(x, 16));
+        let [r, g, b] = [0, 0, 0];
+        if (hex.length <= 4) {
+            [r, g, b] = hex.match(/\w/g).map(x => parseInt(x + x, 16));
+        } else {
+            [r, g, b] = hex.match(/\w\w/g).map(x => parseInt(x, 16));
+        }
         return `rgba(${r},${g},${b},${alpha})`;
     }
 
@@ -425,7 +416,6 @@ class TextPage {
     showBook() {
         this.$refs.main.focus();
 
-        this.toggleLayout = false;
         this.updateLayout();
         this.book = null;
         this.meta = null;
@@ -483,12 +473,9 @@ class TextPage {
         if (this.inAnimation) {
             this.$refs.scrollBox1.style.visibility = 'visible';
             this.$refs.scrollBox2.style.visibility = 'visible';
-        } else if (this.toggleLayout) {
+        } else {
             this.$refs.scrollBox1.style.visibility = 'visible';
             this.$refs.scrollBox2.style.visibility = 'hidden';
-        } else {
-            this.$refs.scrollBox1.style.visibility = 'hidden';
-            this.$refs.scrollBox2.style.visibility = 'visible';
         }
     }
 
@@ -589,28 +576,25 @@ class TextPage {
 
         const transitionFinish = this.generateWaitingFunc('resolveTransition1Finish', 'stopScrolling');
 
-        if (!this.toggleLayout)
-            this.page1 = this.page2;
-        this.toggleLayout = true;
-        await this.$nextTick();
-        await utils.sleep(50);
-
         this.cachedPos = -1;
         this.draw();
 
         const page = this.$refs.scrollingPage1;
         let i = 0;
         while (!this.stopScrolling) {
-                page.style.transition = `${this.scrollingDelay}ms ${this.scrollingType}`;
-                page.style.transform = `translateY(-${this.lineHeight}px)`;
-
                 if (i > 0) {
                     this.doDown();
+                    await utils.sleep(1);
+                    await this.$nextTick();
                     if (this.linesDown.length <= this.pageLineCount + 1) {
                         this.stopScrolling = true;
                     }
                 }
+
+                page.style.transition = `${this.scrollingDelay}ms ${this.scrollingType}`;
+                page.style.transform = `translateY(-${this.lineHeight}px)`;
                 await transitionFinish(this.scrollingDelay);
+
                 page.style.transition = '';
                 page.style.transform = 'none';
                 page.offsetHeight;
@@ -678,21 +662,11 @@ class TextPage {
             return;
         }
 
-        //fast draw prepared
-        if (!this.pageChangeAnimation && this.pageChangeDirectionDown && this.pagePrepared && this.bookPos == this.bookPosPrepared) {
-            this.toggleLayout = !this.toggleLayout;
-            this.linesDown = this.linesDownNext;
-            this.linesUp = this.linesUpNext;
-        } else {//normal debounced draw
-            const lines = this.getLines(this.bookPos);
-            this.linesDown = lines.linesDown;
-            this.linesUp = lines.linesUp;
-            this.debouncedUpdatePage(lines.linesDown);
-        }
+        const lines = this.getLines(this.bookPos);
+        this.linesDown = lines.linesDown;
+        this.linesUp = lines.linesUp;
+        this.debouncedUpdatePage(lines.linesDown);
 
-        this.pagePrepared = false;
-        if (!this.pageChangeAnimation)
-            this.debouncedPrepareNextPage();
         this.debouncedDrawStatusBar();
         this.debouncedDrawPageDividerAndOrnament();
 
@@ -907,30 +881,6 @@ class TextPage {
         }
     }
 
-    prepareNextPage() {
-        // подготовка следующей страницы заранее        
-        if (!this.book || !this.parsed.textLength || !this.linesDown || this.pageLineCount < 1)
-            return;
-        
-        let i = this.pageLineCount;
-        if (this.keepLastToFirst)
-            i--;
-        if (i >= 0 && this.linesDown.length > i) {
-            this.bookPosPrepared = this.linesDown[i].begin;
-            
-            const lines = this.getLines(this.bookPosPrepared);
-            this.linesDownNext = lines.linesDown;
-            this.linesUpNext =  lines.linesUp;
-
-            if (this.toggleLayout)
-                this.page2 = this.drawHelper.drawPage(lines.linesDown);//наоборот
-            else
-                this.page1 = this.drawHelper.drawPage(lines.linesDown);
-
-            this.pagePrepared = true;
-        }
-    }
-
     doDown() {
         if (this.linesDown && this.linesDown.length > this.pageLineCount && this.pageLineCount > 0) {
             this.userBookPosChange = true;
@@ -1117,6 +1067,7 @@ class TextPage {
             if (this.startTouch) {
                 const dy = this.startTouch.y - y;
                 const dx = this.startTouch.x - x;
+                this.startTouch = null;
                 const moveDelta = 30;
                 const touchDelta = 15;
                 if (dy > 0 && Math.abs(dy) >= moveDelta && Math.abs(dy) > Math.abs(dx)) {
@@ -1132,10 +1083,23 @@ class TextPage {
                     //движение вправо
                     this.doScrollingSpeedUp();
                 } else if (Math.abs(dy) < touchDelta && Math.abs(dx) < touchDelta) {
-                    this.doToolBarToggle(event);
-                }
+                    if (this.touchMode) {
+                        this.touchMode = 2;
+                        return;
+                    }
 
-                this.startTouch = null;
+                    (async() => {
+                        this.touchMode = 1;
+                        let i = 20;
+                        while (i-- > 0 && this.touchMode === 1)
+                            await utils.sleep(10);
+                        if (this.touchMode === 1)
+                            this.doToolBarToggle();
+                        else
+                            this.doFullScreenToggle();
+                        this.touchMode = 0;
+                    })();
+                }
             }
         }
     }

+ 16 - 0
client/components/Reader/versionHistory.js

@@ -1,4 +1,20 @@
 export const versionHistory = [
+{
+    version: '1.0.0',
+    releaseDate: '2022-12-18',
+    showUntil: '2022-12-25',
+    content:
+`
+<ul>
+    <li>на мобильных устройствах переход в полноэкранный режим теперь возможен через двойной тап по центру</li>
+    <li>добавлено окно "Сетевая библиотека" для omnireader.ru</li>
+    <li>улучшена работа синхронизации с сервером при плохом качестве связи</li>
+    <li>добавлена сборка релизов читалки: <a href="https://github.com/bookpauk/liberama/releases" target="_blank">https://github.com/bookpauk/liberama/releases</a></li>
+</ul>
+
+`
+},
+
 {
     version: '0.12.2',
     releaseDate: '2022-09-04',

+ 0 - 19
client/components/Settings/Settings.vue

@@ -1,19 +0,0 @@
-<template>
-    <div>
-        Раздел Settings в разработке
-    </div>
-</template>
-
-<script>
-//-----------------------------------------------------------------------------
-import vueComponent from '../vueComponent.js';
-
-class Settings {
-    created() {
-    }
-
-}
-
-export default vueComponent(Settings);
-//-----------------------------------------------------------------------------
-</script>

+ 0 - 19
client/components/Sources/Sources.vue

@@ -1,19 +0,0 @@
-<template>
-    <div>
-        Раздел Sources в разработке
-    </div>
-</template>
-
-<script>
-//-----------------------------------------------------------------------------
-import vueComponent from '../vueComponent.js';
-
-class Sources {
-    created() {
-    }
-
-}
-
-export default vueComponent(Sources);
-//-----------------------------------------------------------------------------
-</script>

+ 0 - 1
client/components/share/Notify.vue

@@ -27,7 +27,6 @@ class Notify {
             icon,
             actions: [{icon: 'la la-times notify-button-icon', color: 'black'}],
             html: true,
-            classes: 'notify-margin',
 
             message: 
                 `<div style="max-width: 350px">

+ 94 - 24
client/components/share/NumInput.vue

@@ -6,15 +6,26 @@
         class="no-mp"
         :class="(error ? 'error' : '')"
         :disable="disable"
+        :mask="mask"
     >
         <slot></slot>
         <template #prepend>
+            <q-icon
+                v-show="mmButtons"
+                v-ripple="modelValue != min" 
+                style="font-size: 100%"
+                :class="(modelValue != min ? '' : 'disable')" 
+                name="la la-angle-double-left" 
+                class="button" 
+                @click="toMin"
+            />
+
             <q-icon
                 v-ripple="validate(modelValue - step)" 
                 :class="(validate(modelValue - step) ? '' : 'disable')" 
-                name="la la-minus-circle" 
+                :name="minusIcon" 
                 class="button" 
-                @click="minus"
+                @click="onClick('minus')"
                 @mousedown.prevent.stop="onMouseDown($event, 'minus')"
                 @mouseup.prevent.stop="onMouseUp"
                 @mouseout.prevent.stop="onMouseUp"
@@ -27,9 +38,9 @@
             <q-icon
                 v-ripple="validate(modelValue + step)"
                 :class="(validate(modelValue + step) ? '' : 'disable')"
-                name="la la-plus-circle"
+                :name="plusIcon"
                 class="button"
-                @click="plus"
+                @click="onClick('plus')"
                 @mousedown.prevent.stop="onMouseDown($event, 'plus')"
                 @mouseup.prevent.stop="onMouseUp"
                 @mouseout.prevent.stop="onMouseUp"
@@ -37,6 +48,16 @@
                 @touchend.stop="onTouchEnd"
                 @touchcancel.prevent.stop="onTouchEnd"
             />
+
+            <q-icon
+                v-show="mmButtons"
+                v-ripple="modelValue != max" 
+                style="font-size: 100%"
+                :class="(modelValue != max ? '' : 'disable')" 
+                name="la la-angle-double-right" 
+                class="button" 
+                @click="toMax"
+            />
         </template>
     </q-input>
 </template>
@@ -49,17 +70,18 @@ import * as utils from '../../share/utils';
 
 const componentOptions = {
     watch: {
-        filteredValue: function(newValue) {
-            if (this.validate(newValue)) {
-                this.error = false;
-                this.$emit('update:modelValue', this.string2number(newValue));
-            } else {
-                this.error = true;
-            }
+        filteredValue() {
+            this.checkErrorAndEmit(true);
         },
-        modelValue: function(newValue) {
+        modelValue(newValue) {
             this.filteredValue = newValue;
         },
+        min() {
+            this.checkErrorAndEmit();
+        },
+        max() {
+            this.checkErrorAndEmit();
+        }
     }
 };
 class NumInput {
@@ -70,7 +92,11 @@ class NumInput {
         max: { type: Number, default: Number.MAX_VALUE },
         step: { type: Number, default: 1 },
         digits: { type: Number, default: 0 },
-        disable: Boolean
+        disable: Boolean,
+        minusIcon: {type: String, default: 'la la-minus-circle'},
+        plusIcon: {type: String, default: 'la la-plus-circle'},
+        mmButtons: Boolean,
+        mask: String,
     };
 
     filteredValue = 0;
@@ -95,6 +121,16 @@ class NumInput {
         return true;
     }
 
+    checkErrorAndEmit(emit = false) {
+        if (this.validate(this.filteredValue)) {
+            this.error = false;
+            if (emit)
+                this.$emit('update:modelValue', this.string2number(this.filteredValue));
+        } else {
+            this.error = true;
+        }
+    }
+
     plus() {
         const newValue = this.modelValue + this.step;
         if (this.validate(newValue))
@@ -107,23 +143,42 @@ class NumInput {
             this.filteredValue = newValue;
     }
 
+    onClick(way) {
+        if (this.clickRepeat)
+            return;
+
+        if (way == 'plus') {
+            this.plus();
+        } else {
+            this.minus();
+        }
+    }
+
     onMouseDown(event, way) {
         this.startClickRepeat = true;
         this.clickRepeat = false;
 
         if (event.button == 0) {
             (async() => {
-                await utils.sleep(300);
-                if (this.startClickRepeat) {
-                    this.clickRepeat = true;
-                    while (this.clickRepeat) {
-                        if (way == 'plus') {
-                            this.plus();
-                        } else {
-                            this.minus();
+                if (this.inRepeatFunc)
+                    return;
+
+                this.inRepeatFunc = true;
+                try {
+                    await utils.sleep(300);
+                    if (this.startClickRepeat) {
+                        this.clickRepeat = true;
+                        while (this.clickRepeat) {
+                            if (way == 'plus') {
+                                this.plus();
+                            } else {
+                                this.minus();
+                            }
+                            await utils.sleep(100);
                         }
-                        await utils.sleep(50);
                     }
+                } finally {
+                    this.inRepeatFunc = false;
                 }
             })();
         }
@@ -133,7 +188,12 @@ class NumInput {
         if (this.inTouch)
             return;
         this.startClickRepeat = false;
-        this.clickRepeat = false;
+        if (this.clickRepeat) {
+            (async() => {
+                await utils.sleep(50);
+                this.clickRepeat = false;
+            })();
+        }
     }
 
     onTouchStart(event, way) {
@@ -151,6 +211,14 @@ class NumInput {
         this.inTouch = false;
         this.onMouseUp();
     }
+
+    toMin() {
+        this.filteredValue = this.min;
+    }
+
+    toMax() {
+        this.filteredValue = this.max;
+    }
 }
 
 export default vueComponent(NumInput);
@@ -165,7 +233,9 @@ export default vueComponent(NumInput);
 
 .button {
     font-size: 130%;
-    border-radius: 20px;
+    border-radius: 15px;
+    width: 30px;
+    height: 30px;
     color: #bbb;
     cursor: pointer;
 }

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

@@ -10,7 +10,9 @@
                     @touchend.stop="onTouchEnd"
                     @touchmove.stop="onTouchMove"
                 >
-                    <span class="header-text col"><slot name="header"></slot></span>
+                    <div class="header-text col" style="width: 0">
+                        <slot name="header"></slot>
+                    </div>
                     <slot name="buttons"></slot>
                     <span class="close-button row justify-center items-center" @mousedown.stop @click="close"><q-icon name="la la-times" size="16px" /></span>
                 </div>

+ 24 - 15
client/components/vueComponent.js

@@ -17,7 +17,7 @@ export default function(componentClass) {
                     }
                 }
             } else if (prop === '_props') {
-                comp['props'] = obj[prop];
+                comp.props = obj[prop];
             }
         } else {//usual prop
             data[prop] = obj[prop];
@@ -26,23 +26,32 @@ export default function(componentClass) {
     comp.data = () => _.cloneDeep(data);
     
     //methods
-    const classProto = Object.getPrototypeOf(obj);
-    const classMethods = Object.getOwnPropertyNames(classProto);
     const methods = {};
     const computed = {};
-    for (const method of classMethods) {
-        const desc = Object.getOwnPropertyDescriptor(classProto, method);
-        if (desc.get) {//has getter, computed
-            computed[method] = {get: desc.get};
-            if (desc.set)
-                computed[method].set = desc.set;
-        } else if ( ['beforeCreate', 'created', 'beforeMount', 'mounted', 'beforeUpdate', 'updated', 'activated',//life cycle hooks
-                    'deactivated', 'beforeUnmount', 'unmounted', 'errorCaptured', 'renderTracked', 'renderTriggered',//life cycle hooks
-                    'setup'].includes(method) ) {
-            comp[method] = obj[method];
-        } else if (method !== 'constructor') {//usual
-            methods[method] = obj[method];
+
+    let classProto = Object.getPrototypeOf(obj);
+    while (classProto) {
+        const classMethods = Object.getOwnPropertyNames(classProto);
+        for (const method of classMethods) {
+            const desc = Object.getOwnPropertyDescriptor(classProto, method);
+            if (desc.get) {//has getter, computed
+                if (!computed[method]) {
+                    computed[method] = {get: desc.get};
+                    if (desc.set)
+                        computed[method].set = desc.set;
+                }
+            } else if ( ['beforeCreate', 'created', 'beforeMount', 'mounted', 'beforeUpdate', 'updated', 'activated',
+                        'deactivated', 'beforeUnmount', 'unmounted', 'errorCaptured', 'renderTracked', 'renderTriggered',
+                        'setup'].includes(method) ) {//life cycle hooks
+                if (!comp[method])
+                    comp[method] = obj[method];
+            } else if (method !== 'constructor') {//usual
+                if (!methods[method])
+                    methods[method] = obj[method];
+            }
         }
+
+        classProto = Object.getPrototypeOf(classProto);
     }
     comp.methods = methods;
     comp.computed = computed;

+ 2 - 27
client/router.js

@@ -1,41 +1,16 @@
 import { createRouter, createWebHashHistory } from 'vue-router';
 import _ from 'lodash';
 
-const CardIndex = () => import('./components/CardIndex/CardIndex.vue');
-const Search = () => import('./components/CardIndex/Search/Search.vue');
-const Card = () => import('./components/CardIndex/Card/Card.vue');
-const Book = () => import('./components/CardIndex/Book/Book.vue');
-const History = () => import('./components/CardIndex/History/History.vue');
-
 //немедленная загрузка
 //import Reader from './components/Reader/Reader.vue';
 const Reader = () => import('./components/Reader/Reader.vue');
 const ExternalLibs = () => import('./components/ExternalLibs/ExternalLibs.vue');
 
-const Income = () => import('./components/Income/Income.vue');
-const Sources = () => import('./components/Sources/Sources.vue');
-const Settings = () => import('./components/Settings/Settings.vue');
-const Help = () => import('./components/Help/Help.vue');
-const NotFound404 = () => import('./components/NotFound404/NotFound404.vue');
-
 const myRoutes = [
-    ['/', null, null, '/cardindex'],
-    ['/cardindex', CardIndex],
-    ['/cardindex~search', Search],
-    ['/cardindex~card', Card],
-    ['/cardindex~card/:authorId', Card],
-    ['/cardindex~book', Book],
-    ['/cardindex~book/:bookId', Book],
-    ['/cardindex~history', History],
-
+    ['/', null, null, '/reader'],
     ['/reader', Reader],
     ['/external-libs', ExternalLibs],
-    ['/income', Income],
-    ['/sources', Sources],
-    ['/settings', Settings],
-    ['/help', Help],
-    ['/404', NotFound404],
-    ['/:pathMatch(.*)*', null, null, '/cardindex'],
+    ['/:pathMatch(.*)*', null, null, '/reader'],
 ];
 
 let routes = {};

+ 5 - 18
client/share/utils.js

@@ -1,4 +1,5 @@
 import _ from 'lodash';
+import dayjs from 'dayjs';
 import baseX from 'base-x';
 import PAKO from 'pako';
 import {Buffer} from 'safe-buffer';
@@ -35,24 +36,6 @@ export function randomHexString(len) {
     return Buffer.from(randomArray(len)).toString('hex');
 }
 
-export function formatDate(d, format) {
-    if (!format)
-        format = 'normal';
-
-    switch (format) {
-        case 'normal':
-            return `${d.getDate().toString().padStart(2, '0')}.${(d.getMonth() + 1).toString().padStart(2, '0')}.${d.getFullYear()} ` + 
-                `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
-        case 'coDate':
-            return `${d.getFullYear()}-${(d.getMonth() + 1).toString().padStart(2, '0')}-${d.getDate().toString().padStart(2, '0')}`;
-        case 'coMonth':
-            return `${(d.getMonth() + 1).toString().padStart(2, '0')}`;
-        case 'noDate':
-            return `${d.getDate().toString().padStart(2, '0')}.${(d.getMonth() + 1).toString().padStart(2, '0')}.${d.getFullYear()}`;
-    }
-    
-}
-
 export function fallbackCopyTextToClipboard(text) {
     let textArea = document.createElement('textarea');
     textArea.value = text;
@@ -416,3 +399,7 @@ export function resizeImage(dataUrl, toWidth, toHeight, quality = 0.9) {
 export function makeDonation() {
     window.open('https://donatty.com/liberama', '_blank');
 }
+
+export function dateFormat(date, format = 'DD.MM.YYYY') {
+    return dayjs(date).format(format);
+}

+ 64 - 37
client/store/modules/reader.js

@@ -1,6 +1,10 @@
 import * as utils from '../../share/utils';
 import googleFonts from './fonts/fonts.json';
 
+const minuteMs = 60*1000;//количество ms в минуте
+const hourMs = 60*minuteMs;//количество ms в часе
+const dayMs = 24*hourMs;//количество ms в сутках
+
 const readerActions = {
     'loader': 'На страницу загрузки',
     'loadFile': 'Загрузить файл с диска',
@@ -44,17 +48,17 @@ const toolButtons = [
     {name: 'undoAction',  show: true},
     {name: 'redoAction',  show: true},
     {name: 'fullScreen',  show: true},
-    {name: 'scrolling',   show: false},
+    {name: 'scrolling',   show: true},
     {name: 'setPosition', show: true},
     {name: 'search',      show: true},
-    {name: 'copyText',    show: false},
+    {name: 'copyText',    show: true},
     {name: 'convOptions', show: true},
     {name: 'refresh',     show: true},
     {name: 'contents',    show: true},
     {name: 'libs',        show: true},
     {name: 'recentBooks', show: true},
-    {name: 'clickControl', show: false},
-    {name: 'offlineMode', show: false},
+    {name: 'clickControl', show: true},
+    {name: 'offlineMode', show: true},
 ];
 
 //readerActions[name]
@@ -186,6 +190,7 @@ const settingDefaults = {
     fontShifts: {},
     showToolButton: {},
     toolBarHideOnScroll: false,
+    toolBarMultiLine: true,
     userHotKeys: {},
     userWallpapers: [],
 
@@ -198,10 +203,6 @@ const settingDefaults = {
     bucSetOnNew: true, // автоматически включать проверку обновлений для вновь загружаемых файлов
     bucCancelEnabled: true, // вкл/выкл отмену проверки книг через bucCancelDays
     bucCancelDays: 90, // количество дней, через которое отменяется проверка книги, при условии отсутствия обновлений за это время
-
-    //для SettingsPage
-    needUpdateSettingsView: 0,
-
 };
 
 for (const font of fonts)
@@ -227,30 +228,52 @@ function addDefaultsToSettings(settings) {
     return false;
 }
 
-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: 'Флибуста | Книжное братство'},
-        ]},
-        {r: 'http://fantasy-worlds.org', s: 'http://fantasy-worlds.org', list: [
-            {l: 'http://fantasy-worlds.org', c: 'Миры Фэнтези'},
-        ]},
-        {r: 'http://samlib.ru', s: 'http://samlib.ru', list: [
-            {l: 'http://samlib.ru', c: 'Журнал "Самиздат"'},
-        ]},
-        {r: 'http://lib.ru', s: 'http://lib.ru', list: [
-            {l: 'http://lib.ru', c: 'Библиотека Максима Мошкова'},
-        ]},
-        {r: 'https://aldebaran.ru', s: 'https://aldebaran.ru', list: [
-            {l: 'https://aldebaran.ru', c: 'АЛЬДЕБАРАН | Электронная библиотека книг'},
-        ]},
-    ]
-};
+function getLibsDefaults(mode = 'reader') {
+    const result = {
+        startLink: '',
+        comment: '',
+        closeAfterSubmit: false,
+        openInFrameOnEnter: false,
+        openInFrameOnAdd: false,
+        helpShowed: false,
+        mode,
+        groups: [
+            {r: 'http://samlib.ru', s: 'http://samlib.ru', list: [
+                {l: 'http://samlib.ru', c: 'Журнал "Самиздат"'},
+            ]},
+            {r: 'http://lib.ru', s: 'http://lib.ru', list: [
+                {l: 'http://lib.ru', c: 'Библиотека Максима Мошкова'},
+            ]},
+            {r: 'https://aldebaran.ru', s: 'https://aldebaran.ru', list: [
+                {l: 'https://aldebaran.ru', c: 'АЛЬДЕБАРАН | Электронная библиотека книг'},
+            ]},
+        ],
+    };
+
+    if (mode === 'liberama') {
+        result.groups.unshift(
+            {r: 'http://fantasy-worlds.org', s: 'http://fantasy-worlds.org', list: [
+                {l: 'http://fantasy-worlds.org', c: 'Миры Фэнтези'},
+            ]}
+        );
+        result.groups.unshift(
+            {r: 'http://flibusta.is', s: 'http://flibusta.is', list: [
+                {l: 'http://flibusta.is', c: 'Флибуста | Книжное братство'},
+            ]}
+        );
+    } else if (mode === 'omnireader') {
+        result.groups.unshift(
+            {r: 'https://lib.omnireader.ru', s: 'https://lib.omnireader.ru', list: [
+                {l: 'https://lib.omnireader.ru', c: 'Общественное достояние'},
+            ]}
+        );
+    }
+
+    result.startLink = result.groups[0].r;
+    result.comment = result.groups[0].c;
+
+    return result;
+}
 
 // initial state
 const state = {
@@ -262,11 +285,11 @@ const state = {
     profilesRev: 0,
     allowProfilesSave: false,//подстраховка для разработки
     whatsNewContentHash: '',
-    donationRemindDate: '',
+    donationNextPopup: Date.now() + dayMs*30,
     currentProfile: '',
     settings: Object.assign({}, settingDefaults),
     settingsRev: {},
-    libs: Object.assign({}, libsDefaults),
+    libs: false,
     libsRev: 0,
 };
 
@@ -302,8 +325,8 @@ const mutations = {
     setWhatsNewContentHash(state, value) {
         state.whatsNewContentHash = value;
     },
-    setDonationRemindDate(state, value) {
-        state.donationRemindDate = value;
+    setDonationNextPopup(state, value) {
+        state.donationNextPopup = value;
     },
     setCurrentProfile(state, value) {
         state.currentProfile = value;
@@ -329,6 +352,10 @@ const mutations = {
 };
 
 export default {
+    minuteMs,
+    hourMs,
+    dayMs,
+
     readerActions,
     toolButtons,
     hotKeys,
@@ -336,7 +363,7 @@ export default {
     webFonts,
     settingDefaults,
     addDefaultsToSettings,
-    libsDefaults,
+    getLibsDefaults,
 
     namespaced: true,
     state,

+ 14 - 10
docs/beta/beta.liberama

@@ -87,18 +87,22 @@ server {
     proxy_read_timeout 600s;
   }
 
-  location / {
-    root /home/beta.liberama/public;
+  location /tmp {
+    root /home/beta.liberama/.liberama/public-files;
 
-    location /tmp {
-      types { } default_type "application/xml; charset=utf-8";
-      add_header Content-Encoding gzip;
-      try_files $uri @liberama;
-    }
+    types { } default_type "application/xml; charset=utf-8";
+    add_header Content-Encoding gzip;
+    try_files $uri @liberama;
+  }
 
-    location /upload {
-      try_files $uri @liberama;
-    }
+  location /upload {
+    root /home/beta.liberama/.liberama/public-files;
+
+    try_files $uri @liberama;
+  }
+
+  location / {
+    root /home/beta.liberama/.liberama/public;
 
     location ~* \.(?:manifest|appcache|html)$ {
       expires -1;

+ 66 - 10
docs/beta/beta.omnireader

@@ -32,18 +32,22 @@ server {
     proxy_read_timeout 600s;
   }
 
-  location / {
-    root /home/beta.liberama/public;
+  location /tmp {
+    root /home/beta.liberama/.liberama/public-files;
 
-    location /tmp {
-      types { } default_type "application/xml; charset=utf-8";
-      add_header Content-Encoding gzip;
-      try_files $uri @liberama;
-    }
+    types { } default_type "application/xml; charset=utf-8";
+    add_header Content-Encoding gzip;
+    try_files $uri @liberama;
+  }
 
-    location /upload {
-      try_files $uri @liberama;
-    }
+  location /upload {
+    root /home/beta.liberama/.liberama/public-files;
+
+    try_files $uri @liberama;
+  }
+
+  location / {
+    root /home/beta.liberama/.liberama/public;
 
     location ~* \.(?:manifest|appcache|html)$ {
       expires -1;
@@ -57,3 +61,55 @@ server {
 
   return 301 https://$host$request_uri;
 }
+
+server {
+  listen 80;
+  server_name b.beta.omnireader.ru;
+  set $liberama http://127.0.0.1:34081;
+
+  client_max_body_size 50m;
+  proxy_read_timeout 1h;
+
+  gzip on;
+  gzip_min_length 1024;
+  gzip_proxied expired no-cache no-store private auth;
+  gzip_types *;
+
+  location @liberama {
+    proxy_pass $liberama;
+  }
+
+  location /api {
+    proxy_pass $liberama;
+  }
+
+  location /ws {
+    proxy_pass $liberama;
+    proxy_http_version 1.1;
+    proxy_set_header Upgrade $http_upgrade;
+    proxy_set_header Connection "upgrade";
+    proxy_read_timeout 600s;
+  }
+
+  location /tmp {
+    root /home/beta.liberama/.liberama/public-files;
+
+    types { } default_type "application/xml; charset=utf-8";
+    add_header Content-Encoding gzip;
+    try_files $uri @liberama;
+  }
+
+  location /upload {
+    root /home/beta.liberama/.liberama/public-files;
+
+    try_files $uri @liberama;
+  }
+
+  location / {
+    root /home/beta.liberama/.liberama/public;
+
+    location ~* \.(?:manifest|appcache|html)$ {
+      expires -1;
+    }
+  }
+}

+ 15 - 11
docs/beta/beta.omnireader_http

@@ -1,6 +1,6 @@
 server {
   listen 80;
-  server_name beta.omnireader.ru;
+  server_name beta.omnireader.ru b.beta.omnireader.ru;
   set $liberama http://127.0.0.1:34081;
 
   client_max_body_size 50m;
@@ -27,18 +27,22 @@ server {
     proxy_read_timeout 600s;
   }
 
-  location / {
-    root /home/beta.liberama/public;
+  location /tmp {
+    root /home/beta.liberama/.liberama/public-files;
 
-    location /tmp {
-      types { } default_type "application/xml; charset=utf-8";
-      add_header Content-Encoding gzip;
-      try_files $uri @liberama;
-    }
+    types { } default_type "application/xml; charset=utf-8";
+    add_header Content-Encoding gzip;
+    try_files $uri @liberama;
+  }
 
-    location /upload {
-      try_files $uri @liberama;
-    }
+  location /upload {
+    root /home/beta.liberama/.liberama/public-files;
+
+    try_files $uri @liberama;
+  }
+
+  location / {
+    root /home/beta.liberama/.liberama/public;
 
     location ~* \.(?:manifest|appcache|html)$ {
       expires -1;

+ 28 - 20
docs/liberama.top/liberama

@@ -43,18 +43,22 @@ server {
     proxy_read_timeout 600s;
   }
 
-  location / {
-    root /home/liberama/public;
+  location /tmp {
+    root /home/liberama/.liberama/public-files;
 
-    location /tmp {
-      types { } default_type "application/xml; charset=utf-8";
-      add_header Content-Encoding gzip;
-      try_files $uri @liberama;
-    }
+    types { } default_type "application/xml; charset=utf-8";
+    add_header Content-Encoding gzip;
+    try_files $uri @liberama;
+  }
 
-    location /upload {
-      try_files $uri @liberama;
-    }
+  location /upload {
+    root /home/liberama/.liberama/public-files;
+
+    try_files $uri @liberama;
+  }
+
+  location / {
+    root /home/liberama/.liberama/public;
 
     location ~* \.(?:manifest|appcache|html)$ {
       expires -1;
@@ -98,18 +102,22 @@ server {
     proxy_read_timeout 600s;
   }
 
-  location / {
-    root /home/liberama/public;
+  location /tmp {
+    root /home/liberama/.liberama/public-files;
 
-    location /tmp {
-      types { } default_type "application/xml; charset=utf-8";
-      add_header Content-Encoding gzip;
-      try_files $uri @liberama;
-    }
+    types { } default_type "application/xml; charset=utf-8";
+    add_header Content-Encoding gzip;
+    try_files $uri @liberama;
+  }
 
-    location /upload {
-      try_files $uri @liberama;
-    }
+  location /upload {
+    root /home/liberama/.liberama/public-files;
+
+    try_files $uri @liberama;
+  }
+
+  location / {
+    root /home/liberama/.liberama/public;
 
     location ~* \.(?:manifest|appcache|html)$ {
       expires -1;

+ 7 - 7
docs/omnireader.ru/README.md

@@ -3,7 +3,7 @@
 ### git, clone
 ```
 cd ~
-sudo apt install ssh git
+sudo apt install ssh git zip
 git clone https://github.com/bookpauk/liberama
 ```
 
@@ -18,6 +18,7 @@ sudo apt install -y nodejs
 ```
 cd liberama
 npm i
+cd docs/omnireader.ru
 ```
 
 ### create public dir
@@ -30,8 +31,8 @@ sudo chown www-data.www-data /home/liberama
 #### download from https://download.calibre-ebook.com/
 ```
 wget "https://download.calibre-ebook.com/5.29.0/calibre-5.29.0-x86_64.txz"
-sudo -u www-data mkdir -p /home/liberama/data/calibre
-sudo -u www-data tar xvf calibre-5.29.0-x86_64.txz -C /home/liberama/data/calibre
+sudo -u www-data mkdir -p /home/liberama/.liberama/calibre
+sudo -u www-data tar xvf calibre-5.29.0-x86_64.txz -C /home/liberama/.liberama/calibre
 ```
 
 ### external converters
@@ -44,7 +45,7 @@ sudo apt install rar libreoffice poppler-utils djvulibre-bin libtiff-tools graph
 Сначала настроим для HTTP:
 ```
 sudo apt install nginx
-sudo cp docs/omnireader.ru/omnireader_http /etc/nginx/sites-available/omnireader
+sudo cp ./omnireader_http /etc/nginx/sites-available/omnireader
 sudo ln -s /etc/nginx/sites-available/omnireader /etc/nginx/sites-enabled/omnireader
 sudo rm /etc/nginx/sites-enabled/default
 sudo service nginx reload
@@ -55,7 +56,7 @@ sudo chown -R www-data.www-data /var/www
 #### Следовать инструкции установки certbot https://certbot.eff.org/instructions?ws=nginx&os=ubuntu-20
 После установки сертификата, можно использовать конфиг для nginx c ssl:
 ```
-sudo cp docs/omnireader.ru/omnireader /etc/nginx/sites-available/omnireader
+sudo cp ./omnireader /etc/nginx/sites-available/omnireader
 sudo service nginx reload
 
 ```
@@ -68,7 +69,7 @@ sudo service php7.4-fpm restart
 
 sudo mkdir /home/oldreader
 sudo chown www-data.www-data /home/oldreader
-sudo -u www-data cp -r docs/omnireader.ru/old/* /home/oldreader
+sudo -u www-data cp -r ./old/* /home/oldreader
 ```
 
 ## Запуск по крону
@@ -78,7 +79,6 @@ sudo -u www-data cp -r docs/omnireader.ru/old/* /home/oldreader
 
 ## Деплой и запуск
 ```
-cd docs/omnireader.ru
 ./stop_server.sh
 ./deploy.sh
 ./start_server.sh

+ 63 - 8
docs/omnireader.ru/omnireader

@@ -32,18 +32,73 @@ server {
     proxy_read_timeout 600s;
   }
 
+  location /tmp {
+    root /home/liberama/.liberama/public-files;
+
+    types { } default_type "application/xml; charset=utf-8";
+    add_header Content-Encoding gzip;
+    try_files $uri @liberama;
+  }
+
+  location /upload {
+    root /home/liberama/.liberama/public-files;
+
+    try_files $uri @liberama;
+  }
+
   location / {
-    root /home/liberama/public;
+    root /home/liberama/.liberama/public;
 
-    location /tmp {
-      types { } default_type "application/xml; charset=utf-8";
-      add_header Content-Encoding gzip;
-      try_files $uri @liberama;
+    location ~* \.(?:manifest|appcache|html)$ {
+      expires -1;
     }
+  }
+}
 
-    location /upload {
-      try_files $uri @liberama;
-    }
+server {
+  listen 80;
+  server_name b.omnireader.ru;
+  set $liberama http://127.0.0.1:44081;
+
+  client_max_body_size 50m;
+  proxy_read_timeout 1h;
+
+  gzip on;
+  gzip_min_length 1024;
+  gzip_proxied expired no-cache no-store private auth;
+  gzip_types *;
+
+  location @liberama {
+    proxy_pass $liberama;
+  }
+
+  location /api {
+    proxy_pass $liberama;
+  }
+
+  location /ws {
+    proxy_pass $liberama;
+    proxy_http_version 1.1;
+    proxy_set_header Upgrade $http_upgrade;
+    proxy_set_header Connection "upgrade";
+  }
+
+  location /tmp {
+    root /home/liberama/.liberama/public-files;
+
+    types { } default_type "application/xml; charset=utf-8";
+    add_header Content-Encoding gzip;
+    try_files $uri @liberama;
+  }
+
+  location /upload {
+    root /home/liberama/.liberama/public-files;
+
+    try_files $uri @liberama;
+  }
+
+  location / {
+    root /home/liberama/.liberama/public;
 
     location ~* \.(?:manifest|appcache|html)$ {
       expires -1;

+ 15 - 11
docs/omnireader.ru/omnireader_http

@@ -1,6 +1,6 @@
 server {
   listen 80;
-  server_name omnireader.ru;
+  server_name omnireader.ru b.omnireader.ru;
   set $liberama http://127.0.0.1:44081;
 
   client_max_body_size 50m;
@@ -26,18 +26,22 @@ server {
     proxy_set_header Connection "upgrade";
   }
 
-  location / {
-    root /home/liberama/public;
+  location /tmp {
+    root /home/liberama/.liberama/public-files;
 
-    location /tmp {
-      types { } default_type "application/xml; charset=utf-8";
-      add_header Content-Encoding gzip;
-      try_files $uri @liberama;
-    }
+    types { } default_type "application/xml; charset=utf-8";
+    add_header Content-Encoding gzip;
+    try_files $uri @liberama;
+  }
 
-    location /upload {
-      try_files $uri @liberama;
-    }
+  location /upload {
+    root /home/liberama/.liberama/public-files;
+
+    try_files $uri @liberama;
+  }
+
+  location / {
+    root /home/liberama/.liberama/public;
 
     location ~* \.(?:manifest|appcache|html)$ {
       expires -1;

Fichier diff supprimé car celui-ci est trop grand
+ 259 - 269
package-lock.json


+ 43 - 37
package.json

@@ -1,6 +1,6 @@
 {
-  "name": "Liberama",
-  "version": "0.12.2",
+  "name": "liberama",
+  "version": "1.0.0",
   "author": "Book Pauk <bookpauk@gmail.com>",
   "license": "CC0-1.0",
   "repository": "bookpauk/liberama",
@@ -8,77 +8,83 @@
     "node": ">=16.16.0"
   },
   "scripts": {
-    "dev": "nodemon --inspect --ignore server/public --ignore server/data --ignore client --exec 'node server'",
+    "dev": "nodemon --inspect --ignore server/.liberama --ignore client --exec 'node server'",
     "build:client": "webpack --config build/webpack.prod.config.js",
-    "build:linux": "npm run build:client && node build/linux && pkg -t node16-linux-x64 -C GZip -o dist/linux/liberama .",
-    "build:win": "npm run build:client && node build/win && pkg -t node16-win-x64 -C GZip -o dist/win/liberama .",
+    "build:linux": "npm run build:client && node build/prepkg.js linux && pkg -t node16-linux-x64 -C GZip -o dist/linux/liberama .",
+    "build:linux-arm64": "npm run build:client && node build/prepkg.js linux-arm64 && pkg -t node16-linuxstatic-arm64 -C GZip -o dist/linux-arm64/liberama .",
+    "build:win": "npm run build:client && node build/prepkg.js win && pkg -t node16-win-x64 -C GZip -o dist/win/liberama .",
+    "build:macos": "npm run build:client && node build/prepkg.js macos && pkg -t node16-macos-x64 -C GZip -o dist/macos/liberama .",
     "lint": "eslint --ext=.js,.vue client server",
     "build:client-dev": "webpack --config build/webpack.dev.config.js",
-    "postinstall": "npm run build:client-dev && node build/linux"
+    "build:all": "npm run build:linux && npm run build:win && npm run build:macos && npm run build:linux-arm64",
+    "release": "npm run build:all && node build/release.js",
+    "postinstall": "npm run build:client-dev"
   },
   "bin": "server/index.js",
   "pkg": {
     "scripts": "server/config/*.js"
   },
   "devDependencies": {
-    "@babel/core": "^7.18.13",
-    "@babel/eslint-parser": "^7.18.9",
-    "@babel/eslint-plugin": "^7.18.10",
-    "@babel/plugin-proposal-decorators": "^7.18.10",
-    "@babel/preset-env": "^7.18.10",
+    "@babel/core": "^7.20.5",
+    "@babel/eslint-parser": "^7.19.1",
+    "@babel/eslint-plugin": "^7.19.1",
+    "@babel/plugin-proposal-decorators": "^7.20.5",
+    "@babel/preset-env": "^7.20.2",
     "@vue/compiler-sfc": "^3.2.22",
-    "babel-loader": "^8.2.5",
+    "babel-loader": "^9.1.0",
     "copy-webpack-plugin": "^11.0.0",
-    "css-loader": "^6.7.1",
-    "css-minimizer-webpack-plugin": "^4.0.0",
-    "eslint": "^8.23.0",
-    "eslint-plugin-vue": "^9.4.0",
+    "css-loader": "^6.7.3",
+    "css-minimizer-webpack-plugin": "^4.2.2",
+    "eslint": "^8.29.0",
+    "eslint-plugin-vue": "^9.8.0",
     "html-webpack-plugin": "^5.5.0",
-    "mini-css-extract-plugin": "^2.6.1",
+    "mini-css-extract-plugin": "^2.7.2",
     "pkg": "^5.8.0",
+    "showdown": "^2.1.0",
     "terser-webpack-plugin": "^5.3.6",
-    "vue-eslint-parser": "^9.0.3",
-    "vue-loader": "^17.0.0",
+    "vue-eslint-parser": "^9.1.0",
+    "vue-loader": "^17.0.1",
     "vue-style-loader": "^4.1.3",
-    "webpack": "^5.74.0",
-    "webpack-cli": "^4.10.0",
-    "webpack-dev-middleware": "^5.3.3",
-    "webpack-hot-middleware": "^2.25.2",
+    "webpack": "^5.75.0",
+    "webpack-cli": "^5.0.1",
+    "webpack-dev-middleware": "^6.0.1",
+    "webpack-hot-middleware": "^2.25.3",
     "webpack-merge": "^5.8.0",
     "workbox-webpack-plugin": "^6.5.4"
   },
   "dependencies": {
-    "@quasar/extras": "^1.15.2",
-    "@vue/compat": "^3.2.38",
+    "@quasar/extras": "^1.15.8",
+    "@vue/compat": "^3.2.45",
     "axios": "^0.27.2",
     "base-x": "^4.0.0",
-    "chardet": "^1.4.0",
+    "chardet": "^1.5.0",
     "compression": "^1.7.4",
-    "express": "^4.18.1",
+    "dayjs": "^1.11.7",
+    "express": "^4.18.2",
     "fg-loadcss": "^3.1.0",
     "fs-extra": "^10.1.0",
     "he": "^1.2.0",
     "iconv-lite": "^0.6.3",
-    "jembadb": "^4.2.0",
+    "jembadb": "^5.1.5",
     "localforage": "^1.10.0",
     "lodash": "^4.17.21",
-    "minimist": "^1.2.6",
+    "minimist": "^1.2.7",
     "multer": "^1.4.5-lts.1",
-    "pako": "^2.0.4",
+    "pako": "^2.1.0",
     "path-browserify": "^1.0.1",
-    "pidusage": "^3.0.0",
-    "quasar": "^2.7.7",
+    "pidusage": "^3.0.2",
+    "quasar": "^2.10.2",
     "safe-buffer": "^5.2.1",
-    "sanitize-html": "^2.7.1",
+    "sanitize-html": "^2.8.0",
     "sjcl": "^1.0.8",
     "tar-fs": "^2.1.1",
     "unbzip2-stream": "^1.4.3",
     "vue": "^3.2.37",
-    "vue-router": "^4.1.5",
-    "vuex": "^4.0.2",
+    "vue-router": "^4.1.6",
+    "vuex": "^4.1.0",
     "vuex-persist": "^3.1.3",
-    "webdav": "^4.11.0",
-    "ws": "^8.8.1",
+    "webdav": "^4.11.2",
+    "ws": "^8.11.0",
     "zip-stream": "^4.1.0"
   }
 }

+ 6 - 11
server/config/base.js

@@ -2,19 +2,14 @@ const path = require('path');
 const pckg = require('../../package.json');
 
 const execDir = path.resolve(__dirname, '..');
-const dataDir = `${execDir}/data`;
 
 module.exports = {
     branch: 'unknown',
     version: pckg.version,
     name: pckg.name,
 
-    dataDir: dataDir,
-    tempDir: `${dataDir}/tmp`,
-    logDir: `${dataDir}/log`,
-    publicDir: `${execDir}/public`,
-    uploadDir: `${execDir}/public/upload`,
-    sharedDir: `${execDir}/public/shared`,
+    execDir,
+
     loggingEnabled: true,
 
     maxUploadFileSize: 50*1024*1024,//50Мб
@@ -27,13 +22,13 @@ module.exports = {
 
     jembaDb: [
         {
-            serverMode: ['reader', 'omnireader', 'liberama.top'],
+            serverMode: ['reader', 'omnireader', 'liberama'],
             dbName: 'app',
             thread: true,
             openAll: true,
         },
         {
-            serverMode: ['reader', 'omnireader', 'liberama.top'],
+            serverMode: ['reader', 'omnireader', 'liberama'],
             dbName: 'reader-storage',
             thread: true,
             openAll: true,
@@ -49,13 +44,13 @@ module.exports = {
     servers: [
         {
             serverName: '1',
-            mode: 'normal', //'none', 'normal', 'site', 'reader', 'omnireader', 'liberama.top', 'book_update_checker'
+            mode: 'reader', //'reader', 'omnireader', 'liberama', 'book_update_checker'
             ip: '0.0.0.0',
             port: '33080',
         },
         /*{
             serverName: '2',
-            mode: 'book_update_checker', //'none', 'normal', 'site', 'reader', 'omnireader', 'liberama.top', 'book_update_checker'
+            mode: 'book_update_checker',
             isHttps: true,
             keysFile: 'server',
             ip: '0.0.0.0',

+ 33 - 12
server/config/index.js

@@ -1,4 +1,5 @@
 const _ = require('lodash');
+const path = require('path');
 const fs = require('fs-extra');
 
 const branchFilename = __dirname + '/application_env';
@@ -29,7 +30,7 @@ class ConfigManager {
         return instance;
     }
 
-    async init() {
+    async init(dataDir) {
         if (this.inited)
             throw new Error('already inited');
 
@@ -44,10 +45,17 @@ class ConfigManager {
         process.env.NODE_ENV = this.branch;
 
         this.branchConfigFile = __dirname + `/${this.branch}.js`;
-        this._config = require(this.branchConfigFile);
+        const config = require(this.branchConfigFile);
 
-        await fs.ensureDir(this._config.dataDir);
-        this._userConfigFile = `${this._config.dataDir}/config.json`;
+        if (dataDir) {
+            config.dataDir = path.resolve(dataDir);
+        } else {
+            config.dataDir = `${config.execDir}/.${config.name}`;
+        }
+
+        await fs.ensureDir(config.dataDir);
+        this._userConfigFile = `${config.dataDir}/config.json`;
+        this._config = config;
 
         this.inited = true;
     }
@@ -72,15 +80,28 @@ class ConfigManager {
     }
 
     async load() {
-        if (!this.inited)
-            throw new Error('not inited');
-        if (!await fs.pathExists(this.userConfigFile)) {
-            await this.save();
-            return;
+        try {
+            if (!this.inited)
+                throw new Error('not inited');
+
+            if (await fs.pathExists(this.userConfigFile)) {
+                const data = JSON.parse(await fs.readFile(this.userConfigFile, 'utf8'));
+                const config = _.pick(data, propsToSave);
+
+                this.config = config;
+
+                //сохраним конфиг, если не все атрибуты присутствуют в файле конфига
+                for (const prop of propsToSave)
+                    if (!Object.prototype.hasOwnProperty.call(config, prop)) {
+                        await this.save();
+                        break;
+                    }
+            } else {
+                await this.save();
+            }
+        } catch(e) {
+            throw new Error(`Error while loading "${this.userConfigFile}": ${e.message}`);
         }
-
-        const data = await fs.readFile(this.userConfigFile, 'utf8');
-        this.config = JSON.parse(data);
     }
 
     async save() {

+ 3 - 8
server/config/production.js

@@ -2,21 +2,16 @@ const path = require('path');
 const base = require('./base');
 
 const execDir = path.dirname(process.execPath);
-const dataDir = `${execDir}/data`;
 
 module.exports = Object.assign({}, base, {
     branch: 'production',
-    dataDir: dataDir,
-    tempDir: `${dataDir}/tmp`,
-    logDir: `${dataDir}/log`,
-    publicDir: `${execDir}/public`,
-    uploadDir: `${execDir}/public/upload`,
-    sharedDir: `${execDir}/public/shared`,
+
+    execDir,
 
     servers: [
         {
             serverName: '1',
-            mode: 'normal', //'none', 'normal', 'site', 'reader', 'omnireader'
+            mode: 'reader',
             ip: '0.0.0.0',
             port: '44080',
         },

+ 18 - 0
server/controllers/WebSocketController.js

@@ -71,6 +71,8 @@ class WebSocketController {
                     await this.test(req, ws); break;
                 case 'get-config':
                     await this.getConfig(req, ws); break;
+                case 'load-book':
+                    await this.loadBook(req, ws); break;
                 case 'worker-get-state':
                     await this.workerGetState(req, ws); break;
                 case 'worker-get-state-finish':
@@ -124,6 +126,22 @@ class WebSocketController {
         }
     }
 
+    async loadBook(req, ws) {
+        const workerId = this.readerWorker.loadBookUrl({
+            url: req.url, 
+            enableSitesFilter: (_.has(req, 'enableSitesFilter') ? req.enableSitesFilter : true),
+            skipHtmlCheck: (_.has(req, 'skipHtmlCheck') ? req.skipHtmlCheck : false),
+            isText: (_.has(req, 'isText') ? req.isText : false),
+            uploadFileName: (_.has(req, 'uploadFileName') ? req.uploadFileName : false),
+            djvuQuality: (_.has(req, 'djvuQuality') ? req.djvuQuality : false),
+            pdfAsText: (_.has(req, 'pdfAsText') ? req.pdfAsText : false),
+            pdfQuality: (_.has(req, 'pdfQuality') ? req.pdfQuality : false),
+        });
+        const state = this.workerState.getState(workerId);
+
+        this.send((state ? state : {}), req, ws);
+    }
+
     async workerGetState(req, ws) {
         if (!req.workerId)
             throw new Error(`key 'workerId' is wrong`);

+ 4 - 0
server/core/AppLogger.js

@@ -37,6 +37,10 @@ class AppLogger {
                 {log: 'FileLog', fileName: this.errLogFileName, exclude: [LM_OK, LM_INFO, LM_TOTAL]},
                 {log: 'FileLog', fileName: this.fatalLogFileName, exclude: [LM_OK, LM_INFO, LM_WARN, LM_ERR, LM_TOTAL]},//LM_FATAL only
             ];
+        } else {
+            loggerParams = [
+                {log: 'ConsoleLog'},
+            ];
         }
 
         this._logger = new Logger(loggerParams);

+ 6 - 2
server/core/AsyncExit.js

@@ -1,9 +1,9 @@
-let instance = null;
-
 const defaultTimeout = 15*1000;//15 sec
 const exitSignals = ['SIGINT', 'SIGTERM', 'SIGBREAK', 'SIGHUP', 'uncaughtException'];
 
 //singleton
+let instance = null;
+
 class AsyncExit {
     constructor(signals = exitSignals, codeOnSignal = 2) {
         if (!instance) {
@@ -22,6 +22,10 @@ class AsyncExit {
 
     _init(signals, codeOnSignal) {
         const runSingalCallbacks = async(signal, err, origin) => {
+            if (!this.onSignalCallbacks.size) {
+                console.error(`Uncaught signal "${signal}" received, error: "${(err.stack ? err.stack : err)}"`);
+            }
+
             for (const signalCallback of this.onSignalCallbacks.keys()) {
                 try {
                     await signalCallback(signal, err, origin);

+ 5 - 2
server/core/FileDownloader.js

@@ -9,11 +9,12 @@ class FileDownloader {
         this.limitDownloadSize = limitDownloadSize;
     }
 
-    async load(url, callback, abort) {
+    async load(url, opts, callback, abort) {
         let errMes = '';
 
-        const options = {
+        let options = {
             headers: {
+                'accept-encoding': 'gzip, compress, deflate',
                 'user-agent': userAgent,
                 timeout: 300*1000,
             },
@@ -22,6 +23,8 @@ class FileDownloader {
             }),
             responseType: 'stream',
         };
+        if (opts)
+            options = Object.assign({}, opts, options);
 
         try {
             const res = await axios.get(url, options);

+ 24 - 10
server/core/Logger.js

@@ -48,8 +48,12 @@ class BaseLog {
         this.outputBufferLength = 0;
         this.outputBuffer = [];
 
-        await this.flushImpl(this.data)
-            .catch(e => { console.error(`Logger error: ${e}`); ayncExit.exit(1); } );
+        try {
+            await this.flushImpl(this.data);
+        } catch (e) {
+            console.error(`Logger error: ${e}`);
+            ayncExit.exit(1);
+        }
         this.flushing = false;
     }
 
@@ -112,10 +116,14 @@ class FileLog extends BaseLog {
         if (this.closed)
             return;
         await super.close();
+
         if (this.fd) {
+            while (this.flushing)
+                await sleep(1);
             await fs.close(this.fd);
             this.fd = null;
         }
+
         if (this.rcid)
             clearTimeout(this.rcid);
     }
@@ -151,15 +159,21 @@ class FileLog extends BaseLog {
         if (this.closed)
             return;
 
-        if (!this.rcid) {
-            await this.doFileRotationIfNeeded();
-            this.rcid = setTimeout(() => {
-                this.rcid = 0;
-            }, LOG_ROTATE_FILE_CHECK_INTERVAL);
-        }
+        this.flushing = true;
+        try {
+            if (!this.rcid) {
+                await this.doFileRotationIfNeeded();
+                this.rcid = setTimeout(() => {
+                    this.rcid = 0;
+                }, LOG_ROTATE_FILE_CHECK_INTERVAL);
+            }
 
-        if (this.fd)
-            await fs.write(this.fd, Buffer.from(data.join('')));
+            if (this.fd) {
+                await fs.write(this.fd, Buffer.from(data.join('')));
+            }
+        } finally {
+            this.flushing = false;
+        }
     }
 }
 

+ 55 - 14
server/core/Reader/JembaReaderStorage.js

@@ -12,6 +12,8 @@ class JembaReaderStorage {
         if (!instance) {
             this.connManager = new JembaConnManager();
             this.db = this.connManager.db['reader-storage'];
+
+            this.cacheMap = new Map();
             this.periodicCleanCache(3*3600*1000);//1 раз в 3 часа
 
             instance = this;
@@ -20,6 +22,21 @@ class JembaReaderStorage {
         return instance;
     }
 
+    getCache(id) {
+        const obj = this.cacheMap.get(id);
+        if (obj)
+            obj.time = Date.now();
+        return obj;
+    }
+
+    setCache(id, newObj) {
+        let obj = this.cacheMap.get(id);
+        if (!obj)
+            obj = {};
+        Object.assign(obj, newObj, {time: Date.now()});
+        this.cacheMap.set(id, obj);
+    }
+
     async doAction(act) {
         try {
             if (!_.isObject(act.items))
@@ -34,7 +51,7 @@ class JembaReaderStorage {
                     result = await this.getItems(act.items);
                     break;
                 case 'set':
-                    result = await this.setItems(act.items, act.force);
+                    result = await this.setItems(act.items, act.identity, act.force);
                     break;
                 default:
                     throw new Error('Unknown action');
@@ -53,8 +70,9 @@ class JembaReaderStorage {
         const db = this.db;
 
         for (const id of Object.keys(items)) {
-            if (this.cache[id]) {
-                result.items[id] = this.cache[id];
+            const obj = this.getCache(id);
+            if (obj && obj.items) {
+                result.items[id] = obj.items;
             } else {
                 const rows = await db.select({//SQL`SELECT rev FROM storage WHERE id = ${id}`
                     table: 'storage',
@@ -63,7 +81,8 @@ class JembaReaderStorage {
                 });
                 const rev = (rows.length && rows[0].rev ? rows[0].rev : 0);
                 result.items[id] = {rev};
-                this.cache[id] = result.items[id];
+
+                this.setCache(id, {items: result.items[id]});
             }
         }
 
@@ -88,7 +107,7 @@ class JembaReaderStorage {
         return result;
     }
 
-    async setItems(items, force) {
+    async setItems(items, identity, force) {
         let check = await this.checkItems(items);
 
         //сначала проверим совпадение ревизий
@@ -96,32 +115,54 @@ class JembaReaderStorage {
             if (!_.isString(items[id].data))
                 throw new Error('items.data is not a string');
 
-            if (!force && check.items[id].rev + 1 !== items[id].rev)
+            //identity необходимо для работы при нестабильной связи,
+            //одному и тому же клиенту разрешается перезаписывать данные при расхождении на 0 или 1 ревизию
+            const obj = this.getCache(id) || {};
+            const sameClient = (identity && obj.identity === identity);
+            if (identity && obj.identity !== identity) {
+                obj.identity = identity;
+                this.setCache(id, obj);
+            }
+
+            const revDiff = items[id].rev - check.items[id].rev;
+            const allowUpdate = force || revDiff === 1 || (sameClient && (revDiff === 0 || revDiff === 1));
+            if (!allowUpdate)
                 return {state: 'reject', items: check.items};
         }
 
         const db = this.db;
-        const newRev = {};
         for (const id of Object.keys(items)) {
             await db.insert({//SQL`INSERT OR REPLACE INTO storage (id, rev, time, data) VALUES (${id}, ${items[id].rev}, strftime('%s','now'), ${items[id].data})`);
                 table: 'storage',
                 replace: true,
                 rows: [{id, rev: items[id].rev, time: utils.toUnixTime(Date.now()), data: items[id].data}],
             });
-            newRev[id] = {rev: items[id].rev};
+            this.setCache(id, {items: {rev: items[id].rev}});
         }
         
-        Object.assign(this.cache, newRev);
-
         return {state: 'success'};
     }
 
     periodicCleanCache(timeout) {
-        this.cache = {};
+        try {
+            const sorted = [];
+            for (const [id, obj] of this.cacheMap)
+                sorted.push({id, time: obj.time});
 
-        setTimeout(() => {
-            this.periodicCleanCache(timeout);
-        }, timeout);
+            sorted.sort((a, b) => b.time - a.time);
+
+            for (const obj of sorted) {
+                //оставляем только 1000 недавних
+                if (this.cacheMap.size <= 1000)
+                    break;
+
+                this.cacheMap.delete(obj.id);
+            }
+        } finally {
+            setTimeout(() => {
+                this.periodicCleanCache(timeout);
+            }, timeout);
+        }
     }
 }
 

+ 6 - 9
server/core/Reader/ReaderWorker.js

@@ -29,9 +29,6 @@ class ReaderWorker {
             this.config.tempDownloadDir = `${config.tempDir}/download`;
             fs.ensureDirSync(this.config.tempDownloadDir);
 
-            this.config.tempPublicDir = `${config.publicDir}/tmp`;
-            fs.ensureDirSync(this.config.tempPublicDir);
-
             this.workerState = new WorkerState();
             this.down = new FileDownloader(config.maxUploadFileSize);
             this.decomp = new FileDecompressor(3*config.maxUploadFileSize);
@@ -55,7 +52,7 @@ class ReaderWorker {
                     moveToRemote: true,
                 },
                 {
-                    dir: this.config.uploadDir,
+                    dir: this.config.uploadPublicDir,
                     remoteDir: '/upload',
                     maxSize: this.config.maxUploadPublicDirSize,
                     moveToRemote: true,
@@ -109,7 +106,7 @@ class ReaderWorker {
             let downloadSize = -1;
             //download or use uploaded
             if (url.indexOf('disk://') != 0) {//download
-                const downdata = await this.down.load(url, (progress) => {
+                const downdata = await this.down.load(url, {}, (progress) => {
                     wState.set({progress});
                 }, q.abort);
 
@@ -119,7 +116,7 @@ class ReaderWorker {
                 await fs.writeFile(downloadedFilename, downdata);
             } else {//uploaded file
                 const fileHash = url.substr(7);
-                downloadedFilename = `${this.config.uploadDir}/${fileHash}`;
+                downloadedFilename = `${this.config.uploadPublicDir}/${fileHash}`;
                 if (!await fs.pathExists(downloadedFilename)) {
                     //если удалено из upload, попробуем восстановить из удаленного хранилища
                     try {
@@ -227,7 +224,7 @@ class ReaderWorker {
 
     async saveFile(file) {
         const hash = await utils.getFileHash(file.path, 'sha256', 'hex');
-        const outFilename = `${this.config.uploadDir}/${hash}`;
+        const outFilename = `${this.config.uploadPublicDir}/${hash}`;
 
         if (!await fs.pathExists(outFilename)) {
             await fs.move(file.path, outFilename);
@@ -242,7 +239,7 @@ class ReaderWorker {
 
     async saveFileBuf(buf) {
         const hash = await utils.getBufHash(buf, 'sha256', 'hex');
-        const outFilename = `${this.config.uploadDir}/${hash}`;
+        const outFilename = `${this.config.uploadPublicDir}/${hash}`;
 
         if (!await fs.pathExists(outFilename)) {
             await fs.writeFile(outFilename, buf);
@@ -255,7 +252,7 @@ class ReaderWorker {
     }
 
     async uploadFileTouch(url) {
-        const outFilename = `${this.config.uploadDir}/${url.replace('disk://', '')}`;
+        const outFilename = `${this.config.uploadPublicDir}/${url.replace('disk://', '')}`;
 
         await utils.touchFile(outFilename);
 

+ 59 - 0
server/core/Zip/ZipReader.js

@@ -0,0 +1,59 @@
+const StreamUnzip = require('./node_stream_zip_changed');
+//const StreamUnzip = require('node-stream-zip');
+
+class ZipReader {
+    constructor() {
+        this.zip = null;
+    }
+
+    checkState() {
+        if (!this.zip)
+            throw new Error('Zip closed');
+    }
+
+    async open(zipFile, zipEntries = true) {
+        if (this.zip)
+            throw new Error('Zip file is already open');
+
+         const zip = new StreamUnzip.async({file: zipFile, skipEntryNameValidation: true});
+         
+        if (zipEntries)
+            this.zipEntries = await zip.entries();
+
+         this.zip = zip;
+    }
+
+    get entries() {
+        this.checkState();
+
+        return this.zipEntries;
+    }
+
+    async extractToBuf(entryFilePath) {
+        this.checkState();
+
+        return await this.zip.entryData(entryFilePath);
+    }
+
+    async extractToFile(entryFilePath, outputFile) {
+        this.checkState();
+
+        await this.zip.extract(entryFilePath, outputFile);
+    }
+
+    async extractAllToDir(outputDir) {
+        this.checkState();
+
+        await this.zip.extract(null, outputDir);
+    }
+
+    async close() {
+        if (this.zip) {
+            await this.zip.close();
+            this.zip = null;
+            this.zipEntries = undefined;
+        }
+    }
+}
+
+module.exports = ZipReader;

+ 2 - 2
server/core/Zip/ZipStreamer.js

@@ -2,7 +2,7 @@
 const path = require('path');
 
 const zipStream = require('zip-stream');*/
-const unzipStream = require('./node_stream_zip');
+const StreamUnzip = require('./node_stream_zip_changed');
 
 class ZipStreamer {
     constructor() {
@@ -63,7 +63,7 @@ class ZipStreamer {
                 decodeEntryNameCallback = false,
             } = options;
 
-            const unzip = new unzipStream({file: zipFile});
+            const unzip = new StreamUnzip({file: zipFile, skipEntryNameValidation: true});
 
             unzip.on('error', reject);
 

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff