├── .babelrc ├── .eslintignore ├── .eslintrc.json ├── .flowconfig ├── .github └── workflows │ └── node.js.yml ├── .gitignore ├── .vscode ├── launch.json └── settings.json ├── LICENSE ├── README.md ├── README_English.md ├── Readme_Env_Setup.md ├── auto_test ├── package.json ├── readme.txt ├── test.js └── test_screenshot │ └── placeholder.txt ├── deprecated ├── Dockerfile ├── dockerguide.md ├── ehentaiMetadata.js ├── fileServer.js ├── machineLearning.js └── sortUtil.js ├── experiment ├── when_turnoff_watching_D_and_E_drive.jpg └── when_watch_D_and_E_drive.jpg ├── package.json ├── public ├── error_loading.png ├── favicon-96x96.png └── index.html ├── screenshot ├── 01.png ├── 02.5.png ├── 02.png ├── 03.png ├── 04.png ├── 05.png ├── 06.png ├── 07.png ├── 08.png ├── git bash.png ├── git bash2.png ├── right-click-search-1.png ├── right-click-search-2.png └── unicode-setting.png ├── src ├── TamperMonkeyScript │ ├── EhentaiHighighliger_many_request.js │ ├── EhentaiHighighliger_one_request.js │ └── EhentaiTagDownload.js ├── client │ ├── AdminPage.js │ ├── AdminUtil.js │ ├── App.js │ ├── ChartPage.js │ ├── ChartUtil.js │ ├── ClientConstant.js │ ├── ErrorPage.js │ ├── ExplorerPage.js │ ├── ExplorerPageUI.js │ ├── ExplorerUtil.js │ ├── HistoryPage.js │ ├── HomePage.js │ ├── LoadingImage.js │ ├── LoginPage.js │ ├── MusicPlayer.js │ ├── OneBook.js │ ├── OneBookOverview.js │ ├── OneBookWaterfall.js │ ├── Sender.js │ ├── TagPage.js │ ├── VideoPlayer.js │ ├── clientUtil.js │ ├── globalContext.js │ ├── images │ │ └── text.png │ ├── index.js │ ├── style │ │ ├── Accordion.scss │ │ ├── AdminPage.scss │ │ ├── App.scss │ │ ├── BigColumnButton.scss │ │ ├── Breadcrumb.scss │ │ ├── ChartPage.scss │ │ ├── Checkbox.scss │ │ ├── Dropdown.scss │ │ ├── ErrorPage.scss │ │ ├── Explorer.scss │ │ ├── FileChangeToolbar.scss │ │ ├── FileNameDiv.scss │ │ ├── HistoryPage.scss │ │ ├── HomePage.scss │ │ ├── LoadingImage.scss │ │ ├── MusicPlayer.scss │ │ ├── OneBook.scss │ │ ├── Pagination.scss │ │ ├── RadioButtonGroup.scss │ │ ├── SortHeader.scss │ │ ├── Spinner.scss │ │ ├── TagPage.scss │ │ ├── ThumbnailPopup.scss │ │ ├── VideoPlayer.scss │ │ ├── _toast.scss │ │ ├── bootstrap.min.css │ │ ├── rc-pagination.scss │ │ ├── sideMenu.scss │ │ └── vars.scss │ └── subcomponent │ │ ├── Accordion.js │ │ ├── BookImage.js │ │ ├── Breadcrumb.js │ │ ├── CenterSpinner.js │ │ ├── Checkbox.js │ │ ├── ClickAndCopyDiv.js │ │ ├── Dropdown.js │ │ ├── DropdownItem.js │ │ ├── FileCellTitle.js │ │ ├── FileChangeToolbar.js │ │ ├── FileNameDiv.js │ │ ├── HistorySection.js │ │ ├── ItemsContainer.js │ │ ├── Pagination.js │ │ ├── RadioButtonGroup.js │ │ ├── SortHeader.js │ │ ├── Spinner.js │ │ └── ThumbnailPopup.js ├── common │ ├── constant.js │ └── util.js ├── config │ ├── port-config.js │ └── user-config.js ├── name-parser │ ├── ParserUtil.js │ ├── all_in_one │ │ ├── index.js │ │ └── index.js.map │ ├── character-names.js │ ├── index.js │ ├── name-parser-config.js │ ├── readme.txt │ └── webpack.config.js └── test │ ├── clientUtil.test.js │ ├── parser.test.js │ └── util.test.js └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | "@babel/preset-react" 5 | ], 6 | "plugins": [ 7 | "@babel/plugin-proposal-class-properties" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # don't ever lint node_modules 2 | node_modules 3 | # don't lint build output (make sure it's set to your correct build folder name) 4 | dist 5 | client 6 | 7 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": [ 4 | "airbnb" 5 | ], 6 | "env": { 7 | "browser": true, 8 | "node": true 9 | }, 10 | "rules": { 11 | "arrow-body-style": "off", 12 | "arrow-parens": "off", 13 | "camelcase": "off", 14 | "class-methods-use-this": "off", 15 | "comma-dangle": "off", 16 | "consistent-return": "off", 17 | "func-names": "off", 18 | "global-require": "off", 19 | "import/newline-after-import": "off", 20 | "import/order": "off", 21 | "indent": "off", 22 | "jsx-quotes":"off", 23 | "jsx-a11y/click-events-have-key-events": "off", 24 | "jsx-a11y/no-noninteractive-element-interactions": "off", 25 | "linebreak-style": "off", 26 | "max-len": "off", 27 | "no-await-in-loop": "off", 28 | "no-console": "off", 29 | "no-continue": "off", 30 | "no-else-return": "off", 31 | "no-empty": "off", 32 | "no-multi-assign": "off", 33 | "no-multi-spaces": "off", 34 | "no-param-reassign": "off", 35 | "no-plusplus": "off", 36 | "no-return-await": "off", 37 | "no-throw-literal": "off", 38 | "no-underscore-dangle": "off", 39 | "no-use-before-define": "off", 40 | "object-curly-newline": "off", 41 | "operator-assignment": "off", 42 | "operator-linebreak": "off", 43 | "prefer-const": "off", 44 | "prefer-destructuring": "off", 45 | "prefer-template": "off", 46 | "quotes": "off", 47 | "radix": "off", 48 | "quote-props": "off", 49 | "react/no-find-dom-node": "off", 50 | "react/destructuring-assignment": "off", 51 | "react/forbid-prop-types": "off", 52 | "react/jsx-boolean-value": "off", 53 | "react/jsx-child-element-spacing": "off", 54 | "react/jsx-closing-bracket-location": "off", 55 | "react/jsx-closing-tag-location": "off", 56 | "react/jsx-curly-brace-presence": "off", 57 | "react/jsx-curly-brace-presenceexpressions on literals in JSX children or attributes (fixable)": "off", 58 | "react/jsx-curly-newline": "off", 59 | "react/jsx-curly-spacing": "off", 60 | "react/jsx-equals-spacing": "off", 61 | "react/jsx-filename-extension": "off", 62 | "react/jsx-first-prop-new-line": "off", 63 | "react/jsx-fragments": "off", 64 | "react/jsx-handler-names": "off", 65 | "react/jsx-indent": "off", 66 | "react/jsx-indent-props": "off", 67 | "react/jsx-key": "off", 68 | "react/jsx-max-depth": "off", 69 | "react/jsx-max-props-per-line": "off", 70 | "react/jsx-newline": "off", 71 | "react/jsx-no-bind": "off", 72 | "react/jsx-no-comment-textnodes": "off", 73 | "react/jsx-no-constructed-context-valuesrerenders.": "off", 74 | "react/jsx-no-duplicate-props": "off", 75 | "react/jsx-no-literals": "off", 76 | "react/jsx-no-script-url": "off", 77 | "react/jsx-no-target-blank": "off", 78 | "react/jsx-no-undef": "off", 79 | "react/jsx-no-useless-fragment": "off", 80 | "react/jsx-one-expression-per-line": "off", 81 | "react/jsx-pascal-case": "off", 82 | "react/jsx-props-no-multi-spaces": "off", 83 | "react/jsx-props-no-spreading": "off", 84 | "react/jsx-sort-default-props": "off", 85 | "react/jsx-sort-props": "off", 86 | "react/jsx-space-before-closing": "off", 87 | "react/jsx-tag-spacing": "off", 88 | "react/jsx-uses-react": "off", 89 | "react/jsx-uses-vars": "off", 90 | "react/jsx-wrap-multilines": "off", 91 | "react/require-default-props": "off", 92 | "react/sort-comp": "off", 93 | "react/boolean-prop-naming": "off", 94 | "react/button-has-type": "off", 95 | "react/default-props-match-prop-types": "off", 96 | "react/destructuring-assignment": "off", 97 | "react/display-name": "off", 98 | "react/forbid-component-props": "off", 99 | "react/forbid-dom-props": "off", 100 | "react/forbid-elements": "off", 101 | "react/forbid-foreign-prop-types": "off", 102 | "react/forbid-prop-types": "off", 103 | "react/function-component-definition": "off", 104 | "react/no-access-state-in-setstate": "off", 105 | "react/no-adjacent-inline-elements": "off", 106 | "react/no-array-index-key": "off", 107 | "react/no-children-prop": "off", 108 | "react/no-danger": "off", 109 | "react/no-danger-with-children": "off", 110 | "react/no-deprecated": "off", 111 | "react/no-did-mount-set-state": "off", 112 | "react/no-did-update-set-state": "off", 113 | "react/no-direct-mutation-state": "off", 114 | "react/no-find-dom-node": "off", 115 | "react/no-is-mounted": "off", 116 | "react/no-multi-comp": "off", 117 | "react/no-redundant-should-component-update": "off", 118 | "react/no-render-return-value": "off", 119 | "react/no-set-state": "off", 120 | "react/no-string-refs": "off", 121 | "react/no-this-in-sfc": "off", 122 | "react/no-typos": "off", 123 | "react/no-unescaped-entities": "off", 124 | "react/no-unknown-property": "off", 125 | "react/no-unsafe": "off", 126 | "react/no-unused-prop-types": "off", 127 | "react/no-unused-state": "off", 128 | "react/no-will-update-set-state": "off", 129 | "react/prefer-es6-class": "off", 130 | "react/prefer-read-only-props": "off", 131 | "react/prefer-stateless-function": "off", 132 | "react/prop-types": "off", 133 | "react/react-in-jsx-scope": "off", 134 | "react/require-default-props": "off", 135 | "react/require-optimization": "off", 136 | "react/require-render-return": "off", 137 | "react/self-closing-comp": "off", 138 | "react/sort-comp": "off", 139 | "react/sort-prop-types": "off", 140 | "react/state-in-constructor": "off", 141 | "react/static-property-placement": "off", 142 | "react/style-prop-object": "off", 143 | "react/void-dom-elements-no-children": "off", 144 | "jsx-a11y/accessible-emoji": "off", 145 | "jsx-a11y/alt-text": "off", 146 | "jsx-a11y/anchor-has-content": "off", 147 | "jsx-a11y/anchor-is-valid": "off", 148 | "jsx-a11y/aria-activedescendant-has-tabindex": "off", 149 | "jsx-a11y/aria-props": "off", 150 | "jsx-a11y/aria-proptypes": "off", 151 | "jsx-a11y/aria-role": "off", 152 | "jsx-a11y/aria-unsupported-elementsattributes.": "off", 153 | "jsx-a11y/autocomplete-valid": "off", 154 | "jsx-a11y/click-events-have-key-events": "off", 155 | "jsx-a11y/heading-has-content": "off", 156 | "jsx-a11y/html-has-lang": "off", 157 | "jsx-a11y/iframe-has-title": "off", 158 | "jsx-a11y/img-redundant-alt": "off", 159 | "jsx-a11y/interactive-supports-focus": "off", 160 | "jsx-a11y/label-has-associated-control": "off", 161 | "jsx-a11y/lang": "off", 162 | "jsx-a11y/media-has-caption": "off", 163 | "jsx-a11y/mouse-events-have-key-events": "off", 164 | "jsx-a11y/no-access-keyused by a screenreader.": "off", 165 | "jsx-a11y/no-autofocus": "off", 166 | "jsx-a11y/no-distracting-elements": "off", 167 | "jsx-a11y/no-interactive-element-to-noninteractive-role": "off", 168 | "jsx-a11y/no-noninteractive-element-interactions": "off", 169 | "jsx-a11y/no-noninteractive-element-to-interactive-role": "off", 170 | "jsx-a11y/no-noninteractive-tabindex": "off", 171 | "jsx-a11y/no-onchange": "off", 172 | "jsx-a11y/no-redundant-roles": "off", 173 | "jsx-a11y/no-static-element-interactionsthe role attribute.": "off", 174 | "jsx-a11y/role-has-required-aria-props": "off", 175 | "jsx-a11y/role-supports-aria-propssupported by that role.": "off", 176 | "jsx-a11y/scope": "off", 177 | "jsx-a11y/tabindex-no-positive": "off", 178 | "no-return-assign": "off", 179 | "semi": "off", 180 | "spaced-comment": "off", 181 | "template-curly-spacing": "off", 182 | "import/first": "off", 183 | "no-unused-expressions": "off", 184 | "no-loop-func":"off" 185 | } 186 | } -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | 3 | [include] 4 | 5 | [libs] 6 | 7 | [lints] 8 | 9 | [options] 10 | 11 | [strict] 12 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ dev ] 9 | pull_request: 10 | branches: [ dev ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: windows-2019 16 | 17 | strategy: 18 | matrix: 19 | node-version: [14.x] 20 | 21 | steps: 22 | - uses: actions/checkout@v2 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v1 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | - run: npm ci 28 | # - run: npm run build --if-present 29 | - run: npm test 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | cache 4 | *.log 5 | yarn.lock 6 | shigureader_local_file_info.json 7 | shigureader_zip_file_content_info.json 8 | unzip_cache 9 | compress_cache 10 | executables 11 | file_info.json 12 | zip_info.json 13 | zip_info 14 | zip_info~ 15 | .DS_Store 16 | imageMagick_cache 17 | minified_zip_cache 18 | zip_output 19 | thumbnails 20 | thumbnails_2 21 | gdata.json 22 | temp_json_info.json 23 | test_screenshot 24 | uploaded-v2.6-node-v14.4.0-win-x64 25 | ShiguReader.exe 26 | history_sql_db 27 | thumbnail_sql_db 28 | history_sql_db.db 29 | thumbnail_sql_db.db 30 | file_sql.db 31 | zip_info_sql.db -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Mocha Tests", 11 | "program": "${workspaceFolder}/node_modules/mocha/bin/_mocha", 12 | "args": [ 13 | "-u", 14 | "bdd", 15 | "--timeout", 16 | "999999", 17 | "--colors", 18 | "${workspaceFolder}/src/test" 19 | ], 20 | "internalConsoleOptions": "openOnSessionStart" 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.enable": false, 3 | 4 | "files.exclude": { 5 | "**/.git": true, 6 | "**/.svn": true, 7 | "**/.hg": true, 8 | "**/CVS": true, 9 | "**/.DS_Store": true, 10 | "**/folder_thumbnails": true, 11 | "**/node_modules": true, 12 | "**/thumbnails": true, 13 | "**/cache": true, 14 | "**/deprecated": true 15 | } 16 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 aji47 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |

iconShiguReader

3 | 4 | 5 | [](https://github.com/hjyssg/ShiguReader/releases) 6 | 7 | 8 | [English Version](https://github.com/hjyssg/ShiguReader/blob/Dev_Frontend/README_English.md) 9 | 10 | 11 | ShiguReader是一款可在电脑或iPad上使用的漫画浏览器,它还支持整理资源、播放音乐和观看视频等多种功能。只需前往[Release](https://github.com/hjyssg/ShiguReader/releases),下载后便可立即开始使用。 12 | 13 | ##### Screenshots 14 | 15 | screenshot-01 16 | screenshot-02 17 | screenshot-02.5 18 | screenshot-03 19 | screenshot-04 20 | screenshot-05 21 | screenshot-06 22 | 23 | ##### 功能特色 24 | 25 | * 可在电脑和iPad上使用。 26 | * 显示每个漫画压缩包的封面,方便浏览。 27 | * 支持播放音乐和视频。 28 | * 提供各种排序和筛选功能。包括根据喜欢排序。 29 | * 可一键压缩压缩包内的图片,节约硬盘空间。 30 | * 进行特定作者或同人类型的全部文件展示。 31 | * 可移动、删除文件。 32 | * 可制作统计图表,统计文件大小和各时期的文件数量。 33 | * 接近于旧版熊猫网的配色,让你感受亲切熟悉。 34 | * 同时支持Windows和lunix系统。 35 | 36 | ##### 支持的文件格式 37 | 38 | 支持的压缩包格式取决于7Zip。支持常见的zip、rar、7z。图片、音乐和视频的支持格式取决于浏览器。图片格式常见的包括jpg、png和gif,视频格式常见的包括mp4和avi,音乐格式支持mp3和wav等多种格式。 39 | 40 | ##### 演示视频 41 | 42 | ShiguReader的演示视频有些过时了,不过我们会尽快更新新版本的演示视频。你可以通过以下链接找到过去的演示视频 43 | [iPad使用](https://www.bilibili.com/video/BV1Mt4y1m7qU) 44 | [PC使用](https://www.bilibili.com/video/BV1t64y1u729/) 45 | [iPhone使用](https://www.bilibili.com/video/BV1xt4y1U73L/) 46 | 47 | ##### 快捷键 48 | 49 | 漫画页面 50 | enter: 全屏 51 | A、D、左右方向键: 翻页 52 | W、S、上下方向键: 上下 53 | +和-: 缩放图片 54 | x:移动到no good文件夹 55 | v:移到good文件夹 56 | g:快速跳页 57 | 58 | 59 | ##### 第三方依赖 60 | linux系统强烈建议安装[image magick](https://imagemagick.org)。这样可以使用它来压缩图片,提高软件的性能。 61 | 62 | ##### 注意事项 63 | 64 | 部分文件名带汉字或日语假名的图片无法正常加载,你可能需要进行以下语言设置。请注意,这可能会导致其他非Unicode软件出现乱码问题。 65 | 66 | Windows 语言设置如下所示:: 67 | Unicode Setting 68 | 69 | ##### 压缩包内图片压缩功能 70 | 71 | [介绍视频](https://www.bilibili.com/video/BV1pi4y147Gu?from=search&seid=13429520178852889848/) 72 | 一些漫画图片过于庞大,比如下载了一本24页共640MB的漫画,但关键画面与一本仅30MB的漫画并没有太大区别。因此,我们添加了压缩包内图片压缩功能。首先,你需要确认是否可以通过cmd运行magick命令。然后就可以通过网页以启动压缩程序,压缩后的文件默认保存在workspace\minified_zip_cache目录里。 73 | 74 | ##### 配合TamperMonkey使用 75 | 76 | 把EhentaiHighighliger.js添加到TamperMonkey。 77 | 在你上绅士网的时候,该脚本会与后端服务器通信。显示文件下载过与否。 78 | 79 | ##### FAQ 80 | 81 | 问: 点击exe后软件无法启动,怎么办? 82 | 答: 默认的3000端口可能已被占用,请尝试更改端口号。 83 | 比如 ShiguReader_Backend.exe --port 5000 84 | 85 | 问:某些视频无法播放,怎么办? 86 | 答:视频只是附件功能,支持的格式有限 87 | 88 | 问:电脑可以正常使用,但是扫描二维码后无法在手机上打开,请问如何解决? 89 | 答:请首先确认电脑和手机是否在同一局域网Wifi下。如果仍然无法打开,请检查电脑防火墙设置。 90 | 91 | 问: ShiguReader是啥意思? 92 | 答: Shigure(しぐれ) + Reader。当年的舰C的同人可真好看。 93 | 94 | 95 | ##### 开发环境设置 96 | 97 | 开发人员请阅读[Readme_Env_Setup](https://github.com/hjyssg/ShiguReader/blob/Dev_Frontend/Readme_Env_Setup.md) 98 | 99 | ##### 反馈与建议 100 | 101 | 如果你仍有疑问或者需要帮助,请在Github上反馈issue。同时,我们也欢迎任何关于改善ShiguReader的建议。 102 | 103 | 104 | ##### DOCKER 使用方法(过时) 105 | 106 | ``` 107 | docker pull liwufan/shigureader 108 | docker run -d -p hostport:3000 -v comicpath:/data liwufan/shigureader 109 | 110 | # hostport 是主机要开放的端口 111 | # comicpath 是要扫描的文件目录 112 | ``` 113 | 有问题阅读 [docker配置说明](https://github.com/hjyssg/ShiguReader/blob/dev/dockerguide.md) 114 | 115 | 116 | ##### NAS 使用方法(过时) 117 | 118 | [热心人总结的](https://github.com/hjyssg/ShiguReader/issues/90) 119 | -------------------------------------------------------------------------------- /README_English.md: -------------------------------------------------------------------------------- 1 |

ShiguReader

2 | 3 | [](https://github.com/hjyssg/ShiguReader/releases) 4 | [](https://hub.docker.com/r/liwufan/shigureader) 5 | [](https://hub.docker.com/r/liwufan/shigureader) 6 | 7 | ShiguReader is a manga browser that can be used on computers or iPads. It also supports various features such as organizing resources, playing music, and watching videos. Simply go to [Release](https://github.com/hjyssg/ShiguReader/releasesx) and download it to start using immediately. 8 | 9 | ##### Screenshots 10 | 11 | screenshot-01 12 | screenshot-02 13 | screenshot-02.5 14 | screenshot-03 15 | screenshot-04 16 | screenshot-05 17 | screenshot-06 18 | 19 | ##### Key Features 20 | 21 | * Can be used on computers and iPads. 22 | * Displays cover images of each manga archive for easy browsing. 23 | * Supports playing music and videos. 24 | * Provides various sorting and filtering functions. 25 | * Can compress images in a comic archive with a single click, saving disk space. 26 | * Displays all files by specific authors or doujin types. 27 | * Allows moving and deleting files. 28 | * Generates statistical charts to show file sizes and file counts in different periods. 29 | * Adopts a color scheme similar to the old version of Panda website, giving you a sense of familiarity. 30 | * The server-side supports both Windows and *nix systems. 31 | 32 | 33 | ##### Supported File Formats 34 | 35 | The supported archive formats depend on 7Zip. Common formats such as zip, rar, and 7z are supported. The supported formats for images, music, and videos depend on the browser. Common image formats include jpg, png, and gif, while common video formats include mp4 and avi. The supported music formats include mp3 and wav, among others. 36 | 37 | ##### Keyboard Shortcuts 38 | 39 | enter: Fullscreen 40 | AD and left/right arrow keys: Page navigation 41 | +-: Image zoom 42 | 43 | ##### Third-Party Dependencies 44 | 45 | While ShiguReader can be used without installing dependencies, it is highly recommended to install [ImageMagick](https://imagemagick.org). This allows you to use it to compress images and improve the software's performance. 46 | 47 | ##### Usage with TamperMonkey 48 | 49 | Add `EhentaiHighighliger.js` to TamperMonkey. When you visit the E-Hentai website, this script will communicate with the backend server to show whether files have been downloaded or not. 50 | 51 | ##### FAQ 52 | 53 | Q: The software doesn't start after clicking the .exe file. What should I do? 54 | A: The default port 3000 may already be in use. Try changing the port number. 55 | ShiguReader_Backend.exe --port 5000 56 | 57 | Q: Some videos cannot be played. What should I do? 58 | A: Videos are only a supplementary feature, and their supported formats are limited. 59 | 60 | Q: The software works fine on my computer, but after scanning the QR code, it doesn't open on my phone. How can I resolve this? 61 | A: Please make sure that your computer and phone are connected to the same local Wi-Fi network. If it still doesn't open, check your computer's firewall settings. 62 | 63 | Q: What does "ShiguReader" mean? 64 | A: ShiguReader is a combination of "Shigure" (しぐれ) and "Reader." The doujinshi of that era's Kantai Collection were really good. 65 | 66 | ##### Donations 67 | 68 | If you like our software and would like to treat us to a cup of milk tea, you can donate by scanning the following QR code via WeChat: 69 | WeChat 70 | 71 | ##### Development Environment Setup 72 | 73 | please refer to [Readme_Env_Setup](https://github.com/hjyssg/ShiguReader/blob/Dev_Frontend/Readme_Env_Setup.md). 74 | 75 | ##### Feedback and Suggestions 76 | 77 | If you have any questions or need assistance, please provide feedback through issues on GitHub. We also welcome any suggestions for improving ShiguReader. 78 | 79 | 80 | 81 | 82 | 83 | -------------------------------------------------------------------------------- /Readme_Env_Setup.md: -------------------------------------------------------------------------------- 1 | ##### Dev Environment Setup 2 | 3 | 4 | ###### Short Version: 5 | 6 | ```bash 7 | 8 | npm i 9 | npm run start 10 | 11 | ``` 12 | 13 | ###### Detailed Version: 14 | ```bash 15 | # Node.js 16 is recommended. 16 | 17 | # Install ImageMagick from https://imagemagick.org. 18 | # It is nice to have, but not necessary. 19 | 20 | # For Mac and *nix, install 7-Zip on your own. 21 | 22 | # If you are a Mac user living in China, remember to use an HTTP proxy. 23 | 24 | # Clone the repository or download it. 25 | git clone https://github.com/hjyssg/ShiguReader 26 | 27 | # Modify the following files: 28 | # - config-path.ini 29 | # - config-etc.ini 30 | 31 | ## Windows default CMD may not work. Please install Git and Git Bash from https://git-scm.com/. 32 | 33 | # Open the command prompt. 34 | cd ShiguReader 35 | 36 | # Install dependencies. 37 | npm install 38 | 39 | # If you live in China, I recommend the following: 40 | npm install -g cnpm --registry=https://registry.npm.taobao.org 41 | cnpm install 42 | 43 | # Start the development server. 44 | npm run start 45 | 46 | # Open the link shown in the command prompt. 47 | 48 | ``` 49 | 50 | 51 | | Software | Must Have | Note | 52 | |---------------|-----------|--------------------------------| 53 | | Node.js | Yes | Version 16 is recommended. | 54 | | image magick | No | Nice to have. | 55 | | 7-Zip | * | Windows does not need to install. Must-have for *nix. | 56 | | git bash | * | Must-have for Windows, not required for *nix. | -------------------------------------------------------------------------------- /auto_test/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "auto_test", 3 | "version": "0.0.1", 4 | "description": "test for shigureader", 5 | "main": "index", 6 | "scripts": { 7 | "test": "mocha test.js" 8 | }, 9 | "author": "", 10 | "license": "ISC", 11 | "dependencies": { 12 | "chai": "^4.2.0", 13 | "mocha": "^8.2.1", 14 | "puppeteer": "^5.5.0" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /auto_test/readme.txt: -------------------------------------------------------------------------------- 1 | 先把shigureader启动 然后在这里npm run test 2 | 3 | * 我不想加太多dep到主package.json了 -------------------------------------------------------------------------------- /auto_test/test.js: -------------------------------------------------------------------------------- 1 | const puppeteer = require('puppeteer'); 2 | // https://stackoverflow.com/questions/25678063/whats-the-difference-between-assertion-library-testing-framework-and-testing-e#:~:text=Assertion%20libraries%20are%20tools%20to,do%20thousands%20of%20if%20statements. 3 | const { expect } = require('chai'); 4 | const { link } = require('promise-fs'); 5 | 6 | const prefix_s = "test_screenshot/"; 7 | 8 | let screen_shot_count = 1; 9 | async function screenshot(page, fileName){ 10 | const fn = screen_shot_count + " " + fileName; 11 | await page.waitForTimeout(500); 12 | await page.screenshot({path: prefix_s + fn}); 13 | screen_shot_count++; 14 | } 15 | 16 | describe('ShiguReader Render Testing', function(){ 17 | 18 | let browser; 19 | let page; 20 | 21 | //https://github.com/mochajs/mocha/issues/2586 22 | //不用arrow function 23 | // Passing arrow functions (aka “lambdas”) to Mocha is discouraged. Lambdas lexically bind this and cannot access the Mocha context. 24 | this.timeout(30*1000); 25 | 26 | before(async function() { 27 | // runs once before the first test in this block 28 | browser = await puppeteer.launch({ 29 | headless: false 30 | }); 31 | }); 32 | 33 | 34 | after(async function() { 35 | // runs once after the last test in this block 36 | await browser.close(); 37 | }); 38 | 39 | beforeEach(async function() { 40 | page = await browser.newPage(); 41 | 42 | await page.setViewport({ 43 | width: 1000, 44 | height: 2000 45 | }); 46 | 47 | }); 48 | 49 | afterEach(async function() { 50 | await page.close(); 51 | }); 52 | 53 | it('home and workflow', async function() { 54 | await page.goto("http://localhost:3000/"); 55 | const title = await page.title(); 56 | expect(title).to.eql('ShiguReader'); 57 | await screenshot(page, 'home.png'); 58 | 59 | // const links = await page.evaluate(() => { 60 | // return Array.from(document.querySelectorAll('.explorer-container .dir-list a')); 61 | // }); 62 | 63 | //explorer------------------------------------------------ 64 | let links = await page.$$eval(".explorer-container .dir-list a", nodeArr => { 65 | return nodeArr.map(e => e.href); 66 | }); 67 | expect(links.length).to.greaterThan(0); 68 | await page.goto(links[0]); 69 | await screenshot(page, 'explorer.png'); 70 | 71 | const secPage = await browser.newPage(); 72 | 73 | 74 | //video-------------------------------- 75 | links = await page.$$eval(".explorer-container .video-list a", nodeArr => { 76 | return nodeArr.map(e => e.href); 77 | }); 78 | expect(links.length).to.greaterThan(0, "video link"); 79 | // await secPage.goto(links[0]); 80 | // await screenshot(secPage, 'video player.png'); 81 | 82 | 83 | //one book------------------------------------------ 84 | links = await page.$$eval(".explorer-container .file-cell-inner", nodeArr => { 85 | return nodeArr.map(e => e.href); 86 | }); 87 | 88 | console.log(links) 89 | expect(links.length).to.greaterThan(0, "one book link"); 90 | // for(let ii = 0; ii < links.length; ii++){ 91 | // await secPage.goto(link[ii]); 92 | // await screenshot(secPage, `${ii}_onebook.png`); 93 | 94 | // } 95 | await secPage.close() 96 | }); 97 | 98 | 99 | it('tag page', async function() { 100 | await page.goto("http://localhost:3000/tagPage/"); 101 | await screenshot(page, 'tag page.png'); 102 | 103 | 104 | // const title = await page.title(); 105 | // expect(title).to.eql('ShiguReader'); 106 | }); 107 | 108 | it('authorPage', async function() { 109 | await page.goto("http://localhost:3000/authorPage/"); 110 | await screenshot(page, 'authorPage.png'); 111 | 112 | // const title = await page.title(); 113 | // expect(title).to.eql('ShiguReader'); 114 | }); 115 | 116 | 117 | it('chart', async function() { 118 | await page.goto("http://localhost:3000/chart"); 119 | await screenshot(page, 'chart.png'); 120 | 121 | // const title = await page.title(); 122 | // expect(title).to.eql('ShiguReader'); 123 | }); 124 | 125 | 126 | it('admin', async function() { 127 | await page.goto("http://localhost:3000/admin"); 128 | await screenshot(page, 'admin.png'); 129 | 130 | // const title = await page.title(); 131 | // expect(title).to.eql('ShiguReader'); 132 | }) 133 | }); -------------------------------------------------------------------------------- /auto_test/test_screenshot/placeholder.txt: -------------------------------------------------------------------------------- 1 | meaningless file 2 | 3 | just to mkdir for git -------------------------------------------------------------------------------- /deprecated/Dockerfile: -------------------------------------------------------------------------------- 1 | # 基于node:alpine 2 | FROM node:alpine 3 | # 安装 node-sass 需要 python build-base,解压工具 p7zip 4 | RUN apk add --no-cache python build-base imagemagick p7zip 5 | 6 | # js文件存放目录 7 | WORKDIR /usr/src/app 8 | COPY package.json ./ 9 | 10 | #安装node依赖 11 | RUN npm install 12 | 13 | # /data 是漫画文件的目录, 也就是 config-path.ini 里面设置的路径。 容器需要把主机里的目录挂载到这个路径让程序读取。 14 | # 修改 src/config/user-config.js 内的 module.exports.good_folder 到 /data 目录下, 比如 /data/good /data/bad 15 | # docker实例,只有权限读取 /data 下挂载的文件 16 | VOLUME /data 17 | 18 | #网页端口 19 | EXPOSE 3000 20 | 21 | #安装程序 22 | COPY . . 23 | RUN mkdir thumbnails cache 24 | RUN chown -R node /usr/src/app 25 | USER node 26 | CMD [ "npm", "run","dev" ] 27 | -------------------------------------------------------------------------------- /deprecated/dockerguide.md: -------------------------------------------------------------------------------- 1 | # Docker使用说明 2 | 3 | ## 与Windows版本的区别 4 | 5 | 6 | 1. config-path.ini 中的文件路径被修改为了 7 | > /data 8 | 9 | 这是docker volume 挂载容器内以后的路径。 10 | 11 | 2. 因为不支持自定义输出目录,所以图包压缩和文件夹打包这两个功能需要手动下载到本地。否则文件会随着程序更新被docker删除。 12 | 13 | ## 运行方式 14 | > docker run -p 3000:3000 -v /path/to/comic:/data -d liwufan/shigureader 15 | 16 | - 3000 是默认端口,可以修改为3001:3000 3002:3000 3003:3000 等等 17 | - /path/to/comic 是漫画保存的目录 18 | 19 | 20 | 21 | Q:如何更新到最新镜像? 22 | 23 | A:镜像分两个分支 24 | - latest 是根据 [推送版本](https://github.com/hjyssg/ShiguReader/releases) 手动更新的,更新时间不定 25 | - nightly 是每晚合并最新代码自动生成的 26 | 使用命令下载新版本 27 | > docker pull liwufan/shigureader:nightly 28 | 29 | -------------------------------------------------------------------------------- /deprecated/ehentaiMetadata.js: -------------------------------------------------------------------------------- 1 | const util = global.requireUtil(); 2 | const { escapeRegExp } = util; 3 | const pathUtil = require("../pathUtil"); 4 | const { isExist } = pathUtil; 5 | const express = require('express'); 6 | const router = express.Router(); 7 | const path = require('path'); 8 | 9 | const serverUtil = require("../serverUtil"); 10 | const parse = serverUtil.parse; 11 | 12 | const jsonfile = require('jsonfile'); 13 | const Loki = require("lokijs"); 14 | const ehentai_db = new Loki(); 15 | const ehentai_collection = ehentai_db.addCollection("ehentai_metadata", { 16 | // indices: ["fileName", "tags", "authors"], 17 | // unique: ['filePath'] 18 | }); 19 | 20 | /** 21 | * return obj will have: 22 | * title, title_jpn, category, fileCount, parody, character, group, artist, female, male, _raw_tag 23 | */ 24 | function extractEntry(entry) { 25 | //a lot info in each metadata 26 | //we do not need all to waste RAM 27 | let meaningfulKey = [ 28 | "title", 29 | "title_jpn", 30 | "category", 31 | "fileCount", 32 | "tags" 33 | ]; 34 | 35 | const result = {}; 36 | for (let key in entry) { 37 | if (meaningfulKey.includes(key) && entry.hasOwnProperty(key)) { 38 | 39 | if (key === "tags") { 40 | const tags = entry[key]; 41 | //e.g tags 42 | // parody:pangya 43 | // character:kooh 44 | // group:arisan-antenna 45 | // artist:koari 46 | // female:catgirl 47 | // female:gymshorts 48 | // female:kemonomimi 49 | // female:lolicon 50 | // female:twintails 51 | 52 | tags.forEach(e => { 53 | const tokens = e.split(":").map(e => e.trim()); 54 | const subkey = tokens[0]; 55 | const subvalue = tokens[1]; 56 | 57 | result[subkey] = result[subkey] || []; 58 | result[subkey].push(subvalue) 59 | }); 60 | 61 | result._raw_tags = tags.join(";") 62 | } else { 63 | result[key] = entry[key]; 64 | } 65 | } 66 | } 67 | 68 | return result; 69 | } 70 | 71 | async function readJson(filePath) { 72 | let end1 = (new Date).getTime(); 73 | try { 74 | return; 75 | if (!(await isExist(filePath))) { 76 | return; 77 | } 78 | 79 | let obj = await jsonfile.readFile(filePath) 80 | 81 | let ii = 0; 82 | for (let key in obj) { 83 | if (obj.hasOwnProperty(key)) { 84 | //each valur represent one file 85 | const entry = extractEntry(obj[key]); 86 | ehentai_collection.insert(entry) 87 | ii++; 88 | if (ii % 10000 === 0) { 89 | console.log("[ehentaiMetadata] loading:", ii); 90 | } 91 | } 92 | } 93 | 94 | //这个和file collection进行join的key标注? 95 | 96 | } catch (error) { 97 | console.error(error) 98 | } finally { 99 | //release memory 100 | obj = null; 101 | let end3 = (new Date).getTime(); 102 | console.log("[ehentaiMetadata] number of entries in database :", ehentai_collection.count()) 103 | console.log(`${(end3 - end1) / 1000}s to load ehentai database`); 104 | } 105 | } 106 | 107 | 108 | // https://github.com/firefoxchan/local-ehentai/blob/master/README-zh.md 109 | const rootPath = path.join(__dirname, "..", "..", ".."); 110 | const _file_ = path.join(rootPath, "gdata.json"); 111 | readJson(_file_); 112 | 113 | 114 | //-------------------------------------------------------------- 115 | 116 | 117 | function searchOneBook(searchWord) { 118 | let reg, sResults; 119 | 120 | reg = escapeRegExp(searchWord); 121 | sResults = ehentai_collection 122 | .chain() 123 | .find({ 'title_jpn': { '$regex': reg } }) 124 | .data(); 125 | 126 | if (!sResults || sResults.length === 0) { 127 | const parseObj = parse(searchWord); 128 | if (parseObj && parseObj.author) { 129 | reg = escapeRegExp(parseObj.author); 130 | sResults = ehentai_collection 131 | .chain() 132 | .find({ 'title_jpn': { '$regex': reg } }) 133 | .where(obj => { 134 | if (obj['title_jpn'].includes(parseObj.title)) { 135 | return true; 136 | } 137 | }) 138 | .data(); 139 | } 140 | } 141 | 142 | if (sResults && sResults.length > 0) { 143 | return sResults; 144 | } 145 | } 146 | 147 | router.post('/api/getEhentaiMetaData', async (req, res) => { 148 | let filePath = req.body && req.body.filePath; 149 | 150 | if (!filePath) { 151 | res.send({ failed: true, reason: "No parameter" }); 152 | return; 153 | } 154 | 155 | const searchWord = path.basename(filePath, path.extname(filePath)); 156 | const sResult = searchOneBook(searchWord) 157 | if (!sResult) { 158 | res.send({ failed: true, reason: "NOT FOUND" }) 159 | } else { 160 | res.send(sResult); 161 | } 162 | }); 163 | 164 | //this search combine into exist tag search 165 | global.searchByTag = function (tag) { 166 | //todo: this algo is draft, need to improve 167 | const reg = escapeRegExp(tag); 168 | let sResults = ehentai_collection 169 | .chain() 170 | .find({ '_raw_tags': { '$regex': reg } }) 171 | // .where(obj => isSub(dir, obj.filePath)) 172 | .data(); 173 | return sResults; 174 | } 175 | 176 | module.exports = router; 177 | -------------------------------------------------------------------------------- /deprecated/fileServer.js: -------------------------------------------------------------------------------- 1 | // ABANDON 废弃文件 2 | // const express = require('express'); 3 | // // const app = express(); 4 | // const userConfig = global.requireUserConfig(); 5 | // const stringHash = require("string-hash"); 6 | // const pfs = require('promise-fs'); 7 | // const path = require('path'); 8 | 9 | // const util = global.requireUtil(); 10 | // const { isImage, isGif } = util; 11 | 12 | // const pathUtil = require("./pathUtil"); 13 | // const { isExist } = pathUtil; 14 | // const memorycache = require('memory-cache'); 15 | 16 | // // async function init() { 17 | // // const port = userConfig.file_server_port; 18 | // // const server = app.listen(port, async () => { 19 | // // console.log("[file server] on ", port) 20 | // // }).on('error', (error) => { 21 | // // console.error("[file server]", error.message); 22 | // // process.exit(22); 23 | // // }); 24 | // // } 25 | 26 | 27 | 28 | 29 | // let sharp; 30 | // try { 31 | // sharp = require('sharp') 32 | // } catch (e) { 33 | // console.error("did not install sharp", e); 34 | // } 35 | // const THUMBNAIL_HUGE_THRESHOLD = 2 * 1000 * 1000; 36 | // const ONEBOOK_HUGE_THRESHOLD = 3 * 1000 * 1000; 37 | 38 | 39 | // const router = express.Router(); 40 | 41 | // //------------------download------------ 42 | // router.get('/api/download/', async (req, res) => { 43 | // let filePath = path.resolve(req.query.p); 44 | // let thumbnailMode = req.query.thumbnailMode; 45 | // if (!filePath) { 46 | // console.error("[/api/download]", filePath, "NO Param"); 47 | // res.send({ failed: true, reason: "NO Param" }); 48 | // return; 49 | // } 50 | 51 | 52 | // if (!(await isExist(filePath))) { 53 | // console.error("[/api/download]", filePath, "NOT FOUND"); 54 | // res.send({ failed: true, reason: "NOT FOUND" }); 55 | // return; 56 | // } 57 | 58 | // try { 59 | // if (sharp && isImage(filePath) && !isGif(filePath)) { 60 | // if(memorycache.get(filePath)){ 61 | // filePath = memorycache.get(filePath); 62 | // }else{ 63 | // const stat = await pfs.stat(filePath); 64 | // // mimize as thumbnail 65 | // if (thumbnailMode && stat.size > THUMBNAIL_HUGE_THRESHOLD) { 66 | // const outputFn = stringHash(filePath).toString() + "-min.webp"; 67 | // const outputPath = path.resolve(global.cachePath, outputFn); 68 | // if (!(await isExist(outputPath))) { 69 | // await sharp(filePath).resize({ height: 280 }).toFile(outputPath); 70 | // } 71 | // memorycache.put(filePath, outputPath, 60*1000); 72 | // filePath = outputPath; 73 | // }else if(stat.size > ONEBOOK_HUGE_THRESHOLD){ 74 | // // shrink huge img 75 | // const outputFn = stringHash(filePath).toString() + "-min-2.webp"; 76 | // const outputPath = path.resolve(global.cachePath, outputFn); 77 | // if (!(await isExist(outputPath))) { 78 | // await sharp(filePath).resize({ height: 1980 }).toFile(outputPath); 79 | // } 80 | // memorycache.put(filePath, outputPath, 60*1000); 81 | // filePath = outputPath; 82 | // } 83 | // } 84 | 85 | 86 | // } 87 | // } catch (e) { 88 | // console.error("[file server error] during compression",e); 89 | // } 90 | 91 | // res.setHeader('Cache-Control', 'public, max-age=86400') 92 | // res.download(filePath); // Set disposition and send it. 93 | // }); 94 | 95 | // // module.exports.init = init; 96 | // module.exports = router; 97 | -------------------------------------------------------------------------------- /deprecated/sortUtil.js: -------------------------------------------------------------------------------- 1 | // [deprecated]前后端都不需要了 2 | 3 | const nameParser = require('../name-parser'); 4 | const _ = require('underscore'); 5 | 6 | 7 | /** 8 | * sort file by time。 9 | * 有三种时间 10 | * * mtime: OS磁盘文件系统保存 11 | * * rtime:最近一次的阅读时间 12 | * * tag time:根据文件名推算出来的时间 13 | */ 14 | module.exports.sort_file_by_time = function (files, config) { 15 | const { fileInfos, getBaseName, ascend, onlyByMTime } = config; 16 | const fp2Time = {}; 17 | 18 | // 5400个文件计算time需要0.4秒 19 | let logLabel = "sort_file_by_time "+files.length; 20 | // console.time(logLabel); 21 | 22 | files.forEach(fp => { 23 | if(fp2Time[fp]){ 24 | return fp2Time[fp]; 25 | } 26 | const fn = getBaseName(fp); 27 | let time = null; 28 | 29 | function getMtime(){ 30 | if(!time){ 31 | const mTime = fileInfos && fileInfos[fp] && parseInt(fileInfos[fp].mtimeMs); 32 | time = mTime; 33 | } 34 | } 35 | 36 | function getTTime(){ 37 | if(!time){ 38 | let tTime = nameParser.getDateFromParse(fn); 39 | tTime = tTime && tTime.getTime(); 40 | time = tTime; 41 | } 42 | } 43 | 44 | if (onlyByMTime) { 45 | getMtime(); 46 | } else { 47 | getMtime(); 48 | getTTime(); 49 | } 50 | 51 | time = time || -Infinity; 52 | fp2Time[fp] = ascend ? time : -time; 53 | return fp2Time[fp]; 54 | }) 55 | // console.timeEnd(logLabel); 56 | 57 | // logLabel = "sort_file_by_time "+files.length + " part_2" 58 | // console.time(logLabel); 59 | files.sort((a, b)=> fp2Time[a] - fp2Time[b]); 60 | // console.timeEnd(logLabel); 61 | } 62 | 63 | -------------------------------------------------------------------------------- /experiment/when_turnoff_watching_D_and_E_drive.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hjyssg/ShiguReader/64a614d0e2b1e50df010dc95397d435bc729f084/experiment/when_turnoff_watching_D_and_E_drive.jpg -------------------------------------------------------------------------------- /experiment/when_watch_D_and_E_drive.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hjyssg/ShiguReader/64a614d0e2b1e50df010dc95397d435bc729f084/experiment/when_watch_D_and_E_drive.jpg -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ShiguReader_Frontend", 3 | "version": "2.0.0", 4 | "description": "All-in-one solution for local doujin/anime/music file", 5 | "repository": "hjyssg/ShiguReader", 6 | "main": "/src/client/index.js", 7 | "bin": "/src/client/index.js", 8 | "scripts": { 9 | "build": "webpack --mode production", 10 | "build-dev": "webpack --mode development --devtool inline-source-map", 11 | "start": "webpack-dev-server --mode development --devtool inline-source-map --hot", 12 | "start-production": "webpack-dev-server --mode production", 13 | "flow": "flow", 14 | "test": "mocha ./src/test" 15 | }, 16 | "author": "aji47", 17 | "license": "MIT", 18 | "dependencies": { 19 | "chart.js": "^4.3.0", 20 | "classnames": "^2.2.6", 21 | "dateformat": "^3.0.3", 22 | "filesize": "^4.1.2", 23 | "jquery": "^3.5.0", 24 | "js-cookie": "^2.2.1", 25 | "prop-types": "^15.6.2", 26 | "qrcode.react": "^3.1.0", 27 | "query-string": "^6.2.0", 28 | "react": "^18.2.0", 29 | "react-chartjs-2": "^5.2.0", 30 | "react-click-outside": "^3.0.1", 31 | "react-collapse": "^5.0.1", 32 | "react-dom": "^18.2.0", 33 | "react-dplayer": "^0.4.2", 34 | "react-modal": "^3.12.1", 35 | "react-range-slider-input": "^3.0.7", 36 | "react-router-dom": "^4.3.1", 37 | "react-toastify": "^9.1.2", 38 | "react-visibility-sensor": "^5.1.1", 39 | "request": "^2.88.2", 40 | "request-promise": "^4.2.6", 41 | "screenfull": "^4.0.0", 42 | "sweetalert2": "^8.0.7", 43 | "underscore": "^1.9.1", 44 | "whatwg-fetch": "^3.0.0" 45 | }, 46 | "devDependencies": { 47 | "dart-sass": "^1.25.0", 48 | "babel-polyfill": "^6.26.0", 49 | "@babel/core": "^7.0.0", 50 | "@babel/plugin-proposal-class-properties": "^7.0.0", 51 | "@babel/preset-env": "^7.0.0", 52 | "@babel/preset-react": "^7.0.0", 53 | "babel-eslint": "^10.0.0", 54 | "babel-loader": "^8.0.0", 55 | "chai": "^4.2.0", 56 | "concurrently": "^4.0.0", 57 | "css-loader": "^2.1.0", 58 | "eslint": "^5.0.0", 59 | "eslint-config-airbnb": "^17.0.0", 60 | "eslint-plugin-import": "^2.11.0", 61 | "eslint-plugin-jsx-a11y": "^6.0.3", 62 | "eslint-plugin-react": "^7.7.0", 63 | "file-loader": "^3.0.0", 64 | "less": "^3.9.0", 65 | "less-loader": "^4.1.0", 66 | "mocha": "^5.2.0", 67 | "sass-loader": "^7.1.0", 68 | "style-loader": "^0.23.1", 69 | "url-loader": "^1.0.1", 70 | "webpack": "^5.82.1", 71 | "webpack-cli": "^5.1.1", 72 | "webpack-dev-server": "^4.15.0", 73 | "html-webpack-plugin": "^5.5.1", 74 | "clean-webpack-plugin": "^4.0.0" 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /public/error_loading.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hjyssg/ShiguReader/64a614d0e2b1e50df010dc95397d435bc729f084/public/error_loading.png -------------------------------------------------------------------------------- /public/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hjyssg/ShiguReader/64a614d0e2b1e50df010dc95397d435bc729f084/public/favicon-96x96.png -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | ShiguReader 14 | 15 | 16 | 17 |
18 | 19 | 20 | -------------------------------------------------------------------------------- /screenshot/01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hjyssg/ShiguReader/64a614d0e2b1e50df010dc95397d435bc729f084/screenshot/01.png -------------------------------------------------------------------------------- /screenshot/02.5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hjyssg/ShiguReader/64a614d0e2b1e50df010dc95397d435bc729f084/screenshot/02.5.png -------------------------------------------------------------------------------- /screenshot/02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hjyssg/ShiguReader/64a614d0e2b1e50df010dc95397d435bc729f084/screenshot/02.png -------------------------------------------------------------------------------- /screenshot/03.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hjyssg/ShiguReader/64a614d0e2b1e50df010dc95397d435bc729f084/screenshot/03.png -------------------------------------------------------------------------------- /screenshot/04.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hjyssg/ShiguReader/64a614d0e2b1e50df010dc95397d435bc729f084/screenshot/04.png -------------------------------------------------------------------------------- /screenshot/05.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hjyssg/ShiguReader/64a614d0e2b1e50df010dc95397d435bc729f084/screenshot/05.png -------------------------------------------------------------------------------- /screenshot/06.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hjyssg/ShiguReader/64a614d0e2b1e50df010dc95397d435bc729f084/screenshot/06.png -------------------------------------------------------------------------------- /screenshot/07.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hjyssg/ShiguReader/64a614d0e2b1e50df010dc95397d435bc729f084/screenshot/07.png -------------------------------------------------------------------------------- /screenshot/08.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hjyssg/ShiguReader/64a614d0e2b1e50df010dc95397d435bc729f084/screenshot/08.png -------------------------------------------------------------------------------- /screenshot/git bash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hjyssg/ShiguReader/64a614d0e2b1e50df010dc95397d435bc729f084/screenshot/git bash.png -------------------------------------------------------------------------------- /screenshot/git bash2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hjyssg/ShiguReader/64a614d0e2b1e50df010dc95397d435bc729f084/screenshot/git bash2.png -------------------------------------------------------------------------------- /screenshot/right-click-search-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hjyssg/ShiguReader/64a614d0e2b1e50df010dc95397d435bc729f084/screenshot/right-click-search-1.png -------------------------------------------------------------------------------- /screenshot/right-click-search-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hjyssg/ShiguReader/64a614d0e2b1e50df010dc95397d435bc729f084/screenshot/right-click-search-2.png -------------------------------------------------------------------------------- /screenshot/unicode-setting.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hjyssg/ShiguReader/64a614d0e2b1e50df010dc95397d435bc729f084/screenshot/unicode-setting.png -------------------------------------------------------------------------------- /src/TamperMonkeyScript/EhentaiTagDownload.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name Ehentai tag download 3 | // @grant GM_xmlhttpRequest 4 | // @grant GM_addStyle 5 | // @grant GM_getValue 6 | // @grant GM_setValue 7 | // @connect localhost 8 | // @connect api.e-hentai.org 9 | // @namespace Aji47 10 | // @version 0.0.1 11 | // @description 12 | // @author Aji47 13 | // @include *://exhentai.org/* 14 | // @include *://g.e-hentai.org/* 15 | // @include *://e-hentai.org/* 16 | // ==/UserScript== 17 | 18 | 19 | function getCurrentTime() { 20 | return new Date().getTime(); 21 | } 22 | 23 | async function GM_xmlhttpRequestPromise(dataObj) { 24 | return new Promise((resolve, reject) => { 25 | GM_xmlhttpRequest({ 26 | method: "POST", 27 | data: JSON.stringify(dataObj), 28 | url: "https://api.e-hentai.org/api.php", 29 | onerror: err => { 30 | reject(err); 31 | }, 32 | ontimeout: () => { 33 | reject("timeout"); 34 | }, 35 | onload: res => { 36 | resolve(res); 37 | } 38 | }); 39 | }) 40 | } 41 | 42 | 43 | //https://stackoverflow.com/questions/6480082/add-a-javascript-button-using-greasemonkey-or-tampermonkey 44 | function addButton(text, onclick, cssObj, id) { 45 | const defaultCSS = { 46 | position: 'fixed', top: '7%', left: '50%', 'z-index': 3, 47 | "background-color": "#57cff7", "color": "white", 48 | "padding": "10px", "border": "0px", 49 | "font-size": "1rem", "font-weight": "bold" 50 | } 51 | cssObj = Object.assign(defaultCSS, cssObj || {}) 52 | let button = document.createElement('button'), btnStyle = button.style; 53 | document.body.appendChild(button) 54 | button.innerHTML = text; 55 | button.onclick = onclick 56 | btnStyle.position = 'fixed'; 57 | button.id = id; 58 | Object.keys(cssObj).forEach(key => btnStyle[key] = cssObj[key]); 59 | return button; 60 | } 61 | 62 | //https://stackoverflow.com/questions/3665115/how-to-create-a-file-in-memory-for-user-to-download-but-not-through-server 63 | function download(filename, text) { 64 | var element = document.createElement('a'); 65 | element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(text)); 66 | element.setAttribute('download', filename); 67 | 68 | element.style.display = 'none'; 69 | document.body.appendChild(element); 70 | 71 | element.click(); 72 | 73 | document.body.removeChild(element); 74 | } 75 | 76 | function sleep(ms) { 77 | return new Promise(resolve => setTimeout(resolve, ms)); 78 | } 79 | 80 | ///------------------------------------------- 81 | 82 | 83 | function findToken() { 84 | const nodes = Array.prototype.slice.call(document.getElementsByClassName("gl1t")); 85 | if (!nodes || nodes.length === 0) { 86 | return; 87 | } 88 | 89 | const result = []; 90 | nodes.forEach(e => { 91 | try { 92 | const subNode = e.getElementsByClassName("gl4t")[0]; 93 | const text = subNode.textContent; 94 | if (!text || text.includes("翻訳") || text.includes("翻译")) { 95 | return; 96 | } 97 | const link = e.querySelector("a").href; 98 | result.push({ 99 | title: text, 100 | link 101 | }) 102 | } catch (e) { 103 | console.error(e); 104 | } 105 | }); 106 | 107 | 108 | return result; 109 | } 110 | 111 | 112 | let _stop_download_; 113 | 114 | const max_data = 24; 115 | 116 | async function doMainTask() { 117 | 118 | stop_download_ = false; 119 | 120 | debugger 121 | 122 | const data = { 123 | "method": "gdata", 124 | "gidlist": [], 125 | "namespace": 1 126 | } 127 | 128 | const linkAndNameArr = findToken(); 129 | for (let ii = 0; ii < linkAndNameArr.length; ii++) { 130 | e = linkAndNameArr[ii]; 131 | const url = new URL(e.link); 132 | const tokens = url.pathname.split("/").filter(e => !!e); 133 | data.gidlist.push([tokens[1], tokens[2]]); 134 | 135 | try { 136 | if (data.gidlist.length > max_data) { 137 | const res = await GM_xmlhttpRequestPromise(data); 138 | 139 | await sleep(1000); 140 | data.gidlist = []; 141 | const str = JSON.stringify(JSON.parse(res.responseText)); 142 | download(getCurrentTime(), str); 143 | } 144 | } catch (e) { 145 | debugger 146 | console.error(e); 147 | } 148 | } 149 | } 150 | 151 | 152 | 153 | (function () { 154 | 'use strict'; 155 | addButton("download all images", doMainTask, { top: '7%' }, "a-begin-button"); 156 | 157 | addButton("stop download", () => { 158 | _stop_download_ = true; 159 | console.log("going to stop..."); 160 | }, { top: '12%' }, "a-stop-button"); 161 | })(); 162 | 163 | -------------------------------------------------------------------------------- /src/client/AdminUtil.js: -------------------------------------------------------------------------------- 1 | import Swal from 'sweetalert2'; 2 | import Sender from './Sender'; 3 | import { toast } from 'react-toastify'; 4 | import React, { Component } from 'react'; 5 | 6 | 7 | 8 | const askPregenerate = function (path, fastUpdateMode) { 9 | Swal.fire({ 10 | title: "Pregenerate Thumbnail", 11 | text: path, 12 | showCancelButton: true, 13 | confirmButtonText: 'Yes', 14 | cancelButtonText: 'No' 15 | }).then((result) => { 16 | if (result.value === true) { 17 | const reqBoby = { 18 | pregenerateThumbnailPath: path, 19 | fastUpdateMode: fastUpdateMode 20 | } 21 | Sender.post('/api/pregenerateThumbnails', reqBoby, res => { 22 | const reason = res.json.reason; 23 | const isFailed = res.isFailed() 24 | 25 | const toastConfig = { 26 | position: "top-right", 27 | autoClose: 5 * 1000, 28 | hideProgressBar: true, 29 | closeOnClick: true, 30 | pauseOnHover: true, 31 | draggable: true, 32 | progress: false 33 | }; 34 | 35 | const badge = isFailed ? (Error) : 36 | (progressing...) 37 | 38 | let divContent = ( 39 |
40 |
41 | {badge} 42 |
43 | 44 | {isFailed && reason && ( 45 |
46 |
{reason}
47 |
48 | )} 49 |
); 50 | 51 | toast(divContent, toastConfig) 52 | }); 53 | } 54 | }); 55 | } 56 | 57 | //https://stackoverflow.com/questions/47313645/module-exports-cannot-set-property-of-undefined 58 | export { askPregenerate } 59 | -------------------------------------------------------------------------------- /src/client/ChartUtil.js: -------------------------------------------------------------------------------- 1 | 2 | const clientUtil = require("./clientUtil"); 3 | const { getBaseName } = clientUtil; 4 | import _ from "underscore"; 5 | import React, { Component } from "react"; 6 | 7 | 8 | const BY_MTIME = "by mtime"; 9 | const BY_TAG_TIME = "by tag time"; 10 | 11 | const BY_YEAR = "by year"; 12 | const BY_QUARTER = "by quarter"; 13 | const BY_MONTH = "by month"; 14 | const BY_DAY = "by day"; 15 | 16 | const VALUE_COUNT = "file number"; 17 | const VALUE_FILESIZE = "file size in GB"; 18 | 19 | 20 | export const do_statitic_by_time_v2 = (ByTagTime, byMTime, type, timeSourceType, valueType, timeType) => { 21 | // 选择合适的数据源 22 | const dataSource = timeSourceType === BY_TAG_TIME ? ByTagTime : byMTime; 23 | 24 | // 初始化结果对象 25 | const result = {}; 26 | 27 | // 遍历数据源 28 | for (const timestamp in dataSource) { 29 | if (dataSource.hasOwnProperty(timestamp)) { 30 | const date = new Date(parseInt(timestamp)); 31 | const data = dataSource[timestamp][type]; 32 | 33 | if (!data) continue; // 如果没有对应类型的数据,跳过 34 | 35 | // 根据 valueType 选择统计的值 36 | const value = valueType === VALUE_COUNT ? data.fileCount : data.fileSize / (1024 * 1024); // 转换为MB 37 | 38 | // 根据 timeType 生成键 39 | let key; 40 | switch (timeType) { 41 | case BY_YEAR: 42 | key = date.getFullYear(); 43 | break; 44 | case BY_QUARTER: 45 | key = `${date.getFullYear()}-Q${Math.floor(date.getMonth() / 3) + 1}`; 46 | break; 47 | case BY_MONTH: 48 | key = `${date.getFullYear()}-${date.getMonth() + 1}`; 49 | break; 50 | case BY_DAY: 51 | key = `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`; 52 | break; 53 | default: 54 | throw new Error("Unsupported timeType"); 55 | } 56 | 57 | // 合并数据 58 | if (!result[key]) { 59 | result[key] = 0; 60 | } 61 | result[key] += value; 62 | } 63 | } 64 | 65 | return result; 66 | }; 67 | 68 | /** 69 | * 从对象中获取并排序键,基于提供的函数进行过滤, 70 | * 并返回一个包含过滤后键及其对应值的对象。 71 | * 72 | * @param {Object} keyToValueTable - 包含键值对的对象。 73 | * @param {Function} [filterFunction] - 用于过滤键值对的函数。接收一个键及其值并返回一个布尔值。 74 | * @returns {{keys: Array, values: Array}} 一个包含排序和过滤后的键及其对应值的对象。 75 | */ 76 | export function getKeyAndValues(keyToValueTable, filterFunction) { 77 | const tempKeys = _.keys(keyToValueTable); 78 | tempKeys.sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); 79 | const values = []; 80 | let keys = []; 81 | tempKeys.forEach((key) => { 82 | const value = keyToValueTable[key]; 83 | 84 | if (filterFunction && !filterFunction(key, value)) { 85 | return; 86 | } 87 | values.push(value); 88 | keys.push(key); 89 | }); 90 | 91 | return { 92 | keys, 93 | values, 94 | }; 95 | } 96 | 97 | 98 | export const SimpleDataTable = ({labels, values}) => { 99 | const tableHeader = ( 100 | 101 | 102 | name 103 | number 104 | 105 | 106 | ); 107 | 108 | const rows = labels.map((e, index) => { 109 | return ( 110 | 111 | {e} 112 | {values[index]} 113 | 114 | ); 115 | }); 116 | 117 | return ( 118 | 119 | {tableHeader} 120 | {rows} 121 |
122 | ); 123 | } 124 | 125 | export const calculateTotalFilesAndSize = (ByTagTime, type) => { 126 | let totalFileCount = 0; 127 | let totalFileSize = 0; // 以字节为单位 128 | 129 | Object.keys(ByTagTime).forEach(timestamp => { 130 | const data = ByTagTime[timestamp][type]; 131 | if(!data){ 132 | return; 133 | } 134 | totalFileCount += data.fileCount; 135 | totalFileSize += data.fileSize; 136 | }); 137 | 138 | 139 | 140 | return { 141 | totalFileCount, 142 | totalFileSize 143 | }; 144 | }; -------------------------------------------------------------------------------- /src/client/ClientConstant.js: -------------------------------------------------------------------------------- 1 | const BY_FILE_NUMBER = "file number"; 2 | const BY_TAG_NAME = "tag name"; 3 | const BY_TIME = "time"; 4 | const BY_MTIME = "mtime"; 5 | const BY_LAST_READ_TIME = "last read time"; 6 | const BY_READ_COUNT = "read count" 7 | const BY_FILE_SIZE = "file size"; 8 | const BY_AVG_PAGE_SIZE = "avg page size"; 9 | const BY_PAGE_NUMBER = "page num"; 10 | const BY_FILENAME = "filename"; 11 | const BY_GOOD_SCORE = "score"; 12 | const BY_FOLDER = "by folder name"; 13 | const BY_LATEST_WORK = "by latest work"; 14 | const BY_RANDOM = "random"; 15 | 16 | module.exports = { 17 | BY_FILE_NUMBER, 18 | BY_TAG_NAME, 19 | BY_TIME, 20 | BY_MTIME, 21 | BY_LAST_READ_TIME, 22 | BY_READ_COUNT, 23 | BY_FILE_SIZE, 24 | BY_AVG_PAGE_SIZE, 25 | BY_PAGE_NUMBER, 26 | BY_FILENAME, 27 | BY_GOOD_SCORE, 28 | BY_FOLDER, 29 | BY_LATEST_WORK, 30 | BY_RANDOM, 31 | } 32 | 33 | module.exports.TAG_SORT_OPTIONS = [ 34 | BY_FILE_NUMBER, 35 | BY_GOOD_SCORE, 36 | BY_TAG_NAME, 37 | BY_LATEST_WORK, 38 | BY_RANDOM 39 | ]; 40 | 41 | module.exports.AUTHOR_SORT_OPTIONS = [ 42 | BY_FILE_NUMBER, 43 | BY_GOOD_SCORE, 44 | BY_TAG_NAME, 45 | BY_LATEST_WORK, 46 | BY_RANDOM 47 | ]; 48 | 49 | module.exports.SORT_OPTIONS = [ 50 | BY_TIME, 51 | BY_MTIME, 52 | BY_LAST_READ_TIME, 53 | BY_READ_COUNT, 54 | BY_FILE_SIZE, 55 | BY_AVG_PAGE_SIZE, 56 | BY_PAGE_NUMBER, 57 | BY_FILENAME, 58 | BY_GOOD_SCORE, 59 | BY_RANDOM 60 | ]; 61 | -------------------------------------------------------------------------------- /src/client/ErrorPage.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import './style/ErrorPage.scss'; 3 | import LoginPage from "./LoginPage"; 4 | const _ = require("underscore"); 5 | 6 | 7 | export default class ErrorPage extends Component { 8 | render() { 9 | let { filePath, res } = this.props; 10 | let { status, statusText } = res || {}; 11 | 12 | let text; 13 | 14 | if (status === 504) { 15 | text = "The backend server did not start."; 16 | } else if (res.isFailed() && res.json.reason) { 17 | status = "ERROR" 18 | statusText = _.isString(res.json.reason)? res.json.reason: "" 19 | text = filePath; 20 | } else if (status === 404 && filePath) { 21 | text = `Could not find ${filePath}.`; 22 | } else if (status === 500 && filePath) { 23 | text = `${filePath} is a broken file`; 24 | } 25 | 26 | if(statusText === "You need to login"){ 27 | sessionStorage.setItem('url_before_login', window.location.href||""); 28 | return ; 29 | } 30 | 31 | return ( 32 |
33 |
34 |
35 | {status} 36 |
37 |

{statusText}

38 |

{text}

39 | {/* Go To Homepage */} 40 |
41 | 42 |
); 43 | } 44 | } 45 | 46 | -------------------------------------------------------------------------------- /src/client/ExplorerUtil.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | // import React, { Component } from 'react'; 3 | import _ from "underscore"; 4 | // import './style/Explorer.scss'; 5 | // import LoadingImage from './LoadingImage'; 6 | // import Sender from './Sender'; 7 | // import { Link } from 'react-router-dom'; 8 | 9 | // const userConfig = require('@config/user-config'); 10 | // import ErrorPage from './ErrorPage'; 11 | // import CenterSpinner from './subcomponent/CenterSpinner'; 12 | const util = require("@common/util"); 13 | // const queryString = require('query-string'); 14 | // import Pagination from './subcomponent/Pagination'; 15 | // import ItemsContainer from './subcomponent/ItemsContainer'; 16 | // import SortHeader from './subcomponent/SortHeader'; 17 | // import Breadcrumb from './subcomponent/Breadcrumb'; 18 | // import FileCellTitle from './subcomponent/FileCellTitle'; 19 | // import Checkbox from './subcomponent/Checkbox'; 20 | // import ThumbnailPopup from './subcomponent/ThumbnailPopup'; 21 | // import { getFileUrl } from './clientUtil'; 22 | // const nameParser = require('@name-parser'); 23 | // const classNames = require('classnames'); 24 | // const Constant = require("@common/constant"); 25 | const clientUtil = require("./clientUtil"); 26 | const { getDir, getBaseName, getPerPageItemNumber, isSearchInputTextTyping, filesizeUitl, sortFileNames } = clientUtil; 27 | // const { isVideo, isCompress, isImage, isMusic } = util; 28 | // const AdminUtil = require("./AdminUtil"); 29 | 30 | const ClientConstant = require("./ClientConstant"); 31 | const { BY_FILE_NUMBER, 32 | BY_TIME, 33 | BY_MTIME, 34 | BY_LAST_READ_TIME, 35 | BY_READ_COUNT, 36 | BY_FILE_SIZE, 37 | BY_AVG_PAGE_SIZE, 38 | BY_PAGE_NUMBER, 39 | BY_FILENAME, 40 | BY_GOOD_SCORE, 41 | BY_FOLDER, 42 | BY_RANDOM } = ClientConstant; 43 | 44 | 45 | 46 | 47 | export const sortFiles = (info, files, sortOrder, isSortAsc) => { 48 | //-------sort algo 49 | const byFn = (a, b) => { 50 | const ap = getBaseName(a); 51 | const bp = getBaseName(b); 52 | return ap.localeCompare(bp); 53 | } 54 | 55 | 56 | // 一律先时间排序 57 | // 下方的sort都是stable sort。 58 | files = _.sortBy(files, e => { 59 | // 没有信息,排到前面来触发后端get thumbnail。获得信息 60 | const mtime = info.getMtime(e); 61 | const ttime = info.getTTime(e); 62 | 63 | if (mtime && ttime) { 64 | const gap = Math.abs(mtime - ttime); 65 | const GAP_THRESHOLD = 180 * 24 * 3600 * 1000; 66 | if (gap > GAP_THRESHOLD) { 67 | return Math.min(mtime, ttime) || Infinity; 68 | } else { 69 | return mtime || Infinity; 70 | } 71 | } else { 72 | return mtime || ttime; 73 | } 74 | }); 75 | 76 | if (sortOrder === BY_RANDOM) { 77 | files = _.shuffle(files); 78 | } else if (sortOrder === BY_FILENAME) { 79 | files.sort((a, b) => { 80 | return byFn(a, b); 81 | }); 82 | } else if (sortOrder == BY_GOOD_SCORE) { 83 | // 喜好排序 84 | files.sort((a, b) => { 85 | let s1 = info.getScore(a); 86 | let s2 = info.getScore(b); 87 | 88 | if (s1 == s2) { 89 | const adjustScore = (fp, score) => { 90 | const { good_folder_root, not_good_folder_root } = info.context; 91 | if (good_folder_root && fp.includes(good_folder_root)) { 92 | score += 1; 93 | } else if (not_good_folder_root && fp.includes(not_good_folder_root)) { 94 | score -= 1; 95 | } 96 | return score; 97 | } 98 | s1 = adjustScore(a, s1); 99 | s2 = adjustScore(b, s2); 100 | 101 | return s1 - s2; 102 | } else { 103 | return s1 - s2; 104 | } 105 | }) 106 | } else if (sortOrder === BY_FOLDER) { 107 | files = _.sortBy(files, e => { 108 | const dir = getDir(e); 109 | return dir; 110 | }); 111 | } else if (sortOrder === BY_TIME) { 112 | // pass 113 | } else if (sortOrder === BY_MTIME) { 114 | //只看mtime 115 | files = _.sortBy(files, e => { 116 | const mtime = info.getMtime(e); 117 | return mtime || Infinity; 118 | }); 119 | } else if (sortOrder === BY_LAST_READ_TIME) { 120 | files = _.sortBy(files, e => { 121 | return info.getLastReadTime(e); 122 | }); 123 | } else if (sortOrder === BY_READ_COUNT) { 124 | files = _.sortBy(files, e => { 125 | return info.getReadCount(e); 126 | }); 127 | } else if (sortOrder === BY_FILE_SIZE) { 128 | files = _.sortBy(files, e => { 129 | return info.getFileSize(e); 130 | }); 131 | } else if (sortOrder === BY_AVG_PAGE_SIZE) { 132 | files = _.sortBy(files, e => { 133 | return info.getPageAvgSize(e); 134 | }); 135 | } else if (sortOrder === BY_PAGE_NUMBER) { 136 | files = _.sortBy(files, e => { 137 | return info.getPageNum(e); 138 | }); 139 | } 140 | 141 | if (!isSortAsc) { 142 | files.reverse(); 143 | } 144 | 145 | return files; 146 | } -------------------------------------------------------------------------------- /src/client/HistoryPage.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import './style/HistoryPage.scss'; 3 | import Sender from './Sender'; 4 | import _ from "underscore"; 5 | // import ReactDOM from 'react-dom'; 6 | // import Swal from 'sweetalert2'; 7 | // import Cookie from "js-cookie"; 8 | import { Link } from 'react-router-dom'; 9 | import ErrorPage from './ErrorPage'; 10 | import CenterSpinner from './subcomponent/CenterSpinner'; 11 | const clientUtil = require("./clientUtil"); 12 | const { getBaseName } = clientUtil; 13 | // const AdminUtil = require("./AdminUtil"); 14 | import { GlobalContext } from './globalContext' 15 | const util = require("@common/util"); 16 | const classNames = require('classnames'); 17 | import Pagination from './subcomponent/Pagination'; 18 | import ThumbnailPopup from './subcomponent/ThumbnailPopup'; 19 | 20 | 21 | 22 | function renderHistory(history) { 23 | 24 | const groupByDay = _.groupBy(history, e => { 25 | let d = new Date(e.time); 26 | d.setHours(0); 27 | d.setMinutes(0); 28 | d.setSeconds(0); 29 | d.setMilliseconds(0); 30 | return d.getTime(); 31 | }); 32 | 33 | let keys = _.keys(groupByDay); 34 | keys = _.sortBy(keys, e => -e); 35 | 36 | const historyDom = keys.map(key => { 37 | const timeStr = clientUtil.dateFormat_ymd(new Date(parseInt(key))); 38 | let items = groupByDay[key]; 39 | 40 | items = _.sortBy(items, e => -e.time); 41 | 42 | const dayHistory = items.map((e, ii) => { 43 | const filePath = e.filePath; 44 | const toUrl = util.isVideo(filePath)? 45 | clientUtil.getVideoPlayerLink(filePath) : 46 | clientUtil.getOneBookLink(filePath); 47 | const fn = getBaseName(filePath)||filePath; 48 | const itemTimeStr = clientUtil.dateFormat_v1(new Date(e.time)); 49 | const tooltip = `${fn}\n${itemTimeStr}` 50 | 51 | const cn = classNames("icon", { 52 | "far fa-file-video": util.isVideo(filePath), 53 | "fas fa-book": util.isCompress(filePath), 54 | "far fa-folder": !util.isVideo(filePath) && !util.isCompress(filePath) 55 | }); 56 | 57 | return ( 58 | 59 | 60 |
61 | 62 | {fn} 63 |
64 |
65 | ); 66 | 67 | }) 68 | 69 | return ( 70 |
71 |
72 | {timeStr} 73 | {`${items.length} items`} 74 |
75 | {dayHistory} 76 |
77 | ) 78 | }) 79 | 80 | return ( 81 |
82 | {/*
Recent Read
*/} 83 |
84 | {historyDom} 85 |
86 |
) 87 | } 88 | 89 | export default class HistoryPage extends Component { 90 | constructor(prop) { 91 | super(prop); 92 | 93 | this.metaInfo = [ 94 | {key:"pageIndex", type: "int", defVal: 1}, 95 | ]; 96 | this.state = this.getInitState(); 97 | } 98 | 99 | getInitState(reset) { 100 | const initState = clientUtil.getInitState(this.metaInfo, reset); 101 | return { 102 | ...initState, 103 | totalCount: 0 104 | } 105 | } 106 | 107 | setStateAndSetHash(state, callback) { 108 | this.setState(state, callback); 109 | const newState = {...this.state, ...state}; 110 | clientUtil.saveStateToUrl(this.metaInfo, newState); 111 | } 112 | 113 | componentDidMount() { 114 | this.requestHistory(this.state.pageIndex); 115 | } 116 | 117 | requestHistory(pageIndex) { 118 | Sender.post("/api/getHistoryPageData", {page: pageIndex-1}, res => { 119 | let { rows, count } = res.json; 120 | let history = rows || []; 121 | history.forEach(e => { 122 | e.time = parseInt(e.time); 123 | }) 124 | this.setStateAndSetHash({history, res, totalCount: count}) 125 | }); 126 | } 127 | 128 | 129 | handlePageChange(index) { 130 | this.setStateAndSetHash({ 131 | pageIndex: index, 132 | history: [] 133 | }); 134 | this.requestHistory(index); 135 | } 136 | 137 | renderPagination() { 138 | return (
139 | this.pagination = ref} 140 | currentPage={this.state.pageIndex} 141 | itemPerPage={200} 142 | totalItemNum={this.state.totalCount} 143 | onChange={this.handlePageChange.bind(this)} 144 | // onExtraButtonClick={this.toggleItemNum.bind(this)} 145 | // linkFunc={clientUtil.linkFunc} 146 | />
); 147 | } 148 | 149 | render() { 150 | document.title = "History" 151 | const {res} = this.state; 152 | if(!res){ 153 | return (); 154 | }else if(res.isFailed()){ 155 | return ; 156 | }else{ 157 | return ( 158 |
159 | {this.renderPagination()} 160 | {renderHistory(this.state.history)} 161 |
) 162 | } 163 | } 164 | } 165 | 166 | HistoryPage.contextType = GlobalContext; -------------------------------------------------------------------------------- /src/client/HomePage.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import React, { Component, useState, useEffect } from 'react'; 3 | // import _ from "underscore"; 4 | import './style/HomePage.scss'; 5 | import Sender from './Sender'; 6 | import { Link } from 'react-router-dom'; 7 | 8 | import ErrorPage from './ErrorPage'; 9 | import CenterSpinner from './subcomponent/CenterSpinner'; 10 | import ItemsContainer from './subcomponent/ItemsContainer'; 11 | import ThumbnailPopup from './subcomponent/ThumbnailPopup'; 12 | 13 | const util = require("@common/util"); 14 | const classNames = require('classnames'); 15 | const clientUtil = require("./clientUtil"); 16 | 17 | function getOneLineListItem(icon, fileName, filePath) { 18 | return ( 19 | 20 |
  • 21 | {icon} 22 | {fileName} 23 |
  • 24 |
    25 | ); 26 | } 27 | 28 | function getPathItems(items){ 29 | const result = (items||[]).map(item => { 30 | const toUrl = clientUtil.getExplorerLink(item); 31 | const text = item; 32 | const result = getOneLineListItem(, text, item); 33 | return {result}; 34 | }) 35 | return result; 36 | } 37 | 38 | const HomePage = () => { 39 | const [res, setRes] = useState(null) 40 | 41 | useEffect(() => { 42 | async function fetchData() { 43 | const res = await Sender.getWithPromise("/api/homePagePath"); 44 | if (!res.isFailed()) { 45 | setRes(res); 46 | } 47 | } 48 | fetchData(); 49 | }, []); 50 | 51 | document.title = "ShiguReader"; 52 | 53 | if(!res){ 54 | return (); 55 | }else if(res.isFailed()){ 56 | return ; 57 | }else { 58 | let {dirs, hdd_list, quickAccess, recentAccess } = res.json; 59 | const dirItems = getPathItems(dirs); 60 | const hddItems = getPathItems(hdd_list); 61 | const quickAccessItems = getPathItems(quickAccess); 62 | const recentAccessItems = getPathItems(recentAccess); 63 | 64 | return ( 65 |
    66 | 67 | {dirItems &&
    Scanned And Under Watch
    } 68 | 69 | 70 | {quickAccessItems &&
    Quick Access
    } 71 | 72 | 73 | {recentAccessItems &&
    Recent Access
    } 74 | 75 | 76 | {hddItems &&
    Hard Drivers
    } 77 | 78 |
    ) 79 | } 80 | } 81 | 82 | export default HomePage; -------------------------------------------------------------------------------- /src/client/LoadingImage.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import Sender from './Sender'; 3 | const classNames = require('classnames'); 4 | import "./style/LoadingImage.scss" 5 | const clientUtil = require("./clientUtil"); 6 | const util = require("@common/util"); 7 | const _ = require("underscore"); 8 | const VisibilitySensor = require('react-visibility-sensor').default; 9 | 10 | // 同时初始的url不好用,才会去api request 11 | // 三个场景: 普通zip,folder,tag 12 | export default class LoadingImage extends Component { 13 | constructor(props) { 14 | super(props); 15 | this.state = { 16 | url: props.url, 17 | isVisible: false 18 | }; 19 | console.assert(["tag", "author", "folder", "zip"].includes(props.mode)) 20 | } 21 | 22 | componentDidUpdate(prevProps) { 23 | if (this.props.url && prevProps.url !== this.props.url) { 24 | this.setState({ 25 | url: this.props.url 26 | }); 27 | } 28 | } 29 | 30 | onChange(isVisible) { 31 | // only use to turn on 32 | if (!isVisible) { 33 | return; 34 | } 35 | 36 | this.setState({ 37 | isVisible 38 | }) 39 | 40 | if (this.shouldAskUrl()) { 41 | this.requestThumbnail() 42 | } 43 | } 44 | 45 | shouldAskUrl() { 46 | if (this.state.url === "NO_THUMBNAIL_AVAILABLE" || this.props.url === "NO_THUMBNAIL_AVAILABLE") { 47 | return false; 48 | } else { 49 | return !this.props.url; 50 | } 51 | } 52 | 53 | componentDidMount() { 54 | // if (this.shouldAskUrl()) { 55 | // this.requestThumbnail() 56 | // } 57 | } 58 | 59 | componentWillUnmount() { 60 | this.isUnmounted = true; 61 | } 62 | 63 | isAuthorTagMode() { 64 | const { mode } = this.props; 65 | return mode === "author" || mode === "tag"; 66 | } 67 | 68 | async requestThumbnail() { 69 | const { mode, fileName } = this.props; 70 | let api; 71 | if (this.isAuthorTagMode()) { 72 | api = "/api/getTagThumbnail" 73 | } else if (mode === "folder") { 74 | // TODO backend need to support? 75 | } else { 76 | api = '/api/getZipThumbnail' 77 | } 78 | 79 | const body = {}; 80 | if (this.isAuthorTagMode()) { 81 | body[mode] = fileName; 82 | } else { 83 | body["filePath"] = fileName; 84 | } 85 | 86 | if (!api) { 87 | return; 88 | } 89 | 90 | const res = await Sender.postWithPromise(api, body); 91 | if (!this.isUnmounted) { 92 | if (res.isFailed()) { 93 | this.setState({ url: "NO_THUMBNAIL_AVAILABLE" }) 94 | } else { 95 | const url = clientUtil.getFileUrl(res.json.url); 96 | this.setState({ url }); 97 | } 98 | } 99 | } 100 | 101 | 102 | 103 | getImageUrl() { 104 | if(this.state.url && this.state.url !== "NO_THUMBNAIL_AVAILABLE"){ 105 | return this.state.url; 106 | } 107 | } 108 | 109 | render() { 110 | const { className, style, fileName, url, title, 111 | mode, musicNum, ...others } = this.props; 112 | 113 | const _url = this.getImageUrl(); 114 | 115 | 116 | let cn = classNames("loading-image", className, { 117 | "empty-block": !_url 118 | }); 119 | 120 | 121 | 122 | if (!_url) { 123 | let temp = ""; 124 | if (musicNum > 0) { 125 | temp = "fas fa-music" 126 | } else if (mode === "zip") { 127 | temp = "fas fa-file-archive" 128 | } else if (mode == "folder") { 129 | temp = "far fa-folder" 130 | }else if (mode == "tag") { 131 | temp = "fas fa-tags" 132 | }else if (mode == "author") { 133 | temp = "fas fa-pen" 134 | } 135 | cn += " " + temp; 136 | } 137 | 138 | 139 | let content; 140 | if (_url) { 141 | content = ( { this.dom = e && e.node }} 142 | className={className} src={_url} title={title || fileName} 143 | loading="lazy" 144 | {...others} />); 145 | } else { 146 | content = (
    ); 147 | } 148 | 149 | return ( 150 | 151 | {content} 152 | 153 | ) 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/client/LoginPage.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | 3 | // import { Switch, Route, Link, Redirect } from 'react-router-dom'; 4 | // import screenfull from 'screenfull'; 5 | // const clientUtil = require("./clientUtil"); 6 | // const { getSearchInputText } = clientUtil; 7 | import ReactDOM from 'react-dom'; 8 | // import Cookie from "js-cookie"; 9 | // import 'react-toastify/dist/ReactToastify.css'; 10 | // import { GlobalContext } from './globalContext' 11 | import Sender from './Sender'; 12 | 13 | 14 | // http://localhost:3000/ 15 | class LoginPage extends Component { 16 | state = {}; 17 | 18 | getPasswordInput() { 19 | const pathInput = ReactDOM.findDOMNode(this.passwordInputRef); 20 | const text = (pathInput && pathInput.value) || ""; 21 | return text; 22 | } 23 | 24 | async setPasswordCookie() { 25 | const text = this.getPasswordInput(); 26 | const res = await Sender.postWithPromise('/api/login', {"password":text}); 27 | if (!res.isFailed()) { 28 | //跳转回login之前的页面 29 | const prevUrl = sessionStorage.getItem('url_before_login') || "/"; 30 | if(prevUrl == window.location.href){ 31 | location.reload(true); 32 | }else{ 33 | window.location.href = prevUrl; 34 | } 35 | }else{ 36 | this.setState({errMessage: "Wrong Password"}); 37 | } 38 | } 39 | 40 | render() { 41 | let content = ( 42 |
    ShiguReader
    43 |
    44 | this.passwordInputRef = pathInput} 46 | onChange={()=> this.setState({errMessage: ""})} 47 | onKeyPress={e => { 48 | if (e.which === 13 || e.keyCode === 13) { 49 | //enter key 50 | this.setPasswordCookie(); 51 | e.preventDefault(); 52 | e.stopPropagation(); 53 | } 54 | }} 55 | /> 56 | 57 |
    {this.state.errMessage}
    58 | 61 |
    62 |
    ); 63 | 64 | return ( 65 |
    66 |
    67 | {content} 68 |
    69 |
    70 | ) 71 | } 72 | } 73 | 74 | 75 | export default LoginPage; -------------------------------------------------------------------------------- /src/client/MusicPlayer.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import './style/MusicPlayer.scss'; 4 | const classNames = require('classnames'); 5 | const util = require("@common/util"); 6 | const clientUtil = require("./clientUtil"); 7 | const { getDir, getBaseName, getFileUrl } = clientUtil; 8 | 9 | export default class MusicPlayer extends Component { 10 | constructor(prop) { 11 | super(prop); 12 | this.state = { 13 | index: 0 14 | }; 15 | } 16 | 17 | bindEvent() { 18 | const that = this; 19 | this.refs.audio.addEventListener('ended', () => { 20 | let next = that.state.index + 1; 21 | if (next === that.props.audioFiles.length) { 22 | next = 0; 23 | } 24 | that.handleIndexChange(next); 25 | }); 26 | } 27 | 28 | componentDidMount() { 29 | this.bindEvent(); 30 | } 31 | 32 | componentDidUpdate() { 33 | 34 | } 35 | 36 | handleIndexChange(index) { 37 | this.setState({ index }, () => { 38 | this.refs.audio.pause(); 39 | this.refs.audio.load(); 40 | this.refs.audio.play(); 41 | }); 42 | } 43 | 44 | render() { 45 | const { audioFiles, className } = this.props; 46 | const { index } = this.state; 47 | const audioItems = audioFiles.map((e, ii) => { 48 | const cn = classNames("aji-music-player-item", { 49 | "aji-music-player-active fas fa-volume-up": ii === index 50 | }) 51 | return (
    {getBaseName(e)}
    ) 52 | }); 53 | 54 | if (audioFiles.length === 0) { 55 | return (
    NO AUDIO FILES
    ); 56 | } 57 | 58 | //https://stackoverflow.com/questions/43577182/react-js-audio-src-is-updating-on-setstate-but-the-audio-playing-doesnt-chang 59 | 60 | const totalCn = classNames("aji-music-player", className); 61 | 62 | return ( 63 |
    64 |
    65 | {audioItems} 66 |
    67 | 70 |
    71 | ) 72 | } 73 | } 74 | 75 | MusicPlayer.propTypes = { 76 | audioFiles: PropTypes.arrayOf(PropTypes.string) 77 | }; 78 | -------------------------------------------------------------------------------- /src/client/OneBookOverview.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import _ from 'underscore'; 3 | const classNames = require('classnames'); 4 | 5 | import { Link } from 'react-router-dom'; 6 | import Sender from './Sender'; 7 | import './style/OneBook.scss'; 8 | import ErrorPage from './ErrorPage'; 9 | import CenterSpinner from './subcomponent/CenterSpinner'; 10 | import FileNameDiv from './subcomponent/FileNameDiv'; 11 | // import ReactDOM from 'react-dom'; 12 | 13 | const VisibilitySensor = require('react-visibility-sensor').default; 14 | const util = require("@common/util"); 15 | const queryString = require('query-string'); 16 | 17 | const clientUtil = require("./clientUtil"); 18 | const { getDir, getBaseName, isMobile, getFileUrl, sortFileNames } = clientUtil; 19 | 20 | class SmartImage extends Component { 21 | constructor(props) { 22 | super(props); 23 | this.state = { 24 | isVisible: false 25 | }; 26 | } 27 | 28 | onChange(isVisible) { 29 | if (this.state.isVisible && !isVisible) { 30 | return; 31 | } 32 | 33 | this.setState({ 34 | isVisible 35 | }) 36 | } 37 | 38 | render() { 39 | const { url, index, filePath, dirPath } = this.props; 40 | const { isVisible } = this.state; 41 | 42 | let content; 43 | if (isVisible) { 44 | const tooltip = `page: ${index} \nfilename: ${getBaseName(filePath)}`; 45 | content = () 49 | } else { 50 | content =
    51 | } 52 | 53 | const toUrl = clientUtil.getOneBookLink(dirPath, index); 54 | 55 | return ( 56 | 57 |
    58 | 59 | {content} 60 | 61 |
    62 |
    63 | ) 64 | } 65 | } 66 | 67 | 68 | export default class OneBookOverview extends Component { 69 | constructor(props) { 70 | super(props); 71 | this.state = { 72 | imageFiles: [], 73 | musicFiles: [] 74 | }; 75 | } 76 | 77 | 78 | getTextFromQuery(props) { 79 | const _props = props || this.props; 80 | return queryString.parse(_props.location.search)["p"] || ""; 81 | } 82 | 83 | componentDidMount() { 84 | this.sendExtract(); 85 | } 86 | 87 | isImgFolder() { 88 | return !util.isCompress(this.getTextFromQuery()) 89 | } 90 | 91 | async sendExtract() { 92 | const fp = this.getTextFromQuery(); 93 | const api = this.isImgFolder() ? "/api/listImageFolderContent" : "/api/extract"; 94 | const res = await Sender.postWithPromise(api, { filePath: fp, startIndex: 0 }) 95 | this.handleRes(res); 96 | } 97 | 98 | askRerender(){ 99 | this.setState({ 100 | rerenderTick: !this.state.rerenderTick 101 | }) 102 | } 103 | 104 | handleRes(res) { 105 | this.res = res; 106 | if (!res.isFailed()) { 107 | let { zipInfo, path, stat, imageFiles, musicFiles } = res.json; 108 | imageFiles = imageFiles || []; 109 | musicFiles = musicFiles || []; 110 | 111 | sortFileNames(imageFiles); 112 | sortFileNames(musicFiles); 113 | 114 | this.setState({ imageFiles, musicFiles, path, fileStat: stat, zipInfo }); 115 | } else { 116 | this.askRerender(); 117 | } 118 | } 119 | 120 | isFailedLoading() { 121 | return this.res && this.res.isFailed(); 122 | } 123 | 124 | renderImageGrid() { 125 | const { imageFiles } = this.state; 126 | if (!this.hasImage()) { 127 | return; 128 | } 129 | 130 | const fp = this.getTextFromQuery(); 131 | const images = imageFiles 132 | .map((e, ii) => { 133 | let url = clientUtil.getFileUrl(e, true); 134 | return (); 135 | }) 136 | 137 | return images; 138 | } 139 | 140 | renderPath() { 141 | if (!this.state.path) { 142 | return; 143 | } 144 | 145 | const parentPath = getDir(this.state.path); 146 | const toUrl = clientUtil.getExplorerLink(parentPath); 147 | 148 | return ( 149 |
    150 | {parentPath} 151 |
    ); 152 | } 153 | 154 | hasImage() { 155 | return this.state.imageFiles.length > 0; 156 | } 157 | 158 | render() { 159 | if (this.isFailedLoading()) { 160 | const fp = this.getTextFromQuery(); 161 | return ; 162 | } 163 | 164 | const { imageFiles, index, musicFiles } = this.state; 165 | const bookTitle = (
    166 | 167 | {this.renderPath()} 168 |
    ); 169 | 170 | if (_.isEmpty(imageFiles) && _.isEmpty(musicFiles)) { 171 | if (this.res && !this.res.isFailed()) { 172 | return (

    173 |
    174 |
    No image or music file
    175 | {bookTitle} 176 | {this.renderTags()} 177 | {this.renderToolbar()} 178 |
    179 |

    ); 180 | } else { 181 | return (); 182 | } 183 | } 184 | 185 | if (this.state.path) { 186 | document.title = getBaseName(this.state.path); 187 | } 188 | 189 | return ( 190 |
    191 | {bookTitle} 192 |
    193 | {this.renderImageGrid()} 194 |
    195 |
    196 | ); 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /src/client/OneBookWaterfall.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import _ from 'underscore'; 3 | const nameParser = require('@name-parser'); 4 | const classNames = require('classnames'); 5 | import ReactDOM from 'react-dom'; 6 | 7 | import { Link } from 'react-router-dom'; 8 | import Sender from './Sender'; 9 | import './style/OneBook.scss'; 10 | import ErrorPage from './ErrorPage'; 11 | import CenterSpinner from './subcomponent/CenterSpinner'; 12 | import FileNameDiv from './subcomponent/FileNameDiv'; 13 | 14 | const util = require("@common/util"); 15 | const queryString = require('query-string'); 16 | import screenfull from 'screenfull'; 17 | 18 | const clientUtil = require("./clientUtil"); 19 | const { getDir, getBaseName, isMobile, getFileUrl, sortFileNames } = clientUtil; 20 | const VisibilitySensor = require('react-visibility-sensor').default; 21 | 22 | class SmartImage extends Component { 23 | constructor(props) { 24 | super(props); 25 | this.state = { 26 | isVisible: props.visible || false 27 | }; 28 | } 29 | 30 | onChange(isVisible) { 31 | if (this.state.isVisible && !isVisible) { 32 | return; 33 | } 34 | 35 | this.setState({ 36 | isVisible 37 | }) 38 | } 39 | 40 | render() { 41 | const { url, index, style } = this.props; 42 | const { isVisible } = this.state; 43 | 44 | let content = () 50 | 51 | return ( 52 | 53 | {content} 54 | 55 | ) 56 | } 57 | } 58 | 59 | //maybe combine with renderOneBookOverview into one file 60 | 61 | 62 | export default class OneBookWaterfall extends Component { 63 | constructor(props) { 64 | super(props); 65 | this.state = { 66 | imageFiles: [], 67 | musicFiles: [], 68 | }; 69 | } 70 | 71 | getTextFromQuery(props) { 72 | const _props = props || this.props; 73 | return queryString.parse(_props.location.search)["p"] || ""; 74 | } 75 | 76 | askRerender(){ 77 | this.setState({ 78 | rerenderTick: !this.state.rerenderTick 79 | }) 80 | } 81 | 82 | componentDidMount() { 83 | this.sendExtract(); 84 | 85 | if (!isMobile()) { 86 | screenfull.onchange(() => { 87 | this.askRerender(); 88 | }); 89 | } 90 | } 91 | 92 | isImgFolder() { 93 | return !util.isCompress(this.getTextFromQuery()) 94 | } 95 | 96 | async sendExtract() { 97 | const fp = this.getTextFromQuery(); 98 | const api = this.isImgFolder() ? "/api/listImageFolderContent" : "/api/extract"; 99 | const res = await Sender.postWithPromise(api, { filePath: fp, startIndex: 0 }); 100 | this.handleRes(res); 101 | } 102 | 103 | handleRes(res) { 104 | this.res = res; 105 | if (!res.isFailed()) { 106 | let { zipInfo, path, stat, imageFiles, musicFiles } = res.json; 107 | imageFiles = imageFiles || []; 108 | musicFiles = musicFiles || []; 109 | 110 | //imageFiles name can be 001.jpg, 002.jpg, 011.jpg, 012.jpg 111 | //or 1.jpg, 2.jpg 3.jpg 1.jpg 112 | //todo: the sort is wrong for imgFolder 113 | sortFileNames(imageFiles); 114 | sortFileNames(musicFiles); 115 | 116 | this.setState({ imageFiles, musicFiles, path, fileStat: stat, zipInfo }); 117 | } else { 118 | this.askRerender(); 119 | } 120 | } 121 | 122 | isFailedLoading() { 123 | return this.res && this.res.isFailed(); 124 | } 125 | 126 | onError() { 127 | //todo 128 | //maybe display a center spin 129 | } 130 | 131 | getMaxHeight() { 132 | let height = clientUtil.getWindowsHeight(); 133 | height -= 10; 134 | return height; 135 | } 136 | 137 | renderImage() { 138 | const { imageFiles } = this.state; 139 | if (!this.hasImage()) { 140 | return; 141 | } 142 | 143 | const maxHeight = this.getMaxHeight(); 144 | 145 | let images = imageFiles.map((file, index) => { 146 | return (
    147 | 153 |
    ); 154 | }); 155 | return (
    156 | {images} 157 |
    ); 158 | } 159 | 160 | renderPath() { 161 | if (!this.state.path) { 162 | return; 163 | } 164 | 165 | const parentPath = getDir(this.state.path); 166 | const toUrl = clientUtil.getExplorerLink(parentPath); 167 | 168 | return ( 169 |
    170 | {parentPath} 171 |
    ); 172 | } 173 | 174 | hasImage() { 175 | return this.state.imageFiles.length > 0; 176 | } 177 | 178 | render() { 179 | if (this.isFailedLoading()) { 180 | const fp = this.getTextFromQuery(); 181 | return ; 182 | } 183 | 184 | const { imageFiles, musicFiles } = this.state; 185 | const bookTitle = (
    186 | 187 | {this.renderPath()} 188 |
    ); 189 | 190 | if (_.isEmpty(imageFiles) && _.isEmpty(musicFiles)) { 191 | if (this.res && !this.res.isFailed()) { 192 | return (

    193 |
    194 |
    No image or music file
    195 | {bookTitle} 196 | {this.renderTags()} 197 | {this.renderToolbar()} 198 |
    199 |

    ); 200 | } else { 201 | return (); 202 | } 203 | } 204 | 205 | if (this.state.path) { 206 | document.title = getBaseName(this.state.path); 207 | } 208 | 209 | const wraperCn = classNames("one-book-wrapper", { 210 | "full-screen": screenfull.isFullscreen, 211 | }); 212 | 213 | const content = (
    this.wrapperRef = wrapper}> 214 | {this.renderImage()} 215 |
    ); 216 | 217 | 218 | return ( 219 |
    220 | {bookTitle} 221 | {content} 222 |
    223 | ); 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /src/client/Sender.js: -------------------------------------------------------------------------------- 1 | import 'whatwg-fetch'; 2 | 3 | const Sender = {}; 4 | 5 | function attachFunc(res) { 6 | res.isFailed = () => { 7 | if (res.json && res.json.failed) { 8 | return true; 9 | } 10 | return !(res.status === 200 || res.status === 304); 11 | } 12 | } 13 | 14 | 15 | const getWithPromise = Sender.getWithPromise = async function (api) { 16 | // Request with GET/HEAD method cannot have body. 17 | const res = await fetch(api, { 18 | method: 'GET', 19 | // headers: { 20 | // Accept: 'application/json', 21 | // 'Content-Type': 'application/json', 22 | // }, 23 | }); 24 | 25 | try { 26 | //e.g when 504, there is no json, will throw a error 27 | res.json = await res.json(); 28 | } catch (e) { 29 | res.json = { failed: true } 30 | } 31 | 32 | attachFunc(res); 33 | return res; 34 | }; 35 | 36 | const postWithPromise = Sender.postWithPromise = async function (api, body) { 37 | body = body||{}; 38 | const res = await fetch(api, { 39 | method: 'POST', 40 | headers: { 41 | Accept: 'application/json', 42 | 'Content-Type': 'application/json', 43 | }, 44 | body: JSON.stringify(body) 45 | }); 46 | 47 | try { 48 | //e.g when 504, there is no json, will throw a error 49 | res.json = await res.json(); 50 | } catch (e) { 51 | res.json = { failed: true } 52 | } 53 | 54 | attachFunc(res); 55 | return res; 56 | }; 57 | 58 | 59 | //server will return json 60 | Sender.post = async function (api, body, callback) { 61 | if (!callback) { 62 | throw "no callback function" 63 | } 64 | const res = await postWithPromise(api, body); 65 | callback(res); 66 | }; 67 | 68 | //server will return json 69 | Sender.get = async function (api, callback) { 70 | if (!callback) { 71 | throw "no callback function" 72 | } 73 | const res = await getWithPromise(api); 74 | callback(res); 75 | }; 76 | 77 | export default Sender; 78 | -------------------------------------------------------------------------------- /src/client/VideoPlayer.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import _ from 'underscore'; 3 | import './style/VideoPlayer.scss'; 4 | import FileNameDiv from './subcomponent/FileNameDiv'; 5 | import FileChangeToolbar from './subcomponent/FileChangeToolbar'; 6 | const clientUtil = require("./clientUtil"); 7 | const { getDir, getBaseName, filesizeUitl } = clientUtil; 8 | import { Link } from 'react-router-dom'; 9 | import Sender from './Sender'; 10 | const queryString = require('query-string'); 11 | const Cookie = require("js-cookie"); 12 | import DPlayer from "react-dplayer"; 13 | import HistorySection from './subcomponent/HistorySection'; 14 | 15 | 16 | export default class VideoPlayer extends Component { 17 | constructor(props) { 18 | super(props); 19 | this.state = { 20 | }; 21 | } 22 | 23 | componentDidMount() { 24 | const filePath = this.getTextFromQuery(); 25 | if (filePath) { 26 | Sender.post("/api/singleFileInfo", { filePath }, res => { 27 | if (!res.isFailed()) { 28 | const { stat, mecab_tokens } = res.json; 29 | this.setState({ stat, mecab_tokens }) 30 | } else { 31 | this.res = res; 32 | this.onError(); 33 | } 34 | }); 35 | } 36 | 37 | 38 | document.addEventListener('keydown', this.handleKeyDown.bind(this)); 39 | } 40 | 41 | componentWillUnmount() { 42 | document.removeEventListener("keydown", this.handleKeyDown.bind(this)); 43 | } 44 | 45 | handleKeyDown(event) { 46 | const key = event.key.toLowerCase(); 47 | if (key === "+" || key === "=") { 48 | this.changeVideoSize(1.1) 49 | } else if (key === "-") { 50 | this.changeVideoSize(0.9) 51 | } 52 | } 53 | 54 | 55 | getTextFromQuery(props) { 56 | //may allow tag author in future 57 | const _props = props || this.props; 58 | return queryString.parse(_props.location.search)["p"] || ""; 59 | } 60 | 61 | onError(e) { 62 | // console.log(); 63 | this.setState({ 64 | hasError: true 65 | }) 66 | } 67 | 68 | renderTag() { 69 | const filePath = this.getTextFromQuery(); 70 | const dirName = getBaseName(getDir(filePath)); 71 | // let tags = namePicker.pick(dirName) || []; 72 | let tags = []; 73 | tags = _.uniq(tags); 74 | 75 | if (tags.length > 0) { 76 | const tagDoms = tags.map(tag => { 77 | const url = clientUtil.getSearhLink(tag); 78 | return ({tag}) 79 | }); 80 | 81 | return (
    {tagDoms}
    ); 82 | } 83 | } 84 | 85 | renderPath() { 86 | const filePath = this.getTextFromQuery(); 87 | const parentPath = getDir(filePath); 88 | const toUrl = clientUtil.getExplorerLink(parentPath); 89 | 90 | return ( 91 |
    92 | {parentPath} 93 |
    ); 94 | } 95 | 96 | onLoad(dp) { 97 | this.dp = dp; 98 | } 99 | 100 | onTimeupdate(){ 101 | const filePath = this.getTextFromQuery(); 102 | try{ 103 | Cookie.set(filePath, this.dp.video.currentTime); 104 | }catch(e){ 105 | console.error(e); 106 | } 107 | } 108 | 109 | adjustHW(){ 110 | const videoRef = this.dp.video; 111 | const hh = videoRef.videoHeight; // 返回视频的内在高度 112 | const ww = videoRef.videoWidth; // 返回视频的内在宽度 113 | const docWidth = window.innerWidth || document.documentElement.clientWidth || document.body.clientWidth; 114 | const docHeight = window.innerHeight || document.documentElement.clientHeight || document.body.clientHeight; 115 | const aspectRatio = ww / hh; // 计算视频的宽高比 116 | 117 | // 计算基于文档宽度的目标视频宽度 118 | let scale; 119 | if(ww>hh){ 120 | scale = clientUtil.isMobile() ? 0.9 : 0.8; 121 | }else { 122 | scale = clientUtil.isMobile() ? 0.95 : 0.95; 123 | } 124 | let targetWidth = docWidth * scale; 125 | 126 | // 计算基于目标宽度的视频高度 127 | let targetHeight = targetWidth / aspectRatio; 128 | 129 | // 如果计算出的高度超过了文档的高度,则重新调整宽度和高度 130 | if (targetHeight > docHeight * scale) { 131 | targetHeight = docHeight * scale; 132 | targetWidth = targetHeight * aspectRatio; 133 | } 134 | 135 | // 应用样式调整 136 | videoRef.style.width = targetWidth + "px"; 137 | // videoRef.style.height = targetHeight + "px"; 138 | } 139 | 140 | changeVideoSize(ratio) { 141 | const videoRef = this.dp.video; 142 | if (!videoRef) { 143 | console.error('No video element found!'); 144 | return; 145 | } 146 | 147 | const currentWidth = videoRef.offsetWidth; 148 | const newWidth = currentWidth * ratio; 149 | videoRef.style.width = `${newWidth}px`; 150 | } 151 | 152 | 153 | onLoadedmetadata() { 154 | this.adjustHW(); 155 | 156 | const filePath = this.getTextFromQuery(); 157 | const previous = parseFloat(Cookie.get(filePath)); 158 | if(previous > 1 ){ 159 | this.dp.seek(previous) 160 | } 161 | 162 | const that = this; 163 | this.dp.on('fullscreen', function () { 164 | console.log('player fullscreen'); 165 | const videoRef = that.dp.video; 166 | videoRef.style.height = ""; 167 | videoRef.style.width = ""; 168 | }); 169 | 170 | this.dp.on('fullscreen_cancel', function () { 171 | console.log('player fullscreen cancel'); 172 | that.adjustHW(); 173 | }); 174 | } 175 | 176 | render() { 177 | const filePath = this.getTextFromQuery(); 178 | const url = clientUtil.getFileUrl(this.getTextFromQuery()); 179 | const fileName = getBaseName(filePath); 180 | document.title = fileName; 181 | const { hasError, stat, mecab_tokens } = this.state; 182 | //use bootstrap classname util 183 | const videoTitle = filePath && (
    184 |
    185 | {this.renderPath()} 186 |
    ); 187 | 188 | const fileSize = stat && filesizeUitl(stat.size); 189 | const mTime = stat && clientUtil.dateFormat_ymd(new Date(stat.mtimeMs)); 190 | 191 | const videoFileInfo = (stat &&
    192 | {fileSize} 193 | {mTime} 194 |
    ); 195 | 196 | let content; 197 | if (hasError || !filePath) { 198 | const infoStr = (!filePath || (this.res && this.res.status === 404)) ? "Video Not Found" : "Unable to Play Video"; 199 | content = (
    {infoStr}
    ); 200 | } else { 201 | content = ( 202 |
    203 | { 212 | // console.log(player); 213 | this.changeVideoSize(1.1) 214 | }, 215 | }, 216 | { 217 | text: 'Smaller -', 218 | click: (player) => { 219 | this.changeVideoSize(0.9) 220 | }, 221 | }, 222 | { 223 | text: '⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯', 224 | }, 225 | ] 226 | }} 227 | onLoadedmetadata={this.onLoadedmetadata.bind(this)} 228 | onLoad={this.onLoad.bind(this)} 229 | onError={this.onError.bind(this)} 230 | onTimeupdate={this.onTimeupdate.bind(this)} 231 | /> 232 |
    233 | ); 234 | } 235 | 236 | return (
    237 | {content} 238 |
    239 | {videoTitle} 240 | {videoFileInfo} 241 | 242 | {this.renderTag()} 243 | 244 |
    245 |
    246 | ); 247 | } 248 | } 249 | 250 | -------------------------------------------------------------------------------- /src/client/globalContext.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const GlobalContext = React.createContext({}); 4 | 5 | GlobalContext.displayName = "shigureader_global_context"; 6 | -------------------------------------------------------------------------------- /src/client/images/text.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hjyssg/ShiguReader/64a614d0e2b1e50df010dc95397d435bc729f084/src/client/images/text.png -------------------------------------------------------------------------------- /src/client/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { createRoot } from 'react-dom/client'; 4 | import App from './App'; 5 | import { BrowserRouter } from 'react-router-dom'; 6 | import './style/bootstrap.min.css'; 7 | 8 | createRoot(document.getElementById('root')).render( 9 | 10 | ); -------------------------------------------------------------------------------- /src/client/style/Accordion.scss: -------------------------------------------------------------------------------- 1 | @import "vars.scss"; 2 | 3 | .aji-accordion { 4 | .aji-accordion-header { 5 | cursor: pointer; 6 | } 7 | margin-bottom: 10px; 8 | } -------------------------------------------------------------------------------- /src/client/style/AdminPage.scss: -------------------------------------------------------------------------------- 1 | @import "vars.scss"; 2 | 3 | .admin-container { 4 | position: relative; 5 | 6 | @include common_sub_container; 7 | 8 | .admin-section { 9 | font-size: 13px; 10 | 11 | .admin-section-title { 12 | font-weight: 600; 13 | } 14 | 15 | .ip-address-title { 16 | cursor: pointer; 17 | margin-bottom: 12px; 18 | &:hover { 19 | color: #db892a; 20 | background: unset; 21 | } 22 | } 23 | 24 | 25 | 26 | .admin-section-text { 27 | font-size: 15px; 28 | font-weight: 600; 29 | } 30 | 31 | .admin-section-content { 32 | margin: 8px auto 10px 10px; 33 | clear: both; 34 | 35 | .admin-intput { 36 | display: block; 37 | margin-left: 2px; 38 | margin-bottom: 5px; 39 | width: 500px; 40 | 41 | @media screen and (max-width: 550px) { 42 | width: 300px; 43 | } 44 | } 45 | } 46 | 47 | margin-bottom: 20px; 48 | } 49 | 50 | .submit-button { 51 | cursor: pointer; 52 | display: flex; 53 | align-items: center; 54 | justify-content: center; 55 | width: 300px; 56 | background-color: $bacground_level_one; 57 | min-height: 26px; 58 | padding: 1px 5px 2px; 59 | margin: 0 2px; 60 | border-radius: 3px; 61 | font-size: 9pt; 62 | color: $font_color_white_one; 63 | margin-bottom: 10px; 64 | 65 | &:hover { 66 | @include hover_effect; 67 | } 68 | } 69 | 70 | .author-link { 71 | margin-top: 10px; 72 | margin-bottom: 10px; 73 | 74 | a { 75 | font-size: 1.2rem; 76 | color: $font_color_black_two; 77 | } 78 | } 79 | } -------------------------------------------------------------------------------- /src/client/style/App.scss: -------------------------------------------------------------------------------- 1 | @import "vars.scss"; 2 | 3 | #root { 4 | @media screen and (max-width: 400px) { 5 | min-width: 400px; 6 | } 7 | } 8 | 9 | body { 10 | text-align: center; 11 | margin: auto; 12 | color: $font_color_white_one; 13 | } 14 | 15 | a { 16 | color: $font_color_white_one; 17 | 18 | &:hover { 19 | color: unset; 20 | } 21 | } 22 | 23 | .location-title { 24 | color: $font_color_white_one; 25 | font-size: 1rem; 26 | font-weight: 700; 27 | margin-bottom: 10px; 28 | 29 | .exp-top-button { 30 | margin-left: 20px; 31 | } 32 | } 33 | 34 | 35 | .app-container { 36 | background-color: $bacground_level_one; 37 | min-height: 100vh; 38 | padding-bottom: 50px; 39 | 40 | .critical-error { 41 | color: $font_color_white_one; 42 | font-size: 25px; 43 | margin: 20px 20px; 44 | } 45 | 46 | .fab, 47 | .fas { 48 | color: $font_color_white_one; 49 | 50 | &::before { 51 | padding-right: 5px; 52 | } 53 | } 54 | 55 | .fa-download { 56 | color: $font_color_white_one; 57 | margin-left: 10px; 58 | 59 | &:hover { 60 | @include hover_effect; 61 | } 62 | } 63 | 64 | .app-top-topnav { 65 | overflow: hidden; 66 | display: flex; 67 | flex-wrap: wrap; 68 | justify-content: space-between; 69 | margin-bottom: 12px; 70 | 71 | 72 | .app-page-links { 73 | display: inline-flex; 74 | margin-right: 20px; 75 | 76 | a { 77 | display: flex; 78 | justify-content: center; 79 | align-items: center; 80 | text-align: center; 81 | padding: 12px 12px; 82 | text-decoration: none; 83 | font-size: 15px; 84 | 85 | @media screen and (min-width: 1000px) { 86 | &:hover { 87 | @include hover_effect; 88 | 89 | i { 90 | @include hover_effect; 91 | } 92 | } 93 | } 94 | } 95 | } 96 | 97 | .search-bar { 98 | display: inline-flex; 99 | padding-top: 8px; 100 | 101 | .search-input { 102 | padding: 6px; 103 | border: none; 104 | font-size: 17px; 105 | border-radius: 2px; 106 | width: 350px; 107 | } 108 | 109 | .filter-button, 110 | .search-button { 111 | cursor: pointer; 112 | border-radius: 0px; 113 | @include flex_center_display; 114 | background-color: #a7a7a7; 115 | padding-left: 5px; 116 | padding-right: 5px; 117 | margin-left: 1px; 118 | width: 50px; 119 | 120 | &:hover { 121 | @include hover_effect; 122 | } 123 | } 124 | 125 | .search-button { 126 | border-right: 1px $border-color-black solid; 127 | border-left: 1px $border-color-black solid; 128 | } 129 | } 130 | } 131 | } 132 | 133 | 134 | 135 | .login-page{ 136 | display: flex; 137 | justify-content: center; 138 | align-items: center; 139 | 140 | //https://stackoverflow.com/questions/31217268/center-div-on-the-middle-of-screen 141 | .log-section { 142 | background: white; 143 | padding: 20px 50px; 144 | box-shadow: 4px 4px 7px rgba(0, 0, 0, 0.59); 145 | border-radius: 3px; 146 | 147 | #login-input{ 148 | margin-top: 20px; 149 | margin-bottom: 20px; 150 | width: 100%; 151 | } 152 | 153 | #log-err { 154 | color: #e93b3b; 155 | margin-top: 10px; 156 | font-weight: bold; 157 | } 158 | 159 | .author-link { 160 | margin-top: 100px; 161 | 162 | .fab { 163 | color: unset; 164 | } 165 | } 166 | } 167 | } 168 | 169 | 170 | .aji-checkbox-container { 171 | display: flex; 172 | align-items: center; 173 | justify-content: space-around; 174 | 175 | .aji-checkbox { 176 | display: inline-flex; 177 | 178 | .inner { 179 | font-size: 8px; 180 | 181 | @media screen and (min-width: 800px) { 182 | font-size: 10px; 183 | } 184 | 185 | @media screen and (min-width: 1200px) { 186 | font-size: 12px; 187 | } 188 | 189 | @media screen and (min-width: 1400px) { 190 | font-size: 14px; 191 | } 192 | } 193 | 194 | // &:not(:first-child){ 195 | // margin-left: 10px; 196 | // } 197 | 198 | // @media screen and (min-width: 1200px) { 199 | // &:not(:first-child){ 200 | // margin-left: 15px; 201 | // } 202 | // } 203 | } 204 | } 205 | 206 | .history-section { 207 | // margin-top: 20px; 208 | color: $font_color_white_one; 209 | 210 | .history-ellipsis { 211 | cursor: pointer; 212 | font-size: 1.5rem; 213 | &:hover { 214 | @include third_hover_effect; 215 | } 216 | } 217 | 218 | .history-section-content { 219 | display: flex; 220 | flex-direction: column; 221 | justify-content: center; 222 | align-items: center; 223 | } 224 | } 225 | 226 | .vertical-gap-container { 227 | display: flex; 228 | flex-direction: column; 229 | gap: 8px; 230 | align-items: center; 231 | } 232 | 233 | .tool-bar-row { 234 | &.only-one-row { 235 | gap: 8px; 236 | margin-top: unset; 237 | margin-bottom: unset; 238 | 239 | .fas, 240 | .fa { 241 | font-size: 16px; 242 | } 243 | } 244 | } -------------------------------------------------------------------------------- /src/client/style/BigColumnButton.scss: -------------------------------------------------------------------------------- 1 | @import "vars.scss"; 2 | 3 | $zoom-width: 10%; 4 | 5 | .big-column-button { 6 | 7 | background-color: transparent; 8 | 9 | &.next { 10 | right: 0; 11 | } 12 | 13 | &.prev { 14 | left: 0; 15 | } 16 | 17 | position: fixed; 18 | top: 0; 19 | width: $zoom-width; 20 | height: 100%; 21 | z-index: 10; 22 | 23 | @include flex_center_display; 24 | 25 | opacity: 0; 26 | animation: all 0.5s; 27 | 28 | .fas { 29 | font-weight: 600; 30 | font-size: 6rem; 31 | color: black; 32 | 33 | &:active { 34 | color: slategray; 35 | } 36 | } 37 | 38 | &:hover { 39 | opacity: 0.7; 40 | } 41 | } -------------------------------------------------------------------------------- /src/client/style/Breadcrumb.scss: -------------------------------------------------------------------------------- 1 | @import "vars.scss"; 2 | 3 | .explorer-breadcrumb { 4 | padding: 8px 16px; 5 | list-style: none; 6 | margin-bottom: 0px; 7 | 8 | .breadcrumb-item { 9 | display: inline-flex; 10 | align-items: center; 11 | font-size: 18px; 12 | 13 | @media screen and (max-width: 800px) { 14 | font-size: 15px; 15 | } 16 | 17 | color: $font_color_white_one; 18 | text-decoration: none; 19 | background-color: transparent; 20 | -webkit-text-decoration-skip: objects; 21 | } 22 | 23 | .breadcrumb-sep { 24 | padding-left: 4px; 25 | padding-right: 4px; 26 | color: #DDDDDD; 27 | font-weight: 600; 28 | display: inline-flex; 29 | align-items: center; 30 | } 31 | 32 | .breadcrumb-item { 33 | &:not(.current):hover { 34 | text-decoration: underline; 35 | } 36 | 37 | &.current{ 38 | cursor: pointer; 39 | &:hover { 40 | color: #db892a; 41 | background: unset; 42 | } 43 | } 44 | } 45 | } 46 | 47 | .top-button-gropus { 48 | color: $font_color_white_one; 49 | position: relative; 50 | margin-bottom: 1rem; 51 | padding-bottom: 6px; 52 | } 53 | 54 | .exp-top-button { 55 | cursor: pointer; 56 | color: $font_color_white_one; 57 | .fas { 58 | color: $font_color_white_one; 59 | } 60 | 61 | &.warning { 62 | color: #fd8585; 63 | .fas { 64 | color: #fd8585; 65 | } 66 | } 67 | 68 | &:hover { 69 | @include second_hover_effect; 70 | 71 | .fas { 72 | @include second_hover_effect; 73 | } 74 | } 75 | } 76 | 77 | .file-count-row { 78 | padding-bottom: 8px; 79 | 80 | .file-count { 81 | display: inline-flex; 82 | align-items: center; 83 | justify-content: flex-start; 84 | margin-right: 20px; 85 | color: $font_color_white_one; 86 | } 87 | } 88 | 89 | 90 | @media screen and (max-width: 800px) { 91 | .file-count, 92 | .exp-top-button { 93 | font-size: 12px; 94 | } 95 | } -------------------------------------------------------------------------------- /src/client/style/ChartPage.scss: -------------------------------------------------------------------------------- 1 | @import "vars.scss"; 2 | 3 | .chart-container { 4 | @include common_sub_container; 5 | 6 | .chart-radio-button-group { 7 | display: flex; 8 | margin-top: 10px; 9 | 10 | .radio-button { 11 | margin-right: 10px; 12 | } 13 | } 14 | 15 | .total-info { 16 | padding-top: 10px; 17 | } 18 | 19 | .individual-chart-container { 20 | border-top: 1px gray solid; 21 | padding-top: 10px; 22 | } 23 | 24 | .aji-table { 25 | 26 | td, 27 | th { 28 | padding-bottom: 2px; 29 | padding-top: 2px; 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /src/client/style/Checkbox.scss: -------------------------------------------------------------------------------- 1 | @import "vars.scss"; 2 | 3 | .aji-checkbox { 4 | color: $font_color_white_one; 5 | cursor: pointer; 6 | 7 | white-space: nowrap; 8 | overflow: hidden; 9 | text-overflow: ellipsis; 10 | 11 | display: flex; 12 | align-items: center; 13 | 14 | margin-bottom: 5px; 15 | 16 | input { 17 | // height: 1.2rem; 18 | // width: 1.2rem; 19 | margin-right: 5px; 20 | } 21 | 22 | .inner { 23 | font-size: 10px; 24 | } 25 | } -------------------------------------------------------------------------------- /src/client/style/Dropdown.scss: -------------------------------------------------------------------------------- 1 | @import "vars.scss"; 2 | 3 | .aji-dropdown { 4 | color: $font_color_white_one; 5 | cursor: pointer; 6 | position: relative; 7 | height: 16px; 8 | display: flex; 9 | justify-content: center; 10 | 11 | .aji-dropdown-menu { 12 | display: none; 13 | margin-top: 2px; 14 | z-index: 999; 15 | bottom: 25px; 16 | left: -10px; 17 | position: absolute; 18 | border: 1px solid $font_color_white_one; 19 | background: $bacground_level_three; 20 | 21 | .aji-dropdown-item { 22 | min-width: 30px; 23 | color: $font_color_white_one; 24 | 25 | &:not(:last-child) { 26 | border-bottom: 1px solid $font_color_white_one; 27 | } 28 | } 29 | } 30 | 31 | &.open { 32 | .aji-dropdown-menu { 33 | display: block; 34 | } 35 | } 36 | 37 | 38 | } 39 | 40 | //can be use independently 41 | .aji-dropdown-item { 42 | @include flex_center_display; 43 | cursor: pointer; 44 | 45 | &:hover { 46 | @include hover_effect; 47 | } 48 | } -------------------------------------------------------------------------------- /src/client/style/ErrorPage.scss: -------------------------------------------------------------------------------- 1 | @import "vars.scss"; 2 | 3 | .error-page-container { 4 | position: fixed; 5 | top: 30%; 6 | left: 20%; 7 | 8 | 9 | * { 10 | -webkit-box-sizing: border-box; 11 | box-sizing: border-box; 12 | } 13 | 14 | #notfound { 15 | position: relative; 16 | height: 100vh; 17 | } 18 | 19 | #notfound .notfound { 20 | position: absolute; 21 | left: 50%; 22 | top: 50%; 23 | -webkit-transform: translate(-50%, -50%); 24 | -ms-transform: translate(-50%, -50%); 25 | transform: translate(-50%, -50%); 26 | } 27 | 28 | .notfound { 29 | max-width: 767px; 30 | width: 100%; 31 | line-height: 1.4; 32 | padding: 0px 0px; 33 | } 34 | 35 | .notfound .notfound-404 { 36 | position: relative; 37 | height: 150px; 38 | line-height: 150px; 39 | margin-bottom: 25px; 40 | } 41 | 42 | .notfound .notfound-404 { 43 | font-family: 'Titillium Web', sans-serif; 44 | font-size: 150px; 45 | font-weight: 900; 46 | margin: 0px; 47 | text-transform: uppercase; 48 | background: url('../images/text.png'); 49 | -webkit-background-clip: text; 50 | -webkit-text-fill-color: transparent; 51 | background-size: cover; 52 | background-position: center; 53 | margin-left: -20px; 54 | } 55 | 56 | .notfound h2 { 57 | font-family: 'Titillium Web', sans-serif; 58 | font-size: 26px; 59 | font-weight: 700; 60 | margin: 0; 61 | padding-top: 25px; 62 | color: whitesmoke; 63 | } 64 | 65 | .notfound p { 66 | font-family: 'Montserrat', sans-serif; 67 | font-size: 14px; 68 | font-weight: 500; 69 | margin-bottom: 0px; 70 | text-transform: uppercase; 71 | } 72 | 73 | .notfound a { 74 | font-family: 'Titillium Web', sans-serif; 75 | display: inline-block; 76 | text-transform: uppercase; 77 | color: $font_color_white_one; 78 | text-decoration: none; 79 | border: none; 80 | background: #5c91fe; 81 | padding: 10px 40px; 82 | font-size: 14px; 83 | font-weight: 700; 84 | border-radius: 1px; 85 | margin-top: 15px; 86 | -webkit-transition: 0.2s all; 87 | transition: 0.2s all; 88 | } 89 | 90 | .notfound a:hover { 91 | opacity: 0.8; 92 | } 93 | 94 | @media only screen and (max-width: 767px) { 95 | .notfound .notfound-404 { 96 | height: 110px; 97 | line-height: 110px; 98 | } 99 | 100 | .notfound .notfound-404 { 101 | font-size: 100px; 102 | } 103 | } 104 | } -------------------------------------------------------------------------------- /src/client/style/Explorer.scss: -------------------------------------------------------------------------------- 1 | @import "vars.scss"; 2 | @import "sideMenu.scss"; 3 | 4 | .explorer-page-loading { 5 | text-align: center; 6 | font-size: 3rem; 7 | position: fixed; 8 | left: 45%; 9 | top: 50%; 10 | } 11 | 12 | .explorer-container-out { 13 | 14 | &.mode_search, 15 | &.mode_tag, 16 | &.mode_author { 17 | .explorer-breadcrumb { 18 | margin-bottom: 5px; 19 | } 20 | 21 | .not-first-breadcrumb { 22 | margin-top: 10px; 23 | } 24 | 25 | .explorer-one-line-list-item { 26 | padding-left: 16px; 27 | } 28 | } 29 | 30 | &.anchorSideMenu { 31 | display: flex; 32 | } 33 | } 34 | 35 | .explorer-external-link { 36 | color: $font_color_white_one !important; 37 | } 38 | 39 | .explorer-container { 40 | padding-bottom: 30px; 41 | 42 | .fa-folder { 43 | color: yellowgreen; 44 | } 45 | 46 | a { 47 | color: $font_color_white_one !important; 48 | } 49 | 50 | 51 | .file-list { 52 | text-align: left !important; 53 | } 54 | 55 | .file-out-cell { 56 | padding-bottom: 10px; 57 | } 58 | 59 | .file-cell { 60 | 61 | @include file_cell_common; 62 | 63 | padding-top: 5px; 64 | padding-bottom: 10px; 65 | display: flex; 66 | flex-flow: column; 67 | justify-content: start; 68 | align-items: center; 69 | } 70 | 71 | .file-info-row { 72 | color: $font_color_white_one; 73 | display: flex; 74 | justify-content: space-between; 75 | align-items: flex-start; 76 | width: 100%; 77 | padding-left: 15px; 78 | padding-right: 15px; 79 | font-size: 12.5px; 80 | 81 | &.less-padding { 82 | padding-left: 5px; 83 | padding-right: 5px; 84 | } 85 | } 86 | 87 | .explorer-file-change-toolbar { 88 | margin-top: 5px; 89 | padding-left: 15px; 90 | padding-right: 15px; 91 | } 92 | 93 | .file-cell-inner { 94 | padding-bottom: 5px; 95 | display: flex; 96 | flex-flow: column; 97 | justify-content: start; 98 | align-items: center; 99 | overflow: hidden; 100 | 101 | .file-cell-thumbnail { 102 | margin: 0.2rem 1rem; 103 | padding: 0px !important; 104 | max-width: 100%; 105 | height: 205px; 106 | object-fit: scale-down; 107 | margin: auto; 108 | text-align: center; 109 | border-radius: 2px; 110 | margin-top: 20px; 111 | 112 | &.as-folder-thumbnail { 113 | max-width: 90%; 114 | height: 200px; 115 | margin-top: 0px; 116 | } 117 | } 118 | } 119 | 120 | .folder-seperator { 121 | border: 2px solid $border-color-black; 122 | border-radius: 2px; 123 | 124 | .extra-div{ 125 | float: right; 126 | color: $font_color_white_one; 127 | } 128 | } 129 | } 130 | 131 | .file-cell-title { 132 | line-height: 1em; 133 | height: 2em; 134 | /* height is 2x line-height, so two lines will display */ 135 | overflow: hidden; 136 | margin-bottom: 5px; 137 | } 138 | 139 | 140 | 141 | //https://stackoverflow.com/questions/15857006/drawing-a-folder-icon-with-css 142 | .folder-effect { 143 | display: flex; 144 | margin: 0 auto; 145 | margin-top: 20px; 146 | position: relative; 147 | background-color: #f3cf4b; 148 | border-radius: 0 5px 5px 5px; 149 | box-shadow: 4px 4px 7px rgba(0, 0, 0, 0.59); 150 | 151 | &::before { 152 | content: ''; 153 | width: 40%; 154 | height: 10px; 155 | border-radius: 0 5px 0 0; 156 | background-color: #f3cf4b; 157 | position: absolute; 158 | top: -10px; 159 | left: 0px; 160 | } 161 | } 162 | 163 | .fail-reason-text { 164 | color: rgb(128, 16, 16); 165 | } 166 | 167 | .explorer-one-line-list-item { 168 | text-align: left !important; 169 | 170 | display: flex; 171 | align-items: center; 172 | 173 | border-radius: 5px; 174 | border: white; 175 | border-width: 1px; 176 | 177 | font-size: 1rem; 178 | font-weight: bold; 179 | color: black; 180 | 181 | @media only screen and (max-width: 500px) { 182 | font-size: 10px; 183 | } 184 | 185 | @media only screen and (max-width: 1100px) { 186 | font-size: 12px; 187 | } 188 | 189 | padding-left: 5px; 190 | color:$font_color_white_one; 191 | 192 | .far, 193 | .fas { 194 | margin-right: 5px; 195 | } 196 | 197 | .av-color { 198 | color: #abc0eb; 199 | } 200 | } 201 | 202 | 203 | 204 | .item-container { 205 | list-style: none; 206 | 207 | .item-container-expand-button { 208 | color: $font_color_white_one; 209 | cursor: pointer; 210 | padding-left: 14px; 211 | font-size: 40px; 212 | width: 100%; 213 | // text-align: center; 214 | 215 | .arror-icon{ 216 | font-size: 20px; 217 | } 218 | } 219 | } 220 | 221 | 222 | .scan-button { 223 | cursor: pointer; 224 | display: flex; 225 | align-items: center; 226 | justify-content: center; 227 | width: 300px; 228 | background-color: $bacground_level_one; 229 | min-height: 26px; 230 | padding: 1px 5px 2px; 231 | margin: 0 2px; 232 | border-radius: 3px; 233 | font-size: 9pt; 234 | color: $font_color_white_one; 235 | margin-bottom: 10px; 236 | 237 | &:hover { 238 | @include hover_effect; 239 | } 240 | } 241 | 242 | .page-number-range-slider-wrapper { 243 | width: 100%; 244 | display: flex; 245 | justify-content: center; 246 | align-items: center; 247 | margin-top: 15px; 248 | margin-bottom: 15px; 249 | 250 | .small-text-title { 251 | color: $font_color_white_one; 252 | } 253 | 254 | .page-number-range-slider { 255 | width: 500px; 256 | margin-left: 10px; 257 | margin-right: 10px; 258 | height: 4px; 259 | 260 | .range-slider__thumb { 261 | background: $font_color_white_one; 262 | width: 15px; 263 | height: 15px; 264 | } 265 | 266 | .range-slider__range{ 267 | // background: $font_color_white_one; 268 | 269 | } 270 | } 271 | } 272 | 273 | -------------------------------------------------------------------------------- /src/client/style/FileChangeToolbar.scss: -------------------------------------------------------------------------------- 1 | @import "vars.scss"; 2 | 3 | .path-highlight { 4 | color: red; 5 | font-weight: 700; 6 | } 7 | 8 | .move_menu_wrap { 9 | 10 | .move_menu-back-btn { 11 | border: 1px #414141 solid; 12 | width: 60px; 13 | padding-left: 10px; 14 | border-radius: 3px; 15 | cursor: pointer; 16 | } 17 | 18 | .move-header { 19 | border: 1px #414141 solid; 20 | padding-left: 10px; 21 | padding-right: 10px; 22 | padding-top: 2px; 23 | padding-bottom: 2px; 24 | border-radius: 3px; 25 | margin-bottom: 5px; 26 | } 27 | 28 | .move-header-two{ 29 | border: 1px #414141 solid; 30 | padding-left: 10px; 31 | padding-right: 10px; 32 | padding-top: 2px; 33 | padding-bottom: 2px; 34 | border-radius: 3px; 35 | margin-bottom: 5px; 36 | 37 | border: 1px #414141 solid; 38 | width: 100px; 39 | padding-left: 10px; 40 | border-radius: 3px; 41 | cursor: pointer; 42 | 43 | &:hover { 44 | @include third_hover_effect; 45 | } 46 | } 47 | 48 | .move-list-content{ 49 | max-height: 500px; 50 | overflow-y: scroll; 51 | padding-right: 10px; 52 | 53 | .move-path-item{ 54 | display: flex; 55 | justify-content: space-between; 56 | margin-bottom: 5px; 57 | border-bottom: 1px solid #414141; 58 | 59 | .fa-folder { 60 | margin-right: 5px; 61 | } 62 | 63 | .mv-btn-small{ 64 | cursor: pointer; 65 | &:hover{ 66 | @include third_hover_effect; 67 | } 68 | } 69 | } 70 | } 71 | } 72 | 73 | .file-change-tool-bar { 74 | padding-top: 2px; 75 | max-width: 300px; 76 | 77 | color: $font_color_white_one; 78 | width: 100%; 79 | 80 | .tool-bar-row { 81 | display: flex; 82 | justify-content: space-between; 83 | align-items: center; 84 | padding-left: 10px; 85 | padding-right: 10px; 86 | 87 | &.second { 88 | margin-top: 5px; 89 | } 90 | } 91 | 92 | .fa, 93 | .fas { 94 | margin-left: 5px; 95 | cursor: pointer; 96 | font-size: 12px; 97 | 98 | //download is a 99 | //lazy copy-and-paste code here 100 | &.fa-download{ 101 | &:hover { 102 | @include third_hover_effect; 103 | color: $font_dark_orange !important; 104 | } 105 | } 106 | 107 | &:hover { 108 | @include third_hover_effect; 109 | } 110 | } 111 | 112 | &.bigFont { 113 | .fa, 114 | .fas { 115 | font-size: 18px; 116 | } 117 | } 118 | 119 | .file-change-tool-bar-header { 120 | line-height: 16px; 121 | font-size: 16px; 122 | } 123 | } 124 | 125 | //------------------move modal-------------- 126 | 127 | .ReactModal__Overlay--after-open { 128 | @include flex_center_display; 129 | z-index: 10; 130 | background-color: rgb(39 35 35 / 80%) !important; 131 | } 132 | 133 | .file-change-toolbar-move-modal { 134 | background: #929292; 135 | max-width: 600px; 136 | min-width: 400px; 137 | padding-bottom: 10px; 138 | padding-top: 10px; 139 | padding-left: 10px; 140 | padding-right: 10px; 141 | 142 | border-radius: 4px; 143 | font-size: 12px; 144 | 145 | .section { 146 | background: white; 147 | padding: 5px; 148 | border-radius: 2px; 149 | 150 | &.with-bottom-margin { 151 | margin-bottom: 5px; 152 | } 153 | } 154 | 155 | .file-dir-name { 156 | margin-bottom: 5px; 157 | } 158 | 159 | .aji-file-name { 160 | margin-bottom: 10px; 161 | display: block; 162 | 163 | .embed-link { 164 | color: #414141 !important; 165 | } 166 | } 167 | 168 | .title { 169 | // font-weight: 600; 170 | color: #414141; 171 | margin-bottom: 2px; 172 | } 173 | 174 | .modal-list-item { 175 | cursor: pointer; 176 | font-size: 11px; 177 | color: $font_color_black_two; 178 | margin-bottom: 2px; 179 | 180 | &:hover { 181 | @include third_hover_effect; 182 | } 183 | } 184 | } 185 | 186 | .result-zip-path { 187 | font-size: 10px; 188 | } 189 | 190 | .move-button, 191 | .raname-button { 192 | border: 1px #414141 solid; 193 | padding-left: 10px; 194 | padding-right: 10px; 195 | padding-top: 2px; 196 | padding-bottom: 2px; 197 | border-radius: 3px; 198 | 199 | cursor: pointer; 200 | } -------------------------------------------------------------------------------- /src/client/style/FileNameDiv.scss: -------------------------------------------------------------------------------- 1 | @import "vars.scss"; 2 | 3 | .aji-file-name { 4 | .embed-link { 5 | color: #DDDDDD !important; 6 | 7 | &.with-color { 8 | color: rgb(114, 161, 236) !important; 9 | font-weight: 600; 10 | } 11 | } 12 | } 13 | 14 | 15 | 16 | .click-and-copy-text { 17 | cursor: copy; 18 | @include flex_center_display; 19 | display: inline-flex; 20 | margin-left: 5px; 21 | 22 | &:hover { 23 | @include third_hover_effect(); 24 | } 25 | } -------------------------------------------------------------------------------- /src/client/style/HistoryPage.scss: -------------------------------------------------------------------------------- 1 | @import "vars.scss"; 2 | 3 | .history-container { 4 | position: relative; 5 | 6 | @include common_sub_container; 7 | 8 | .history-section { 9 | .admin-section-title{ 10 | color: $font_color_black_two; 11 | } 12 | 13 | .history-day-section { 14 | margin-bottom: 20px; 15 | 16 | .date-text { 17 | font-weight: 600; 18 | border-top: 1px #414141 solid; 19 | border-bottom: 1px #414141 solid; 20 | 21 | display: flex; 22 | justify-content: space-between; 23 | 24 | padding-left: 5px; 25 | padding-right: 10px; 26 | padding-top: 5px; 27 | padding-bottom: 5px; 28 | margin-bottom: 5px; 29 | 30 | span{ 31 | color: $font_color_black_two; 32 | } 33 | } 34 | } 35 | 36 | .history-link { 37 | .history-one-line-list-item { 38 | padding-left: 5px; 39 | .icon{ 40 | color: rgb(116, 173, 0); 41 | &.fa-file-video { 42 | color: #000769; 43 | } 44 | &.fa-book { 45 | color: #414141; 46 | } 47 | } 48 | } 49 | } 50 | } 51 | } -------------------------------------------------------------------------------- /src/client/style/HomePage.scss: -------------------------------------------------------------------------------- 1 | @import "vars.scss"; 2 | 3 | .home-page { 4 | @media only screen and (min-width: 1200px) { 5 | .explorer-one-line-list-item { 6 | font-size: 1rem; 7 | max-width: 1100px; 8 | } 9 | } 10 | 11 | .fa-folder { 12 | color: yellowgreen; 13 | } 14 | 15 | .home-section-title { 16 | // margin-bottom: 5px; 17 | font-weight: bold; 18 | color: $font_color_white_one; 19 | } 20 | } 21 | 22 | -------------------------------------------------------------------------------- /src/client/style/LoadingImage.scss: -------------------------------------------------------------------------------- 1 | @import "vars.scss"; 2 | 3 | .empty-block { 4 | width: 180px; 5 | margin-top: 30px; 6 | font-size: 80px; 7 | @include flex_center_display; 8 | 9 | color: #DDDDDD !important; 10 | } -------------------------------------------------------------------------------- /src/client/style/MusicPlayer.scss: -------------------------------------------------------------------------------- 1 | @import "vars.scss"; 2 | 3 | .aji-music-player { 4 | max-width: 750px; 5 | width: 80%; 6 | border-radius: 2px; 7 | padding: 0px 10px; 8 | 9 | .aji-music-items { 10 | max-height: 350px; 11 | overflow-y: scroll; 12 | margin-bottom: 20px; 13 | } 14 | 15 | .aji-music-player-item { 16 | color: $font_color_white_one; 17 | cursor: pointer; 18 | margin-bottom: 10px; 19 | // overflow-x: visible; 20 | 21 | overflow: hidden; 22 | text-overflow: ellipsis; 23 | white-space: nowrap; 24 | flex-shrink: 1; 25 | font-size: 12px; 26 | 27 | &:hover { 28 | @include third_hover_effect; 29 | } 30 | 31 | &.aji-music-player-active { 32 | &::before { 33 | color: chartreuse; 34 | } 35 | } 36 | } 37 | 38 | .aji-music-player-control { 39 | width: 100%; 40 | 41 | &:focus { 42 | outline: none; 43 | } 44 | } 45 | } -------------------------------------------------------------------------------- /src/client/style/OneBook.scss: -------------------------------------------------------------------------------- 1 | @import "vars.scss"; 2 | 3 | .one-book-nothing-available { 4 | color: $font_color_white_one; 5 | font-size: 2rem; 6 | @include flex_center_display; 7 | 8 | .alert { 9 | padding-left: 100px; 10 | padding-right: 100px; 11 | } 12 | } 13 | 14 | .one-book-container { 15 | @include flex_center_display; 16 | flex-flow: column; 17 | padding-bottom: 20px; 18 | 19 | .only-music { 20 | margin-top: 20px; 21 | min-width: 300px; 22 | } 23 | 24 | .one-book-wrapper { 25 | margin-top: 5px; 26 | @include flex_center_display; 27 | flex-flow: column; 28 | 29 | &.two-page-mode { 30 | flex-flow: row; 31 | } 32 | 33 | &:not(.has-music) { 34 | width: 100%; 35 | } 36 | 37 | &.has-music { 38 | min-width: 500px; 39 | 40 | //https://w3bits.com/rainbow-text/ 41 | .placeholder-for-music { 42 | margin-top: 50px; 43 | font-size: 200px; 44 | background-image: linear-gradient(to left, #ffff55, #abffab, #899dff); 45 | color: transparent; 46 | background-clip: text; 47 | -webkit-background-clip: text; 48 | -webkit-text-fill-color: transparent; 49 | } 50 | 51 | .aji-music-player { 52 | max-width: 80%; 53 | } 54 | } 55 | } 56 | 57 | .fs-toggle-button { 58 | color: $font_color_white_one; 59 | background-color: transparent; 60 | border: 0; 61 | margin-left: 10px; 62 | border-radius: 2px; 63 | padding: 5px; 64 | cursor: pointer; 65 | font-size: 1.5rem; 66 | 67 | &:hover { 68 | @include second_hover_effect; 69 | } 70 | } 71 | 72 | .one-book-list { 73 | list-style: none; 74 | } 75 | 76 | a { 77 | color: $font_color_white_one !important; 78 | } 79 | 80 | .one-book-image-li { 81 | padding-top: 10px; 82 | margin-right: auto; 83 | margin-left: auto; 84 | } 85 | 86 | .one-book-image { 87 | margin-bottom: 0.5rem; 88 | box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.16), 89 | 0 2px 10px 0 rgba(0, 0, 0, 0.12) !important; 90 | z-index: 10; 91 | object-fit: cover; 92 | } 93 | 94 | 95 | 96 | .mobile-one-book-container { 97 | width: 100%; 98 | 99 | .one-book-waterfall-div { 100 | @include flex_center_display; 101 | margin-bottom: 30px; 102 | margin-left: 5px; 103 | margin-right: 5px; 104 | 105 | .one-book-waterfall-image { 106 | max-width: 98%; 107 | object-fit: cover; 108 | } 109 | } 110 | 111 | .mobile-single-image-container { 112 | @include flex_center_display; 113 | margin-bottom: 30px; 114 | margin-left: 5px; 115 | margin-right: 5px; 116 | overflow: scroll; 117 | 118 | @media screen and (min-height: 600px) { 119 | min-height: 600px; 120 | } 121 | 122 | .mobile-single-image { 123 | object-fit: cover; 124 | //note: 100vh does not work 125 | max-width: 100%; 126 | max-height: 900px; 127 | 128 | @media screen and (max-height: 800px) { 129 | max-height: 600px; 130 | } 131 | 132 | &.has-music { 133 | max-height: 600px; 134 | } 135 | } 136 | } 137 | } 138 | 139 | .one-book-tags { 140 | display: flex; 141 | justify-content: flex-start; 142 | } 143 | 144 | .one-book-tags div { 145 | margin-right: 10px; 146 | } 147 | 148 | .one-book-foot-author { 149 | &:hover { 150 | a { 151 | @include third_hover_effect; 152 | } 153 | } 154 | } 155 | 156 | .one-book-foot-index-number { 157 | cursor: none; 158 | position: fixed; 159 | right: 20px; 160 | bottom: 20px; 161 | color: $font_color_white_one; 162 | font-weight: 600; 163 | z-index: 10; 164 | 165 | &.is-last { 166 | color: $font_dark_orange; 167 | } 168 | } 169 | 170 | $font_size_icon: 1.4rem; 171 | 172 | .one-book-file-stat { 173 | color: $font_color_white_one; 174 | display: flex; 175 | font-size: 14px; 176 | 177 | .video-num { 178 | color: $font_dark_orange; 179 | margin-left: 15px; 180 | } 181 | 182 | .mobile-page-num { 183 | cursor: pointer; 184 | margin-left: 15px; 185 | 186 | &:hover { 187 | @include third_hover_effect; 188 | } 189 | } 190 | } 191 | 192 | .one-book-second-toolbar { 193 | @include flex_center_display; 194 | div:first-child { 195 | margin-right: 30px; 196 | } 197 | 198 | .two-page-mode-button, 199 | .rotate-button { 200 | font-size: $font_size_icon; 201 | 202 | &:hover { 203 | @include third_hover_effect(); 204 | } 205 | } 206 | } 207 | } 208 | 209 | .one-book-title { 210 | color: $font_color_white_one; 211 | flex-direction: column; 212 | @include flex_center_display; 213 | font-size: 14px; 214 | gap: 8px; 215 | 216 | .small-box { 217 | flex-direction: row; 218 | @include flex_center_display; 219 | 220 | .folder-link { 221 | margin-right: 10px; 222 | } 223 | } 224 | 225 | a { 226 | color: $font_color_white_one !important; 227 | } 228 | } 229 | 230 | .one-book-path { 231 | color: $font_color_white_one; 232 | font-weight: 400; 233 | margin-right: 10px; 234 | @include flex_center_display; 235 | flex-direction: column; 236 | display: flex; 237 | 238 | a { 239 | line-height: 1em; 240 | max-width: 650px; 241 | color: $font_color_white_one !important; 242 | } 243 | } 244 | 245 | .one-book-overview-container { 246 | .one-book-title { 247 | padding-top: 10px; 248 | padding-bottom: 10px; 249 | } 250 | 251 | .a-with-padding { 252 | padding-bottom: 20px; 253 | padding-left: 10px; 254 | padding-right: 10px; 255 | 256 | .obov-link { 257 | display: block; 258 | } 259 | 260 | .single-img-cell { 261 | height: 280px; 262 | object-fit: scale-down; 263 | max-width: calc(100% - 10px); 264 | } 265 | 266 | .place-holder { 267 | background: white; 268 | } 269 | } 270 | } 271 | 272 | .ehentai-tag-row { 273 | display: flex; 274 | 275 | .ehentai-tag-link { 276 | margin-left: 5px; 277 | } 278 | } 279 | 280 | .one-book-img-load-spinner { 281 | display: none; 282 | 283 | &.show { 284 | display: flex; 285 | position: absolute; 286 | top: 0px; 287 | z-index: 100; 288 | width: 100%; 289 | height: 100%; 290 | align-items: center; 291 | justify-content: center; 292 | 293 | //https://stackoverflow.com/questions/1009753/pass-mouse-events-through-absolutely-positioned-element 294 | pointer-events: none; 295 | } 296 | } 297 | 298 | .one-book-overview-path { 299 | a { 300 | font-size: 15px; 301 | } 302 | 303 | display: flex; 304 | justify-content: center; 305 | gap: 14px; 306 | } 307 | 308 | .one-book-video-container { 309 | display: flex; 310 | flex-direction: column; 311 | 312 | .video-link { 313 | &:hover { 314 | .video-link-text { 315 | text-decoration: underline; 316 | } 317 | } 318 | 319 | .video-link-text { 320 | margin-left: 10px; 321 | } 322 | } 323 | } 324 | 325 | .one-book-toolbar { 326 | // 额外空间,否则点击不舒服。 327 | margin-top: 2px; 328 | margin-bottom: 8px; 329 | } -------------------------------------------------------------------------------- /src/client/style/Pagination.scss: -------------------------------------------------------------------------------- 1 | @import "vars.scss"; 2 | 3 | .pagination-container { 4 | @include flex_center_display 5 | } 6 | 7 | .pagination { 8 | border-radius: 2px; 9 | font-weight: 500; 10 | margin-bottom: 10px; 11 | 12 | .fa-ellipsis-h { 13 | color: #212529; 14 | } 15 | 16 | .pagination-item { 17 | position: relative; 18 | display: block; 19 | margin-left: -1px; 20 | line-height: 1.25; 21 | color: #007bff; 22 | background-color: #fff; 23 | border: 1px solid #dee2e6; 24 | 25 | a { 26 | color: #007bff !important; 27 | display: block; 28 | padding: .5rem .75rem; 29 | cursor: pointer; 30 | } 31 | 32 | &.disabled { 33 | cursor: auto; 34 | a { 35 | color: #212529 !important; 36 | } 37 | } 38 | 39 | &.active { 40 | background-color: #007bff; 41 | border-color: #007bff; 42 | a { 43 | color: $font_color_white_one !important; 44 | } 45 | } 46 | } 47 | 48 | .page-jump { 49 | display: flex; 50 | align-items: center; 51 | background-color: white; 52 | color: #007bff; 53 | padding-left: 10px; 54 | padding-right: 10px; 55 | 56 | input { 57 | width: 35px; 58 | } 59 | } 60 | 61 | .pagination-extra-button { 62 | display: flex; 63 | align-items: center; 64 | background-color: white; 65 | color: #007bff; 66 | padding-left: 10px; 67 | padding-right: 10px; 68 | cursor: pointer; 69 | font-size: 11px; 70 | 71 | border-left: 1px solid #dee2e6; 72 | } 73 | } -------------------------------------------------------------------------------- /src/client/style/RadioButtonGroup.scss: -------------------------------------------------------------------------------- 1 | .radio-button { 2 | display: flex; 3 | align-items: center; 4 | margin-bottom: 5px; 5 | cursor: pointer; 6 | font-size: 10px; 7 | 8 | input { 9 | height: 14px; 10 | width: 14px; 11 | margin-right: 5px; 12 | } 13 | } -------------------------------------------------------------------------------- /src/client/style/SortHeader.scss: -------------------------------------------------------------------------------- 1 | @import "vars.scss"; 2 | 3 | 4 | .sort-header-container { 5 | margin-top: 10px; 6 | margin-bottom: 10px; 7 | padding-top: 10px; 8 | padding-bottom: 10px; 9 | background-color: #99a2b5; 10 | border-radius: 5px; 11 | font-weight: 500; 12 | 13 | .sort-header { 14 | display: flex; 15 | align-items: center; 16 | justify-content: space-between; 17 | font-size: 10px; 18 | 19 | 20 | @media screen and (min-width: 1000px) { 21 | font-size: 16px; 22 | } 23 | 24 | @media screen and (min-width: 1200px) { 25 | font-size: 14px; 26 | padding-left: 50px; 27 | padding-right: 50px; 28 | } 29 | 30 | width: 100%; 31 | 32 | .sort-item { 33 | color: #212529; 34 | 35 | .fas { 36 | color: #212529; 37 | } 38 | 39 | background: unset; 40 | cursor: pointer; 41 | vertical-align: baseline; 42 | 43 | &:hover { 44 | @include third_hover_effect; 45 | } 46 | } 47 | } 48 | } -------------------------------------------------------------------------------- /src/client/style/Spinner.scss: -------------------------------------------------------------------------------- 1 | @import "vars.scss"; 2 | 3 | .loading-container { 4 | width: 100%; 5 | height: 100%; 6 | @include flex_center_display; 7 | 8 | .loading-inner { 9 | margin-top: 100px; 10 | text-align: center; 11 | font-size: 2rem; 12 | color: $font_color_white_one; 13 | 14 | .title { 15 | color: $font_color_white_one; 16 | } 17 | } 18 | } 19 | 20 | .lds-spinner { 21 | color: official; 22 | display: inline-block; 23 | position: relative; 24 | width: 64px; 25 | height: 64px; 26 | } 27 | 28 | .lds-spinner div { 29 | transform-origin: 32px 32px; 30 | animation: lds-spinner 1.2s linear infinite; 31 | } 32 | 33 | .lds-spinner div:after { 34 | content: " "; 35 | display: block; 36 | position: absolute; 37 | top: 3px; 38 | left: 29px; 39 | width: 5px; 40 | height: 14px; 41 | border-radius: 20%; 42 | background: #fff; 43 | } 44 | 45 | .lds-spinner div:nth-child(1) { 46 | transform: rotate(0deg); 47 | animation-delay: -1.1s; 48 | } 49 | 50 | .lds-spinner div:nth-child(2) { 51 | transform: rotate(30deg); 52 | animation-delay: -1s; 53 | } 54 | 55 | .lds-spinner div:nth-child(3) { 56 | transform: rotate(60deg); 57 | animation-delay: -0.9s; 58 | } 59 | 60 | .lds-spinner div:nth-child(4) { 61 | transform: rotate(90deg); 62 | animation-delay: -0.8s; 63 | } 64 | 65 | .lds-spinner div:nth-child(5) { 66 | transform: rotate(120deg); 67 | animation-delay: -0.7s; 68 | } 69 | 70 | .lds-spinner div:nth-child(6) { 71 | transform: rotate(150deg); 72 | animation-delay: -0.6s; 73 | } 74 | 75 | .lds-spinner div:nth-child(7) { 76 | transform: rotate(180deg); 77 | animation-delay: -0.5s; 78 | } 79 | 80 | .lds-spinner div:nth-child(8) { 81 | transform: rotate(210deg); 82 | animation-delay: -0.4s; 83 | } 84 | 85 | .lds-spinner div:nth-child(9) { 86 | transform: rotate(240deg); 87 | animation-delay: -0.3s; 88 | } 89 | 90 | .lds-spinner div:nth-child(10) { 91 | transform: rotate(270deg); 92 | animation-delay: -0.2s; 93 | } 94 | 95 | .lds-spinner div:nth-child(11) { 96 | transform: rotate(300deg); 97 | animation-delay: -0.1s; 98 | } 99 | 100 | .lds-spinner div:nth-child(12) { 101 | transform: rotate(330deg); 102 | animation-delay: 0s; 103 | } 104 | 105 | @keyframes lds-spinner { 106 | 0% { 107 | opacity: 1; 108 | } 109 | 110 | 100% { 111 | opacity: 0; 112 | } 113 | } -------------------------------------------------------------------------------- /src/client/style/TagPage.scss: -------------------------------------------------------------------------------- 1 | @import "vars.scss"; 2 | 3 | .tag-page-loading { 4 | text-align: center; 5 | font-size: 3rem; 6 | position: fixed; 7 | left: 45%; 8 | top: 50%; 9 | } 10 | 11 | .tag-container { 12 | .tag-page-list-group { 13 | list-style: none; 14 | } 15 | 16 | .tag-page-list-item-link { 17 | color: $font_color_white_one; 18 | margin-top: 0.25rem; 19 | margin-bottom: 0.25rem; 20 | 21 | display: flex; 22 | flex-flow: column; 23 | justify-content: start; 24 | align-items: center; 25 | } 26 | 27 | .tag-page-thumbnail { 28 | // max-width: 150px !important; 29 | max-width: 100%; 30 | max-height: 210px; 31 | } 32 | 33 | .tag-page-list-item { 34 | padding-bottom: 10px; 35 | 36 | .tag-cell { 37 | height: 260px; 38 | @include file_cell_common; 39 | padding-left: 15px; 40 | padding-right: 15px; 41 | padding-bottom: 10px; 42 | } 43 | } 44 | 45 | .aji-checkbox-container{ 46 | padding-bottom: 10px; 47 | } 48 | } -------------------------------------------------------------------------------- /src/client/style/ThumbnailPopup.scss: -------------------------------------------------------------------------------- 1 | @import "vars.scss"; 2 | 3 | .thumbnail-popup-wrap { 4 | // color: $font_color_white_one; 5 | cursor: pointer; 6 | position: relative; 7 | display: block; 8 | 9 | 10 | .thumbnail-popup-wrap { 11 | position: relative; 12 | .thumbnail-popup-content { 13 | display: none; 14 | } 15 | } 16 | 17 | &.open { 18 | .thumbnail-popup-content { 19 | display: flex; 20 | justify-content: start; 21 | padding-top: 5px; 22 | align-items: center; 23 | position: absolute; 24 | flex-direction: column; 25 | z-index: 10; 26 | 27 | top: -50px; 28 | left: -250px; 29 | @media screen and (max-width: 1600px) { 30 | right: 100px; 31 | left: unset; 32 | } 33 | 34 | background: rgb(124, 117, 117); 35 | width: 240px; 36 | height: 220px; 37 | border-radius: 3px; 38 | box-shadow: 4px 4px 7px rgba(0, 0, 0, 0.59); 39 | 40 | .thumbnail-popup-title { 41 | color: $font_color_white_one; 42 | font-size: 10px; 43 | line-height: 1em; 44 | height: 2em; 45 | overflow: hidden; 46 | padding-bottom: 5px; 47 | margin-bottom: 5px; 48 | width: 100%; 49 | padding-left: 10px; 50 | padding-right: 10px; 51 | } 52 | 53 | .thumbnail-popup-text { 54 | color: $font_color_white_one; 55 | } 56 | 57 | .thumbnail-video-preview { 58 | margin-top: 15px; 59 | max-width: 95%; 60 | max-height: 180px; 61 | } 62 | 63 | img { 64 | max-width: 95%; 65 | max-height: 180px; 66 | } 67 | } 68 | } 69 | 70 | 71 | } -------------------------------------------------------------------------------- /src/client/style/VideoPlayer.scss: -------------------------------------------------------------------------------- 1 | @import "vars.scss"; 2 | 3 | .video-player-page { 4 | 5 | .video-player-container { 6 | @include flex_center_display; 7 | padding-top: 10px; 8 | 9 | 10 | //horizontal 11 | .horizontal-video { 12 | // max-width: 1000px; 13 | // .dplayer{ 14 | // @media screen and (max-width: 1000px) { 15 | // width: calc(100% - 50px); 16 | // } 17 | 18 | // @media screen and (min-width: 1000px) { 19 | // width: 1000px; 20 | // } 21 | // } 22 | } 23 | 24 | //else vetical 25 | .vertical-video { 26 | // max-height: 800px; 27 | // .dplayer{ 28 | // @media screen and (max-height: 800px) { 29 | // height: calc(100% - 50px); 30 | // } 31 | 32 | // @media screen and (min-height: 800px) { 33 | // height: 800px; 34 | // } 35 | // } 36 | } 37 | } 38 | 39 | .video-title { 40 | @include flex_center_display; 41 | flex-direction: column; 42 | 43 | margin-top: 10px; 44 | font-size: 14px; 45 | color: $font_color_white_one; 46 | gap: 8px; 47 | 48 | .inline-display { 49 | display: inline; 50 | } 51 | } 52 | 53 | .video-tag-row { 54 | margin-top: 5px; 55 | @include flex_center_display; 56 | 57 | .video-tag { 58 | margin-left: 10px; 59 | color: $font_color_white_one; 60 | } 61 | } 62 | 63 | .video-toolbar { 64 | @include flex_center_display; 65 | 66 | // margin-left: auto; 67 | // margin-right: auto; 68 | font-size: 15px; 69 | gap: 14px; 70 | 71 | .aji-dropdown-item { 72 | font-size: 28px; 73 | } 74 | } 75 | 76 | .video-file-info-row { 77 | @include flex_center_display; 78 | 79 | margin-left: auto; 80 | margin-right: auto; 81 | font-size: 15px; 82 | 83 | color: $font_color_white_one; 84 | } 85 | } -------------------------------------------------------------------------------- /src/client/style/_toast.scss: -------------------------------------------------------------------------------- 1 | .Toastify__toast-body { 2 | margin-top: 0px !important; 3 | margin-bottom: 0px !important; 4 | font-size: 14px; 5 | 6 | .toast-header { 7 | padding-bottom: 10px; 8 | border-bottom: 1px solid rgba(0, 0, 0, .05); 9 | 10 | .badge { 11 | margin-right: 10px; 12 | } 13 | } 14 | 15 | .toast-body { 16 | padding-top: 10px; 17 | padding-bottom: 10px; 18 | font-size: 11px; 19 | } 20 | } 21 | 22 | .Toastify__close-button { 23 | // position: fixed; 24 | // right: 10px; 25 | display: none 26 | } 27 | 28 | .one-line-toast { 29 | min-height: unset !important; 30 | } -------------------------------------------------------------------------------- /src/client/style/sideMenu.scss: -------------------------------------------------------------------------------- 1 | .side-menu { 2 | box-sizing: border-box; 3 | padding-left: 20px; 4 | transition: width 1s; 5 | animation: all 1s; 6 | display: none; 7 | background-color: #99a2b5; 8 | overflow-x: hidden; 9 | /* Disable horizontal scroll */ 10 | 11 | &.anchorSideMenu { 12 | padding-top: 10px; 13 | padding-left: 10px; 14 | padding-right: 10px; 15 | padding-bottom: 10px; 16 | display: block; 17 | } 18 | 19 | .side-menu-radio-title { 20 | color: #0f2452; 21 | font-size: 15px; 22 | font-weight: 500; 23 | margin-bottom: 5px; 24 | } 25 | 26 | .speical-checkbox-container { 27 | display: flex; 28 | } 29 | 30 | .aji-checkbox, 31 | .radio-button { 32 | margin-right: 10px; 33 | color: $font_color_white_one; 34 | } 35 | 36 | .info-row { 37 | color: $font_color_white_one; 38 | font-size: 12px; 39 | margin-bottom: 5px; 40 | } 41 | 42 | .exp-tag-container { 43 | border-top: $font_color_white_one solid 1px; 44 | 45 | .side-menu-single-tag { 46 | cursor: pointer; 47 | color: $font_color_white_one; 48 | font-size: 12px; 49 | margin-bottom: 5px; 50 | 51 | &.type-tag { 52 | color: #c9ffd5; 53 | } 54 | 55 | &:hover { 56 | color: #43464e; 57 | font-weight: bold; 58 | } 59 | } 60 | } 61 | } -------------------------------------------------------------------------------- /src/client/style/vars.scss: -------------------------------------------------------------------------------- 1 | $font_color_white_one: #DDDDDD; 2 | $font_color_black_two: #414141; 3 | 4 | $bacground_level_one: #34353b; 5 | $bacground_level_two: #43464e; 6 | $bacground_level_three: #666b7a; 7 | $background_orange_white: rgb(231, 218, 190); 8 | 9 | $font_dark_orange: #da653a; 10 | 11 | $border-color-black: #616161; 12 | 13 | @mixin file_cell_common { 14 | background: $bacground_level_two; 15 | border: 1px solid $border-color-black; 16 | border-radius: 5px; 17 | overflow: hidden; 18 | } 19 | 20 | @mixin hover_effect { 21 | color: #212121; 22 | background: $font_color_white_one; 23 | } 24 | 25 | @mixin second_hover_effect { 26 | color: #2ad4da; 27 | background: unset; 28 | } 29 | 30 | @mixin third_hover_effect { 31 | color: $font_dark_orange; 32 | background: unset; 33 | } 34 | 35 | @mixin flex_center_display { 36 | display: flex; 37 | justify-content: center; 38 | align-items: center; 39 | } 40 | 41 | .flex-center-display-container { 42 | @include flex_center_display; 43 | } 44 | 45 | @mixin common_sub_container { 46 | background: $background_orange_white; 47 | color: $font_color_black_two; 48 | padding-bottom: 20px; 49 | padding-top: 20px; 50 | min-height: 800px; 51 | 52 | a { 53 | color: $font_color_black_two; 54 | } 55 | } 56 | 57 | .f-s-10 { 58 | font-size: 10px; 59 | } 60 | 61 | .f-s-11 { 62 | font-size: 11px; 63 | } 64 | 65 | .f-s-12 { 66 | font-size: 12px; 67 | } 68 | 69 | .f-s-14 { 70 | font-size: 14px; 71 | } 72 | 73 | .f-s-15 { 74 | font-size: 14px; 75 | } 76 | 77 | .f-s-16 { 78 | font-size: 16px; 79 | } 80 | 81 | *:focus { 82 | outline: 0px; 83 | } -------------------------------------------------------------------------------- /src/client/subcomponent/Accordion.js: -------------------------------------------------------------------------------- 1 | 2 | import '../style/Accordion.scss'; 3 | // const util = require("@common/util"); 4 | import { Collapse } from 'react-collapse'; 5 | import React, { useState } from 'react'; 6 | var classNames = require('classnames'); 7 | 8 | export default function Accordion(props) { 9 | const [ open, setOpen ] = useState(false); 10 | const { className, header, body } = props; 11 | const cn = classNames("aji-accordion", className); 12 | 13 | return ( 14 |
    15 |
    { setOpen(!open) }}> {header}
    16 | 17 | {body} 18 | 19 |
    20 | ) 21 | } 22 | 23 | -------------------------------------------------------------------------------- /src/client/subcomponent/BookImage.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from "react"; 2 | import _ from "underscore"; 3 | const clientUtil = require("../clientUtil"); 4 | const util = require("@common/util"); 5 | 6 | 7 | function BookImage(props, ref) { 8 | const { className, imageFiles, index, onLoad, onError, ...rest } = props; 9 | const preloadMade = useRef({}); 10 | const [imageSrc, setImageSrc] = useState(clientUtil.getFileUrl(imageFiles[index])); 11 | 12 | const preloadImages = async (startIndex) => { 13 | const beg = startIndex + 1; 14 | const preload_num = 2; 15 | const end = Math.min(beg + preload_num, imageFiles.length); 16 | 17 | for (let ii = beg; ii < end; ii++) { 18 | const url = clientUtil.getFileUrl(imageFiles[ii]); 19 | if (preloadMade.current[url]) { 20 | continue; 21 | } 22 | preloadMade.current[url] = true; 23 | 24 | const response = await fetch(url, { 25 | method: "GET", 26 | headers: { "Content-Type": "application/octet-stream" }, 27 | }); 28 | 29 | await util.pause(300); 30 | } 31 | }; 32 | 33 | // 使用 Underscore 的 _.debounce 函数来防抖 34 | useEffect(() => { 35 | const debounceSetImageSrc = _.debounce(() => { 36 | setImageSrc(clientUtil.getFileUrl(imageFiles[index])); 37 | preloadImages(index); 38 | }, 100); 39 | 40 | debounceSetImageSrc(); 41 | 42 | return () => debounceSetImageSrc.cancel(); // 清理函数,取消未执行的防抖函数 43 | }, [index]); 44 | 45 | const onImageError = (error) => { 46 | console.error(error); 47 | onError && onError(); 48 | }; 49 | 50 | return ( 51 | book-image 60 | ); 61 | } 62 | 63 | 64 | export default React.forwardRef(BookImage); 65 | -------------------------------------------------------------------------------- /src/client/subcomponent/Breadcrumb.js: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from 'react'; 2 | import '../style/Breadcrumb.scss'; 3 | import { Link } from 'react-router-dom'; 4 | const classNames = require('classnames'); 5 | const clientUtil = require("../clientUtil"); 6 | import { toast } from 'react-toastify'; 7 | 8 | export default class Breadcrumb extends PureComponent { 9 | onClickPath(){ 10 | let { path } = this.props; 11 | clientUtil.CopyToClipboard(path); 12 | 13 | toast('Copied to Clipboard', { 14 | className: "one-line-toast", 15 | position: "top-right", 16 | autoClose: 3 * 1000, 17 | hideProgressBar: true, 18 | closeOnClick: true, 19 | pauseOnHover: true, 20 | draggable: true 21 | }) 22 | } 23 | 24 | render() { 25 | let { path, right, className, sep, server_os, extraDiv} = this.props; 26 | // console.assert(sep); 27 | sep = sep || "\\"; 28 | const beginWithSep = path.startsWith(sep); 29 | const pathes = path.split(sep).filter(e => !!e); 30 | 31 | const pathList = []; 32 | //https://www.w3schools.com/howto/howto_css_breadcrumbs.asp 33 | for (let ii = 0; ii < pathes.length; ii++) { 34 | let item = pathes.slice(0, ii + 1).join(sep); 35 | if(beginWithSep){ 36 | item = sep + item; 37 | } 38 | if (ii === pathes.length - 1) { 39 | //last one not link 40 | pathList.push(
    {pathes[ii]}
    ); 41 | } else { 42 | const toUrl = clientUtil.getExplorerLink(item); 43 | pathList.push({pathes[ii]}); 44 | pathList.push(
    {sep}
    ) 45 | } 46 | } 47 | 48 | const isLinux = server_os === "linux"; 49 | if(isLinux){ 50 | const toUrl = clientUtil.getExplorerLink("/"); 51 | pathList.unshift(
    {sep}
    ) 52 | pathList.unshift(root); 53 | } 54 | 55 | const cn = classNames("explorer-breadcrumb", className); 56 | return (
      {pathList}{right}{extraDiv}
    ); 57 | } 58 | } 59 | 60 | 61 | -------------------------------------------------------------------------------- /src/client/subcomponent/CenterSpinner.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import '../style/Spinner.scss'; 3 | import PropTypes from 'prop-types'; 4 | var classNames = require('classnames'); 5 | import Spinner from './Spinner'; 6 | const clientUtil = require("../clientUtil"); 7 | const { getDir, getBaseName } = clientUtil; 8 | 9 | export default class CenterSpinner extends Component { 10 | render() { 11 | let text = undefined; 12 | if (this.props.text) { 13 | if (this.props.splitFilePath) { 14 | text =
    15 |
    {getBaseName(this.props.text)}
    16 |
    {getDir(this.props.text)}>
    17 |
    18 | } else { 19 | text =
    {this.props.text}
    20 | } 21 | } 22 | 23 | return ( 24 |
    25 |
    26 | {} 27 | {text} 28 |
    is Loading
    29 |
    30 |
    ) 31 | } 32 | } 33 | 34 | CenterSpinner.propTypes = { 35 | text: PropTypes.string, 36 | splitFilePath: PropTypes.bool 37 | }; 38 | -------------------------------------------------------------------------------- /src/client/subcomponent/Checkbox.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import '../style/Checkbox.scss'; 3 | 4 | //default is 0 5 | 6 | export default class Checkbox extends Component { 7 | static defaultProps = { 8 | 9 | }; 10 | 11 | handleChange() { 12 | this.props.onChange(); 13 | } 14 | 15 | render() { 16 | const { onChange, checked, children, title } = this.props; 17 | return (
    18 | { }} onClick={this.handleChange.bind(this)} type="radio" /> 19 | {children} 20 |
    ); 21 | 22 | } 23 | } 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/client/subcomponent/ClickAndCopyDiv.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | // import '../style/textDiv.scss'; 3 | var classNames = require('classnames'); 4 | const clientUtil = require("../clientUtil"); 5 | import { toast } from 'react-toastify'; 6 | import _ from 'underscore'; 7 | 8 | 9 | function onTitleClick(text) { 10 | clientUtil.CopyToClipboard(text); 11 | toast('Copied to Clipboard', { 12 | className: "one-line-toast", 13 | position: "top-right", 14 | autoClose: 3 * 1000, 15 | hideProgressBar: true, 16 | closeOnClick: true, 17 | pauseOnHover: true, 18 | draggable: true 19 | }) 20 | } 21 | 22 | export default function ClickAndCopyDiv(props) { 23 | const { text, className } = props; 24 | const cn2 = classNames("click-and-copy-text", className, "fas fa-copy"); 25 | return ( 26 | onTitleClick(text)} className={cn2} /> 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /src/client/subcomponent/Dropdown.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import '../style/Dropdown.scss'; 3 | const classNames = require('classnames'); 4 | const enhanceWithClickOutside = require('react-click-outside'); 5 | 6 | class Dropdown extends Component { 7 | static defaultProps = { 8 | 9 | }; 10 | 11 | constructor(prop) { 12 | super(prop); 13 | this.state = { open: false }; 14 | } 15 | 16 | toggleOpen() { 17 | this.setState({ 18 | open: !this.state.open 19 | }); 20 | } 21 | 22 | handleClickOutside() { 23 | this.setState({ 24 | open: false 25 | }); 26 | } 27 | 28 | render() { 29 | const { children } = this.props; 30 | const { open } = this.state; 31 | const cn = classNames("aji-dropdown", { 32 | "open": open 33 | }) 34 | 35 | return ( 36 |
    37 |
    38 |
    39 | {React.Children.map(children, (child, index) => { 40 | return child && React.cloneElement(child, { 41 | closeMenu: this.handleClickOutside.bind(this) 42 | }) 43 | })} 44 |
    45 |
    ); 46 | } 47 | } 48 | 49 | 50 | export default enhanceWithClickOutside(Dropdown); 51 | -------------------------------------------------------------------------------- /src/client/subcomponent/DropdownItem.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | 3 | export default class DropdownItem extends Component { 4 | handleChange() { 5 | this.props.closeMenu && this.props.closeMenu(); 6 | this.props.onClick && this.props.onClick(); 7 | } 8 | 9 | render() { 10 | const { children } = this.props; 11 | return (
    12 | {children} 13 |
    ); 14 | } 15 | } 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/client/subcomponent/FileCellTitle.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | const classNames = require('classnames'); 3 | 4 | 5 | export default class FileCellTitle extends Component { 6 | render() { 7 | const { str } = this.props; 8 | const fl = str.length; 9 | const cellTitleCn = classNames("file-cell-title", { 10 | "f-s-12": fl > 30, 11 | "f-s-14": fl <= 30 12 | }); 13 | return (
    {str}
    ) 14 | } 15 | } 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/client/subcomponent/FileNameDiv.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import '../style/FileNameDiv.scss'; 3 | var classNames = require('classnames'); 4 | const clientUtil = require("../clientUtil"); 5 | const nameParser = require('@name-parser'); 6 | import _ from 'underscore'; 7 | import ClickAndCopyDiv from './ClickAndCopyDiv'; 8 | 9 | 10 | function getText(filename, mecab_tokens) { 11 | const text = clientUtil.getBaseNameWithoutExtention(filename); 12 | const extension = filename.replace(text, ""); 13 | 14 | const pResult = nameParser.parse(text); 15 | let allTags = []; 16 | let originalTags = []; 17 | let pTags = []; 18 | let authors; 19 | 20 | if (pResult) { 21 | originalTags = [...pResult.rawTags, ...pResult.charNames]; 22 | authors = pResult.authors; 23 | 24 | if (pResult.comiket) { 25 | allTags.push(pResult.comiket); 26 | } 27 | 28 | if (pResult.group) { 29 | const includeAuthor = authors && authors.length === 1 && pResult.group.includes(authors[0]); 30 | if(!includeAuthor){ 31 | allTags.push(pResult.group); 32 | } 33 | } 34 | 35 | if (authors) { 36 | allTags.push(...authors); 37 | } 38 | 39 | allTags.push(...originalTags); 40 | pTags = allTags.slice(); 41 | } 42 | // let nameTags = namePicker.pick(text) || []; 43 | let nameTags = []; 44 | allTags.push(...nameTags); 45 | 46 | 47 | //less meaningful 48 | // let lessTags = (mecab_tokens && mecab_tokens.length > 1) ? mecab_tokens : namePicker.splitBySpace(text); 49 | // lessTags = lessTags.filter(e => { 50 | // const isUniq = allTags.every(e2 => { 51 | // return !e2.includes(e); 52 | // }); 53 | 54 | // return isUniq; 55 | // }); 56 | // allTags.push(...lessTags); 57 | 58 | //unique 59 | allTags = _.uniq(allTags); 60 | 61 | //sort by its index 62 | const tagIndexes = {}; 63 | allTags.forEach(tag => { 64 | const index = text.indexOf(tag); 65 | if(index < 0){ 66 | //todo 67 | //because tag converting, get index is not not so easy 68 | } 69 | tagIndexes[tag] = index; 70 | }); 71 | allTags = _.sortBy(allTags, tag => { 72 | return tagIndexes[tag]; 73 | }); 74 | 75 | function getPriority(str) { 76 | if (pTags.includes(str)) { 77 | return 4; 78 | } else if (nameTags.includes(str)) { 79 | return 3; 80 | } else { 81 | return 1; 82 | } 83 | } 84 | 85 | //tag1 may include tag2. remove the short one 86 | const willRemove = {}; 87 | for (let ii = 0; ii < allTags.length; ii++) { 88 | const t1 = allTags[ii]; 89 | if (willRemove[t1]) { 90 | continue; 91 | } 92 | for (let jj = ii + 1; jj < allTags.length; jj++) { 93 | const t2 = allTags[jj]; 94 | if (t1.includes(t2)) { 95 | const p1 = getPriority(t1); 96 | const p2 = getPriority(t2); 97 | if (p1 < p2) { 98 | willRemove[t1] = true; 99 | } else { 100 | willRemove[t2] = true; 101 | } 102 | } 103 | } 104 | } 105 | allTags = allTags.filter(e => !willRemove[e]); 106 | 107 | let tempText = text; 108 | // const SEP = "||__SEP__||" 109 | // sep不能含有常见字符,有作者名字就叫一个p。直接就炸掉了 110 | const SEP = "ⶤ▒" 111 | allTags.forEach(tag => { 112 | //https://stackoverflow.com/questions/4514144/js-string-split-without-removing-the-delimiters 113 | const tempHolder = SEP + tag + SEP; 114 | tempText = tempText.replaceAll(tag, tempHolder) 115 | }) 116 | const formatArr = []; 117 | tempText.split(SEP).map((token, ii) => { 118 | if (allTags.includes(token)) { 119 | const tag = token; 120 | let url; 121 | if (authors && authors.includes(tag)) { 122 | url = clientUtil.getAuthorLink(tag); 123 | } else if (originalTags && originalTags.includes(tag)) { 124 | url = clientUtil.getTagLink(tag); 125 | } else { 126 | url = clientUtil.getSearhLink(tag); 127 | } 128 | 129 | const cn = classNames("embed-link", { 130 | "with-color": getPriority(tag) > 1 131 | }); 132 | 133 | const link = {tag}; 134 | formatArr.push(link); 135 | } else { 136 | formatArr.push(token); 137 | } 138 | }); 139 | 140 | if (extension) { 141 | formatArr.push(extension); 142 | } 143 | 144 | return {formatArr} 145 | } 146 | 147 | 148 | 149 | export default function FileNameDiv(props) { 150 | const { filename, className, mecab_tokens, isVideo, ...others } = props; 151 | return ( 152 | 153 | {getText(filename, mecab_tokens)} 154 | 155 | ) 156 | } 157 | 158 | 159 | -------------------------------------------------------------------------------- /src/client/subcomponent/HistorySection.js: -------------------------------------------------------------------------------- 1 | import React, { Component, useState, useEffect } from 'react'; 2 | import _ from 'underscore'; 3 | const clientUtil = require("../clientUtil"); 4 | // const classNames = require('classnames'); 5 | // import ReactDOM from 'react-dom'; 6 | 7 | import Sender from '../Sender'; 8 | 9 | 10 | function HistorySection(props){ 11 | const {filePath} = props; 12 | const [history, setHistory] = useState([]); 13 | const [collapse, setCollapse] = useState(true); 14 | 15 | 16 | useEffect(() => { 17 | async function fetchData() { 18 | const res = await Sender.postWithPromise("/api/getHistoryForOneFile", {filePath}); 19 | if (!res.isFailed()) { 20 | let { history } = res.json; 21 | setHistory(history) 22 | } 23 | } 24 | fetchData(); 25 | }, []); 26 | 27 | let items; 28 | let length = history.length; 29 | if (length === 0) { 30 | return
    It is first time to read this book
    ; 31 | } else { 32 | items = history.map((e, ii) => { 33 | const key = e.time + "_" + ii; 34 | return
    {clientUtil.dateFormat_v1(e.time) }
    35 | }); 36 | 37 | if(items.length > 10 && collapse){ 38 | const middle = (
    setCollapse(false)}>
    ); 39 | items = items.slice(0, 3).concat(middle, items.slice(length - 4)) 40 | } 41 | } 42 | 43 | return ( 44 |
    45 |
    You have read this {length} times
    46 |
    47 | {items} 48 |
    49 |
    ) 50 | } 51 | 52 | export default HistorySection; -------------------------------------------------------------------------------- /src/client/subcomponent/ItemsContainer.js: -------------------------------------------------------------------------------- 1 | 2 | // const util = require("@common/util"); 3 | import React, { useState } from 'react'; 4 | var classNames = require('classnames'); 5 | 6 | export default function ItemsContainer(props) { 7 | const [ open, setOpen ] = useState(false); 8 | const { className, items, neverCollapse } = props; 9 | const TOO_MUCH = 15; 10 | 11 | if (neverCollapse || items.length <= TOO_MUCH) { 12 | return ( 13 |
      14 | {items} 15 |
    ); 16 | } else { 17 | const _items = open ? items : items.slice(0, TOO_MUCH); 18 | const cn = classNames("item-container-expand-button", className, { 19 | }); 20 | 21 | const arrowCn = classNames("arror-icon", { 22 | "fas fa-arrow-down": !open, 23 | "fas fa-arrow-up": open, 24 | }) 25 | 26 | return ( 27 |
      28 | {_items} 29 |
      { setOpen(!open) }} > 30 | ... 31 | 32 |
      33 |
    ); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/client/subcomponent/Pagination.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import '../style/Pagination.scss'; 3 | const classNames = require('classnames'); 4 | const clientUtil = require("../clientUtil"); 5 | 6 | 7 | export default class Pagination extends Component { 8 | constructor(props) { 9 | super() 10 | this.state = { 11 | textValue: props.currentPage 12 | } 13 | } 14 | 15 | static getDerivedStateFromProps(nextProps, prevState){ 16 | return { textValue: nextProps.currentPage } 17 | } 18 | 19 | onChange = (e) => { 20 | if(window.event.ctrlKey){ 21 | return; 22 | } 23 | e.preventDefault(); 24 | const { onChange } = this.props; 25 | const value = typeof e === "number" ? e : parseInt(e.target.textContent.trim()); 26 | onChange && onChange(value); 27 | } 28 | 29 | hasPrev() { 30 | return this.props.currentPage > 1; 31 | } 32 | 33 | prev = () => { 34 | const { onChange, currentPage } = this.props; 35 | onChange && onChange(Math.max(currentPage - 1, 1)); 36 | } 37 | 38 | hasNext() { 39 | return this.props.currentPage < this.getTotalPage(); 40 | } 41 | 42 | next = () => { 43 | const { onChange, currentPage } = this.props; 44 | onChange && onChange(Math.min(currentPage + 1, this.getTotalPage())); 45 | } 46 | 47 | getTotalPage() { 48 | const { 49 | itemPerPage, 50 | totalItemNum, 51 | } = this.props 52 | return Math.ceil(totalItemNum / itemPerPage); 53 | } 54 | 55 | render() { 56 | const { 57 | currentPage, 58 | itemPerPage, 59 | totalItemNum, 60 | onChange, 61 | linkFunc, 62 | className, 63 | onExtraButtonClick 64 | } = this.props 65 | 66 | const { textValue } = this.state; 67 | 68 | let BUFFER_SIZE; 69 | if(clientUtil.getWindowsWidth() < 750){ 70 | BUFFER_SIZE = 1; 71 | }else if (clientUtil.getWindowsWidth() < 1000){ 72 | BUFFER_SIZE = 2; 73 | }else { 74 | BUFFER_SIZE = 5; 75 | } 76 | const totalPage = this.getTotalPage(); 77 | 78 | if (totalPage <= 1) { 79 | return null; 80 | } 81 | 82 | const prevButton = (
    prev
    ) 83 | const nextButton = (
    next
    ) 84 | 85 | let right = Math.max(currentPage - BUFFER_SIZE, 1); 86 | let left = Math.min(currentPage + BUFFER_SIZE, totalPage); 87 | 88 | if (currentPage - right < BUFFER_SIZE) { 89 | const toLeft = BUFFER_SIZE - (currentPage - right); 90 | left = Math.min(currentPage + BUFFER_SIZE + toLeft, totalPage); 91 | } 92 | 93 | if (left - currentPage < BUFFER_SIZE) { 94 | const toRight = BUFFER_SIZE - (left - currentPage); 95 | right = Math.max(currentPage - BUFFER_SIZE - toRight, 1); 96 | } 97 | 98 | let contentList = [1]; 99 | if (right > 2) { 100 | contentList.push("..."); 101 | } 102 | 103 | for (let ii = right; ii <= left; ii++) { 104 | if (!contentList.includes(ii)) { 105 | contentList.push(ii); 106 | } 107 | } 108 | if (left < totalPage - 1) { 109 | contentList.push("..."); 110 | } 111 | 112 | if (!contentList.includes(totalPage)) { 113 | contentList.push(totalPage); 114 | } 115 | 116 | const itemDoms = contentList.map((e, ii) => { 117 | const isEllipsis = e === "..."; 118 | const cn = classNames("pagination-item", { 119 | active: e === currentPage, 120 | "disabled": isEllipsis 121 | }) 122 | let href = linkFunc && !isEllipsis && linkFunc(e-1); 123 | href = href || ""; 124 | return (
  • 125 | { } : this.onChange} href={href} >{e} 126 |
  • ) 127 | }) 128 | 129 | 130 | const pageInput = ( 131 |
    132 | { 136 | if (e.which === 13 || e.keyCode === 13) { 137 | //enter key 138 | this.onChange(parseInt(textValue)); 139 | e.preventDefault(); 140 | e.stopPropagation(); 141 | } 142 | }} 143 | 144 | onChange={e => { 145 | const val = e.target.value 146 | this.setState({ textValue: val }) 147 | }} 148 | /> 149 |
    {`/${totalPage}`}
    150 |
    ) 151 | 152 | const extraButton = (
    155 | {`${itemPerPage} per page`} 156 |
    ) 157 | 158 | return (
      159 | {prevButton} 160 | {itemDoms} 161 | {nextButton} 162 | {pageInput} 163 | {extraButton} 164 |
    ); 165 | } 166 | } 167 | 168 | 169 | -------------------------------------------------------------------------------- /src/client/subcomponent/RadioButtonGroup.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import '../style/RadioButtonGroup.scss'; 3 | 4 | //default is 0 5 | 6 | export default class RadioButtonGroup extends Component { 7 | static defaultProps = { 8 | 9 | }; 10 | 11 | render() { 12 | const { options, name, onChange, checked, className } = this.props; 13 | const buttons = options.map((e, index) => { 14 | return (
    onChange(e, index)}> 15 | { }} onClick={onChange} type="radio" name={name} value={e} key={e} /> {e} 16 |
    ); 17 | }) 18 | return
    {buttons}
    ; 19 | } 20 | } 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/client/subcomponent/SortHeader.js: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from 'react'; 2 | import '../style/SortHeader.scss'; 3 | const classNames = require('classnames'); 4 | 5 | 6 | 7 | 8 | export default class SortHeader extends PureComponent { 9 | static defaultProps = { 10 | 11 | }; 12 | 13 | onClick(key){ 14 | const { onChange, isSortAsc, selected } = this.props; 15 | let newSortOrder = !isSortAsc; 16 | if(key !== selected){ 17 | newSortOrder = false; 18 | } 19 | onChange(key, newSortOrder); 20 | } 21 | 22 | render() { 23 | let { sortOptions, onChange, selected, isSortAsc, className } = this.props; 24 | 25 | if(!selected){ 26 | selected = sortOptions[0]; 27 | } 28 | 29 | console.assert(sortOptions.includes(selected)); 30 | 31 | const items = sortOptions.map((item) => { 32 | let icon; 33 | if (item === selected) { 34 | if (isSortAsc) { 35 | icon = ; 36 | } else { 37 | icon = ; 38 | } 39 | } 40 | return (
    this.onClick(item)}> {icon} {item}
    ) 41 | }) 42 | 43 | return (
    {items}
    ) 44 | 45 | } 46 | } 47 | 48 | 49 | -------------------------------------------------------------------------------- /src/client/subcomponent/Spinner.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import '../style/Spinner.scss'; 3 | const classNames = require('classnames'); 4 | 5 | export default class Spinner extends Component { 6 | render() { 7 | const { className } = this.props; 8 | const cn = classNames("lds-spinner", className); 9 | return ( 10 |
    11 |
    12 |
    13 |
    14 |
    15 |
    16 |
    17 |
    18 |
    19 |
    20 |
    21 |
    22 |
    23 |
    ); 24 | } 25 | } 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/client/subcomponent/ThumbnailPopup.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import '../style/ThumbnailPopup.scss'; 3 | const classNames = require('classnames'); 4 | const _ = require("underscore"); 5 | const util = require("@common/util"); 6 | const clientUtil = require("../clientUtil"); 7 | const { isVideo } = util; 8 | import Sender from '../Sender'; 9 | 10 | class ThumbnailPopup extends Component { 11 | static defaultProps = { 12 | 13 | }; 14 | 15 | constructor(prop) { 16 | super(prop); 17 | this.url = prop.url; 18 | this.isHovering = false; 19 | this.state = {}; 20 | this.useVideoPreviewForFolder = false; 21 | 22 | // a throttled function that can only call the func parameter maximally once per every wait milliseconds. 23 | this.throttleGet = _.throttle(()=> { 24 | this.fetchData(); 25 | }, 1000); 26 | } 27 | 28 | componentDidUpdate(prevProps) { 29 | if (this.props.url && prevProps.url !== this.props.url && !this.state.url) { 30 | this.setState({ 31 | url: this.props.url 32 | }); 33 | } 34 | } 35 | 36 | askRerender(){ 37 | this.setState({ 38 | rerenderTick: !this.state.rerenderTick 39 | }) 40 | } 41 | 42 | async fetchData () { 43 | const { filePath} = this.props; 44 | if(isVideo(filePath)){ 45 | return; 46 | } 47 | 48 | if(!this.url){ 49 | const api = clientUtil.getQuickThumbUrl(filePath); 50 | const res = await Sender.getWithPromise(api); 51 | if (res.isFailed() || !res.json.url) { 52 | // todo 53 | // nothing 54 | } else { 55 | this.url = clientUtil.getFileUrl(res.json.url); 56 | this.useVideoPreviewForFolder = res.json.useVideoPreviewForFolder; 57 | } 58 | this.askRerender(); 59 | } 60 | } 61 | 62 | onMouseMove(){ 63 | this.isHovering = true; 64 | this.throttleGet(); 65 | this.askRerender(); 66 | } 67 | 68 | onMouseOut(){ 69 | this.isHovering = false; 70 | this.askRerender(); 71 | } 72 | 73 | render() { 74 | const { children, filePath, FileName } = this.props; 75 | const { isHovering, url } = this; 76 | const cn = classNames("thumbnail-popup-wrap", { 77 | "open": isHovering 78 | }) 79 | 80 | let extraDom = null; 81 | let titleStr = clientUtil.getBaseName(filePath); 82 | titleStr = util.truncateString(titleStr, 35); 83 | if(this.isHovering){ 84 | if(isVideo(filePath)|| (this.useVideoPreviewForFolder && this.url)){ 85 | let src; 86 | if(isVideo(filePath)){ 87 | src = clientUtil.getFileUrl(filePath); 88 | }else{ 89 | src = this.url; 90 | } 91 | 92 | extraDom = (
    93 |
    {titleStr}
    94 | 97 |
    ) 98 | 99 | } else if(url){ 100 | extraDom = (
    101 |
    {titleStr}
    102 | 103 |
    ) 104 | }else{ 105 | extraDom = (
    106 |
    {titleStr}
    107 |
    NO_THUMBNAIL_AVAILABLE
    108 |
    ) 109 | } 110 | } 111 | 112 | return ( 113 |
    114 | {children} 115 | {extraDom} 116 |
    ); 117 | } 118 | } 119 | 120 | 121 | export default ThumbnailPopup; 122 | -------------------------------------------------------------------------------- /src/common/constant.js: -------------------------------------------------------------------------------- 1 | module.exports.MODE_TAG = "mode tag"; 2 | module.exports.MODE_AUTHOR = "mode author"; 3 | module.exports.MODE_SEARCH = "mode search"; 4 | module.exports.MODE_EXPLORER = "mode explorer"; 5 | 6 | -------------------------------------------------------------------------------- /src/common/util.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | //https://stackoverflow.com/questions/11852589/what-image-formats-do-the-major-browsers-support-2012 4 | const imageTypes = [".jpg", ".png", ".jpeg", ".gif", ".bmp", ".webp", ".avif"]; 5 | const compressTypes = [".zip", ".rar", ".7zip", ".7z", ".gzip", ".tar"]; 6 | const musicTypes = [".mp3", ".wav", ".m4a", ".wma", ".flac", ".ogg", ".m4p", ".aiff"]; 7 | const videoTypes = [".mp4", ".3gp", ".avi", ".mov",".mp4", ".m4v", ".mkv", ".webm", ".flv"]; 8 | 9 | function escapeDot(arr) { 10 | return arr.map(e => e.replace(".", "\\.")) 11 | } 12 | 13 | //ends with 14 | const imageTypesRegex = new RegExp("(" + escapeDot(imageTypes).join("|") + ")$"); 15 | const compressTypesRegex = new RegExp("(" + escapeDot(compressTypes).join("|") + ")$"); 16 | const musicTypesRegex = new RegExp("(" + escapeDot(musicTypes).join("|") + ")$"); 17 | const videoTypesRegex = new RegExp("(" + escapeDot(videoTypes).join("|") + ")$"); 18 | 19 | 20 | function isOnlyDigit(str) { 21 | return str.match(/^[0-9]+$/) != null 22 | } 23 | 24 | module.exports.isGif = function (fn) { 25 | return fn.toLowerCase().endsWith(".gif"); 26 | }; 27 | 28 | /** 29 | * 是否为图片文件 30 | */ 31 | const isImage = module.exports.isImage = function (fn) { 32 | return !!fn.toLowerCase().match(imageTypesRegex); 33 | }; 34 | 35 | /** 36 | * 是否为压缩文件 37 | */ 38 | const isCompress = module.exports.isCompress = function (fn) { 39 | return !!fn.toLowerCase().match(compressTypesRegex); 40 | }; 41 | 42 | /** 43 | * 是否为音乐文件 44 | */ 45 | const isMusic = module.exports.isMusic = function (fn) { 46 | return !!fn.toLowerCase().match(musicTypesRegex); 47 | } 48 | 49 | const isVideo = module.exports.isVideo = function (fn) { 50 | return !!fn.toLowerCase().match(videoTypesRegex); 51 | } 52 | 53 | const companyNames = "ABP ATFB AVOP CPDE CSCT DASD EBOD FDGD GANA GGG HND HNDS ID IPX IPZ KAWD LCBD LXVS MDS MIDE MIMK MIRD MUKC NHDTA PGD PPPD PPT REBDB SDDE SHKD SNIS SOE SSNI STAR TEK TONY TPRO TSDV WANZ WAT YRZ ZUKO DAP"; 54 | const avRegex = new RegExp(companyNames.split(" ").filter(e => e.length > 1).map(e => `${e}\\d{3}`).join("|")); 55 | 56 | module.exports.isAv = function (fn) { 57 | if (!isVideo(fn)) { 58 | return false; 59 | } 60 | 61 | //example ABP-265 62 | if (/[A-Za-z]{2,}-\d{3}/.test(fn)) { 63 | return true; 64 | } 65 | 66 | //ABP264 67 | const fnUp = fn.toUpperCase(); 68 | return avRegex.test(fnUp); 69 | } 70 | 71 | //not for .gif 72 | const compressable = [".jpg", ".jpeg", ".png", ".avif", "webp", ".bmp"] 73 | module.exports.canBeCompressed = function (fn) { 74 | const fnl = fn.toLowerCase(); 75 | return compressable.some((e) => fnl.endsWith(e)); 76 | } 77 | 78 | const hasDuplicate = module.exports.hasDuplicate = (arr) => { 79 | return new Set(arr).size !== arr.length; 80 | } 81 | 82 | 83 | /** 84 | * 用来排序图片和mp3的。files既可能是filename也可能是filepath 85 | */ 86 | module.exports._sortFileNames = function (files, getBaseNameWithoutExtention) { 87 | // assertion 88 | // files.forEach((e, ii) => { 89 | // const good = (!e.includes("/") && !e.includes("\\")); 90 | // console.assert(good); 91 | // }) 92 | 93 | // 奇怪了,以前的sort有numeric这个选项吗,还是我重新发明轮子了? 94 | // A:The Intl.Collator object was introduced in ECMAScript 2015 (ES6). 好像10年前就有了?? 95 | files.sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); 96 | return; 97 | 98 | // if (!getBaseNameWithoutExtention) { 99 | // throw "no getBaseNameWithoutExtention"; 100 | // } 101 | 102 | // const isAllDigit = files.every(e => { 103 | // return isOnlyDigit(getBaseNameWithoutExtention(e)) 104 | // }); 105 | 106 | // // check if duplicate filename 107 | // const fns = files.map(getBaseNameWithoutExtention); 108 | // let isDup = hasDuplicate(fns); 109 | 110 | 111 | // if (isAllDigit && !isDup) { 112 | // files.sort((a, b) => { return parseInt(getBaseNameWithoutExtention(a)) - parseInt(getBaseNameWithoutExtention(b)) }); 113 | // } else { 114 | // files.sort((a, b) => a.localeCompare(b)); 115 | // } 116 | }; 117 | 118 | module.exports.pause = async (time) => { 119 | await new Promise(resolve => setTimeout(resolve, time)); 120 | } 121 | 122 | module.exports.arraySlice = function (arr, beg, end) { 123 | const len = arr.length; 124 | let _beg = beg >= 0 ? beg : len + beg; 125 | let _end = end >= 0 ? end : len + end; 126 | 127 | let result = []; 128 | if (beg >= 0 && end >= 0) { 129 | //normal 130 | result = arr.slice(beg, end); 131 | } else if (beg < 0 && end > 0) { 132 | result = arr.slice(_beg).concat(arr.slice(0, end)); 133 | } else if (beg >= 0 && end < 0) { 134 | result = arr.slice(beg, _end); 135 | } else { 136 | throw "wrf dude" 137 | } 138 | return result; 139 | } 140 | 141 | module.exports.cutIntoSmallArrays = (arr, size)=> { 142 | size = size || 10000; 143 | const result = []; 144 | for (let i = 0; i < arr.length; i += size) { 145 | const chunk = arr.slice(i, i + size); 146 | result.push(chunk); 147 | } 148 | return result; 149 | } 150 | 151 | module.exports.getCurrentTime = function () { 152 | return new Date().getTime(); 153 | } 154 | 155 | module.exports.isDisplayableInExplorer = function (e) { 156 | return isCompress(e) || isVideo(e); 157 | } 158 | 159 | module.exports.isDisplayableInOnebook = function (e) { 160 | return isImage(e) || isMusic(e); 161 | } 162 | 163 | module.exports.escapeRegExp = function (string) { 164 | const str = string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string 165 | const reg = new RegExp(str, 'i'); 166 | return reg; 167 | } 168 | 169 | module.exports.isWindowsPath = function (string) { 170 | return /[A-Za-z]:/.test(string); 171 | } 172 | 173 | /** 174 | * 求平均数 175 | */ 176 | module.exports.getAverage = function(intArray) { 177 | if (intArray.length === 0) { 178 | return 0; 179 | } 180 | 181 | const sum = intArray.reduce((acc, val) => acc + val); 182 | const avg = sum / intArray.length; 183 | 184 | return avg; 185 | } 186 | 187 | 188 | /** 写一个js函数,把string留头留尾,中间的字符换成省略号。穿参数设置最终字符数 */ 189 | const truncateString = module.exports.truncateString = (str, maxLength) => { 190 | if (str.length <= maxLength) return str; 191 | const ellipsis = '...'; 192 | const truncatedLength = maxLength - ellipsis.length; 193 | const frontChars = Math.ceil(truncatedLength / 2); 194 | const backChars = Math.floor(truncatedLength / 2); 195 | const truncatedString = str.substr(0, frontChars) + ellipsis + str.substr(str.length - backChars); 196 | return truncatedString; 197 | } 198 | 199 | -------------------------------------------------------------------------------- /src/config/port-config.js: -------------------------------------------------------------------------------- 1 | // default port 2 | module.exports.default_http_port = 3000; 3 | 4 | -------------------------------------------------------------------------------- /src/config/user-config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const config = {}; 4 | 5 | //----------------- below section used by developer----------------------------- 6 | 7 | config.workspace_name = "workspace"; 8 | 9 | config.cache_folder_name = "cache"; 10 | 11 | config.thumbnail_folder_name = "thumbnails"; 12 | 13 | config.img_convert_cache = "minified_zip_cache" 14 | 15 | config.zip_output_cache = "zip_output"; 16 | 17 | //delete or move to recyle bin 18 | //删除操作是真的彻底删除还是丢进回收站 19 | config.move_file_to_recyle = true; 20 | 21 | //wehter to use meaningful file name in cache folder 22 | //or encode they by hash function 23 | config.readable_cache_folder_name = false; 24 | 25 | //漫画阅读中两页clip在一起以后,翻页是不是还要接着拼在一起 26 | //wether to clip page 27 | config.keep_clip = false; 28 | 29 | //漫画阅读中调整图片高宽比例以后,翻页是否还要保存 30 | //wether to keep zoom scale 31 | config.keep_zoom_scale = false; 32 | 33 | //----------------------------image compress parameter------------------------------------------------- 34 | 35 | //in MB, only for website UI display 36 | config.oversized_image_size = 4; 37 | 38 | // the algo is as following 39 | // size <= img_convert_min_threshold: do not minify 40 | // img_convert_min_threshold < size < img_convert_huge_threshold: image compress 41 | // img_convert_huge_threshold <= size: image compress and reduce resolution 42 | 43 | //压缩图片的时候用的参数 传给magick用的 44 | //magick compress output quality for huge file 45 | config.img_convert_quality = 60; 46 | 47 | //magick compress output quality for middle-size file 48 | config.img_convert_quality_for_middle_size_file = 70; 49 | 50 | //超过这个大小,再转换的时候同时压低分辨率。 51 | //现在太多漫画,扫描出来一来4000*6000。完全没有必要 52 | config.img_convert_huge_threshold = 6; //in MB 53 | 54 | //小于这个大小,没有转换的必要 55 | config.img_convert_min_threshold = 1.5; //in MB 56 | 57 | // output file format 58 | config.img_convert_dest_type = ".jpg"; 59 | 60 | //Only Shrink huge Images ('>' flag) 61 | //参考资料:http://www.imagemagick.org/Usage/resize/#shrink 62 | //不必担心,会保持比例,高宽都低于规定的比例。 63 | config.img_reduce_resolution_dimension = "4000x4000"; 64 | 65 | //-------------------------------- 66 | // config.file_server_port = "15001"; 67 | 68 | 69 | //--------------------------------------------------------------------------------------- 70 | //uses can view folder that has images as a zip 71 | //so users do not have zip their manga 72 | //But this may cause more Memory usage 73 | //可以阅读文件夹的图片,就不需要打包成zip 74 | //但可能用很多内存 75 | config.view_img_folder = true; 76 | 77 | //do not display a zip if it has no image files or music files or video files 78 | config.filter_empty_zip = true; 79 | 80 | //------------------------------------------------------------------ 81 | module.exports = config; 82 | -------------------------------------------------------------------------------- /src/name-parser/ParserUtil.js: -------------------------------------------------------------------------------- 1 | // https://stackoverflow.com/questions/11919065/sort-an-array-by-the-levenshtein-distance-with-best-performance-in-javascript 2 | function editDistance(s, t) { 3 | if (s === t) { 4 | return 0; 5 | } 6 | var n = s.length, m = t.length; 7 | if (n === 0 || m === 0) { 8 | return n + m; 9 | } 10 | var x = 0, y, a, b, c, d, g, h, k; 11 | var p = new Array(n); 12 | for (y = 0; y < n;) { 13 | p[y] = ++y; 14 | } 15 | 16 | for (; (x + 3) < m; x += 4) { 17 | var e1 = t.charCodeAt(x); 18 | var e2 = t.charCodeAt(x + 1); 19 | var e3 = t.charCodeAt(x + 2); 20 | var e4 = t.charCodeAt(x + 3); 21 | c = x; 22 | b = x + 1; 23 | d = x + 2; 24 | g = x + 3; 25 | h = x + 4; 26 | for (y = 0; y < n; y++) { 27 | k = s.charCodeAt(y); 28 | a = p[y]; 29 | if (a < c || b < c) { 30 | c = (a > b ? b + 1 : a + 1); 31 | } 32 | else { 33 | if (e1 !== k) { 34 | c++; 35 | } 36 | } 37 | 38 | if (c < b || d < b) { 39 | b = (c > d ? d + 1 : c + 1); 40 | } 41 | else { 42 | if (e2 !== k) { 43 | b++; 44 | } 45 | } 46 | 47 | if (b < d || g < d) { 48 | d = (b > g ? g + 1 : b + 1); 49 | } 50 | else { 51 | if (e3 !== k) { 52 | d++; 53 | } 54 | } 55 | 56 | if (d < g || h < g) { 57 | g = (d > h ? h + 1 : d + 1); 58 | } 59 | else { 60 | if (e4 !== k) { 61 | g++; 62 | } 63 | } 64 | p[y] = h = g; 65 | g = d; 66 | d = b; 67 | b = c; 68 | c = a; 69 | } 70 | } 71 | 72 | for (; x < m;) { 73 | var e = t.charCodeAt(x); 74 | c = x; 75 | d = ++x; 76 | for (y = 0; y < n; y++) { 77 | a = p[y]; 78 | if (a < c || d < c) { 79 | d = (a > d ? d + 1 : a + 1); 80 | } 81 | else { 82 | if (e !== s.charCodeAt(y)) { 83 | d = c + 1; 84 | } 85 | else { 86 | d = c; 87 | } 88 | } 89 | p[y] = d; 90 | c = a; 91 | } 92 | h = d; 93 | } 94 | 95 | return h; 96 | } 97 | 98 | function getExtraTags(str) { 99 | // [161109] TVアニメ「ラブライブ!サンシャイン!!」挿入歌シングル3「想いよひとつになれ/MIRAI TICKET」/Aqours [320K].zip 100 | //[180727]TVアニメ『音楽少女』OPテーマ「永遠少年」/小倉唯[320K].rar 101 | let jpbReg = /「(.*?)」/g; 102 | const macthes = matchAll(jpbReg, str) || []; 103 | 104 | let jpbReg2 = /『(.*?)』/g; 105 | const macthes2 = matchAll(jpbReg2, str) || []; 106 | 107 | return (macthes.concat(macthes2)).map(e => { 108 | return e.trim(); 109 | }) 110 | } 111 | 112 | function compareInternalDigit(s1, s2) { 113 | const digitTokens1 = s1.match(/\d+/g); 114 | const digitTokens2 = s2.match(/\d+/g); 115 | if (digitTokens1 && digitTokens2) { 116 | if (digitTokens1.length !== digitTokens2.length || 117 | digitTokens1.join() !== digitTokens2.join()) { 118 | return false; 119 | } 120 | } else if (digitTokens1 && !digitTokens2) { 121 | return false; 122 | } else if (!digitTokens1 && digitTokens2) { 123 | return false; 124 | } 125 | return true; 126 | } 127 | 128 | function isHighlySimilar(s1, s2) { 129 | if (!s1 && !s2) { 130 | return true; 131 | } else if (s1 && s2) { 132 | if (!compareInternalDigit(s1, s2)) { 133 | return false; 134 | } 135 | 136 | const distance = editDistance(s1, s2); 137 | const avgLen = (s1.length + s2.length) / 2; 138 | const ratio = distance / (Math.ceil(avgLen)); 139 | 140 | return ratio <= 0.2; 141 | } else { 142 | return false; 143 | } 144 | } 145 | 146 | function matchAll(reg, str) { 147 | const result = []; 148 | var token = reg.exec(str); 149 | while (token) { 150 | if(token[1]){ 151 | result.push(token[1]); 152 | } 153 | token = reg.exec(str); 154 | } 155 | return result; 156 | } 157 | 158 | module.exports = { 159 | editDistance, 160 | getExtraTags, 161 | compareInternalDigit, 162 | isHighlySimilar, 163 | matchAll 164 | } -------------------------------------------------------------------------------- /src/name-parser/character-names.js: -------------------------------------------------------------------------------- 1 | module.exports =[ 2 | //kankore http://dunkel.halfmoon.jp/kancolle/ 3 | "長門", 4 | "陸奥", 5 | "伊勢", 6 | "日向", 7 | "雪風", 8 | "赤城", 9 | "加賀", 10 | "蒼龍", 11 | "飛龍", 12 | "島風", 13 | "吹雪", 14 | "白雪", 15 | "初雪", 16 | "深雪", 17 | "叢雲", 18 | "磯波", 19 | "綾波", 20 | "敷波", 21 | "大井", 22 | "北上", 23 | "金剛", 24 | "比叡", 25 | "榛名", 26 | "霧島", 27 | "鳳翔", 28 | "扶桑", 29 | "山城", 30 | "天龍", 31 | "龍田", 32 | "龍驤", 33 | "睦月", 34 | "如月", 35 | "皐月", 36 | "文月", 37 | "長月", 38 | "菊月", 39 | "三日月", 40 | "望月", 41 | "球磨", 42 | "多摩", 43 | "木曾", 44 | "長良", 45 | "五十鈴", 46 | "名取", 47 | "由良", 48 | "川内", 49 | "神通", 50 | "那珂", 51 | "千歳", 52 | "千代田", 53 | "最上", 54 | "古鷹", 55 | "加古", 56 | "青葉", 57 | "妙高", 58 | "那智", 59 | "足柄", 60 | "羽黒", 61 | "高雄", 62 | "愛宕", 63 | "摩耶", 64 | "鳥海", 65 | "利根", 66 | "筑摩", 67 | "飛鷹", 68 | "隼鷹", 69 | "朧", 70 | "曙", 71 | "漣", 72 | "潮", 73 | "暁", 74 | "響", 75 | "雷", 76 | "電", 77 | "初春", 78 | "子日", 79 | "若葉", 80 | "初霜", 81 | "白露", 82 | "時雨", 83 | "村雨", 84 | "夕立", 85 | "五月雨", 86 | "涼風", 87 | "朝潮", 88 | "大潮", 89 | "満潮", 90 | "荒潮", 91 | "霰", 92 | "霞", 93 | "陽炎", 94 | "不知火", 95 | "黒潮", 96 | "祥鳳", 97 | "千歳甲", 98 | "千代田甲", 99 | "千歳航", 100 | "千代田航", 101 | "翔鶴", 102 | "瑞鶴", 103 | "鬼怒", 104 | "阿武隈", 105 | "夕張", 106 | "瑞鳳", 107 | "三隈", 108 | "初風", 109 | "舞風", 110 | "衣笠", 111 | "伊19", 112 | "鈴谷", 113 | "熊野", 114 | "伊168", 115 | "伊58", 116 | "伊8", 117 | "大和", 118 | "秋雲", 119 | "夕雲", 120 | "巻雲", 121 | "長波", 122 | "阿賀野", 123 | "能代", 124 | "矢矧", 125 | "酒匂", 126 | "武蔵", 127 | "Верный", 128 | "大鳳", 129 | "香取", 130 | "伊401", 131 | "あきつ丸", 132 | "神威", 133 | "まるゆ", 134 | "弥生", 135 | "卯月", 136 | "磯風", 137 | "浦風", 138 | "谷風", 139 | "浜風", 140 | "Bismarck", 141 | "Bismarck zwei", 142 | "Z1", 143 | "Z3", 144 | "Prinz Eugen", 145 | "Bismarck drei", 146 | "Z1 zwei", 147 | "Z3 zwei", 148 | "天津風", 149 | "明石", 150 | "大淀", 151 | "大鯨", 152 | "龍鳳", 153 | "時津風", 154 | "雲龍", 155 | "天城", 156 | "葛城", 157 | "春雨", 158 | "早霜", 159 | "清霜", 160 | "朝雲", 161 | "山雲", 162 | "野分", 163 | "秋月", 164 | "照月", 165 | "初月", 166 | "高波", 167 | "朝霜", 168 | "U-511", 169 | "Graf Zeppelin", 170 | "Saratoga", 171 | "呂500", 172 | "Warspite", 173 | "Iowa", 174 | "Littorio", 175 | "Roma", 176 | "Libeccio", 177 | "Aquila", 178 | "秋津洲", 179 | "Italia", 180 | "Zara", 181 | "Pola", 182 | "瑞穂", 183 | "沖波", 184 | "風雲", 185 | "嵐", 186 | "萩風", 187 | "親潮", 188 | "山風", 189 | "海風", 190 | "江風", 191 | "速吸", 192 | "鹿島", 193 | "神風", 194 | "朝風", 195 | "春風", 196 | "松風", 197 | "旗風", 198 | "天霧", 199 | "狭霧", 200 | "水無月", 201 | "伊26", 202 | "浜波", 203 | "藤波", 204 | "浦波", 205 | "Commandant Teste", 206 | "Richelieu", 207 | "伊400", 208 | "伊13", 209 | "伊14", 210 | "Zara due", 211 | "Гангут", 212 | "Ташкент", 213 | "Ark Royal", 214 | "Гангут два", 215 | "占守", 216 | "国後", 217 | "Jervis", 218 | "春日丸", 219 | "神鷹", 220 | "Luigi Torelli", 221 | "大鷹", 222 | "岸波", 223 | "UIT-25", 224 | "伊504", 225 | "涼月", 226 | "択捉", 227 | "松輪", 228 | "佐渡", 229 | "対馬", 230 | "日振", 231 | "大東", 232 | "福江", 233 | "Nelson", 234 | "Gotland", 235 | "Maestrale", 236 | 237 | "穂乃果", 238 | "ことり", 239 | 240 | "エルフ" 241 | ].filter(e => e.length > 1); 242 | -------------------------------------------------------------------------------- /src/name-parser/name-parser-config.js: -------------------------------------------------------------------------------- 1 | module.exports.same_tag_regs_table = { 2 | "東方Project": [/^東方$/, /Touhou\s*Project/, /東方project/], 3 | "オリジナル": [/^Original$/], 4 | "Kanon": [/カノン|Kanon/], 5 | "艦これ": [/艦これ|舰これ/, /艦隊これくしょん/, /Kantai\s*Collection/, /KanColle/], 6 | "ラブライブ!": [/Love Live/, /ラブライブ/], 7 | "ラブライブ!サンシャイン!!": [/ラブライブ.*サンシャイン.*/], 8 | "プリンセスコネクト!Re:Dive": [/プリンセスコネクト.*Re.*Dive/], 9 | "Fate/Grand Order": [/Fate.*Grand.*Order/, /FGO/], 10 | "Fate/Stay Night": [/Fate.*Stay.*Night/], 11 | "Fate/Zero": [/Fate.*Zero/], 12 | "Fate/kaleid liner プリズマ☆イリヤ": [/Fate.*kaleid.*liner.*プリズマ.*イリヤ/, /Fate.*kaleid.*liner/, /プリズマ.*イリヤ/], 13 | "Fate": [/^Fate\s*/*\w+/], 14 | "アイドルマスター": [/アイドルマスタ/, /DOL.*M@STER/, /dol.*master/, /アイマス/], 15 | "アイドルマスター シンデレラガールズ": [/アイドルマスター.*シンデレラガールズ/, /DOLM@STER.*CINDERELLA.*GIRLS/], 16 | "アイドルマスター ミリオンライブ": [/アイドルマスター.*ミリオン/, /ミリオンライブ/], 17 | "アイドルマスター シャイニーカラーズ": [/アイドルマスター.*シャイニーカラーズ/], 18 | "アズールレーン": [/Azur Lane/], 19 | "ガールズ&パンツァー": [/Girls.*nd.*Panzer/], 20 | "けいおん": [/けいおん/, /K-ON/], 21 | "プリキュア": [/プリキュア/], 22 | "To LOVEる": [/To.*LOVEる/, /To.*LOVE.*ru/], 23 | "魔法少女まどか☆マギカ": [/まどか.*マギカ|PuellaMagiMadoka/], 24 | "アイカツ!": [/アイカツ.*/], 25 | "エヴァンゲリオン": [/エヴァンゲリオン/, /^エヴァ$/, /Evangelion/], 26 | "Angel Beats": [/Angel.*Beats.*/, /エンジェル.*ビーツ/], 27 | "Dead Or Alive": [/Dead Or Alive/, /デッド.*オア.*アライヴ/, /DEADorALIVE/], 28 | "IS <インフィニット・ストラトス>": [/S.*インフィニット.*ストラトス.*/, /インフィニット.*ストラトス/], 29 | "D.C~ダ・カーポ": [/D\.C\./, /ダ.*カーポ/], 30 | "Dog Days": [/Dog.*Days.*/], 31 | "Dream C Club": [/Dream.*C.*Club/], 32 | "ガンダム ": [/ガンダム|gundam/], 33 | "コードギアス": [/コードギアス/], 34 | "ご注文はうさぎですか": [/ご注文はうさぎですか/], 35 | "ソードアート・オンライン": [/ソードアート.*オンライン/, /Sword\s*Art\s*Online/], 36 | "ダンガンロンパ": [/ダンガンロンパ/], 37 | "ドラゴンクエスト": [/ドラゴンクエスト/, /Dragon\s*Quest/], 38 | "ファイナルファンタジー": [/ファイナルファンタジー|FinalFantasy/], 39 | "咲 -Saki-": [/咲.*Saki.*/], 40 | "咲-Saki- 阿知賀編": [/咲.*Saki.*阿知賀編/], 41 | "聖剣伝説": [/聖剣伝説/], 42 | "閃乱カグラ": [/閃乱カグラ/], 43 | "魔法少女リリカルなのは": [/魔法少女リリカルなのは.*|Nanoha$|^なのは$/], 44 | "キング・オブ・ファイター": [/キング\.オブ\.ファイター/, /^KOF$/], 45 | "ファイアーエムブレム": [/ファイアーエムブレム/], 46 | "ファンタシースター": [/ファンタシースター|PhantasyStar/], 47 | "To Heart 2": [/To.*Heart.*2|トゥハート2/] 48 | } 49 | 50 | module.exports.not_author_but_tag = [ 51 | "同人音声", 52 | "同人誌", 53 | "アンソロジー", 54 | "DL版", 55 | "よろず", 56 | "成年コミック", 57 | "Pixiv", 58 | "アーティスト", 59 | "雑誌", 60 | "English", 61 | "Chinese", 62 | "320K" 63 | ] 64 | -------------------------------------------------------------------------------- /src/name-parser/readme.txt: -------------------------------------------------------------------------------- 1 | all in one是给tamper monkey require用的 2 | 3 | 4 | npx webpack --config webpack.config.js -------------------------------------------------------------------------------- /src/name-parser/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | // module.exports = { 4 | // mode: 'development', // 指定为开发模式,不进行压缩和混淆 5 | // entry: './index.js', 6 | // output: { 7 | // filename: 'index.js', 8 | // path: path.resolve(__dirname, 'all_in_one') 9 | // }, 10 | 11 | // }; 12 | 13 | 14 | module.exports = { 15 | mode: 'development', 16 | entry: './index.js', 17 | output: { 18 | filename: 'index.js', 19 | path: path.resolve(__dirname, 'all_in_one') 20 | }, 21 | devtool: 'source-map', // 不使用 eval,而是生成 source map 22 | module: { 23 | rules: [ 24 | { 25 | test: /\.js$/, 26 | exclude: /node_modules/, 27 | use: { 28 | loader: 'babel-loader' 29 | } 30 | } 31 | ] 32 | } 33 | }; 34 | -------------------------------------------------------------------------------- /src/test/clientUtil.test.js: -------------------------------------------------------------------------------- 1 | const assert = require("assert"); 2 | const clientUtil = require("../client/clientUtil"); 3 | 4 | describe("clientUtil", function () { 5 | describe("#getSep()", function () { 6 | it('should return "/" for linux and web url path', function () { 7 | const result = clientUtil.getSep("/home/user/example.html"); 8 | assert.strictEqual(result, "/"); 9 | }); 10 | it('should return "\\" for windows path', function () { 11 | const result = clientUtil.getSep("C:\\Users\\user\\example.html"); 12 | assert.strictEqual(result, "\\"); 13 | }); 14 | it('should return "/" for web url', function () { 15 | const result = clientUtil.getSep("http://www.example.com"); 16 | assert.strictEqual(result, "/"); 17 | }); 18 | }); 19 | 20 | describe("#getDir()", function () { 21 | it("should return the directory path of the file path", function () { 22 | const result = clientUtil.getDir( 23 | "C:\\Users\\user\\Documents\\example.html" 24 | ); 25 | assert.strictEqual(result, "C:\\Users\\user\\Documents"); 26 | }); 27 | 28 | it("should return an empty string if the file path is empty", function () { 29 | const result = clientUtil.getDir(""); 30 | assert.strictEqual(result, ""); 31 | }); 32 | 33 | it("should return the correct directory path with slash separator on Linux", function () { 34 | const result = clientUtil.getDir("/home/user/example.html"); 35 | assert.strictEqual(result, "/home/user"); 36 | }); 37 | 38 | it("folder 1", function () { 39 | const result = clientUtil.getDir("C:\\Users\\user\\Documents"); 40 | assert.strictEqual(result, "C:\\Users\\user"); 41 | }); 42 | 43 | it("folder 2", function () { 44 | const result = clientUtil.getDir("C:\\Users\\user\\Documents\\"); 45 | assert.strictEqual(result, "C:\\Users\\user"); 46 | }); 47 | }); 48 | 49 | describe("#getBaseName()", function () { 50 | it("should return the base name of the file path", function () { 51 | const result = clientUtil.getBaseName( 52 | "C:\\Users\\user\\Documents\\example.html" 53 | ); 54 | assert.strictEqual(result, "example.html"); 55 | }); 56 | 57 | it("should return the base name of the file path 2", function () { 58 | const result = clientUtil.getBaseName("C:\\Users\\user\\Documents\\"); 59 | assert.strictEqual(result, "Documents"); 60 | }); 61 | 62 | it("should return the base name of the file path 3", function () { 63 | const result = clientUtil.getBaseName("C:\\Users\\user\\Documents\\"); 64 | assert.strictEqual(result, "Documents"); 65 | }); 66 | 67 | it("should return an empty string if the file path is empty", function () { 68 | const result = clientUtil.getBaseName(""); 69 | assert.strictEqual(result, ""); 70 | }); 71 | }); 72 | 73 | describe("#getBaseNameWithoutExtention()", function () { 74 | it("should return the base name without the extension of the file name", function () { 75 | const result = clientUtil.getBaseNameWithoutExtention( 76 | "C:\\Users\\user\\Documents\\example.html" 77 | ); 78 | assert.strictEqual(result, "example"); 79 | }); 80 | it("should return the base name if there is no extension in the file name", function () { 81 | const result = clientUtil.getBaseNameWithoutExtention( 82 | "C:\\Users\\user\\noextension" 83 | ); 84 | assert.strictEqual(result, "noextension"); 85 | }); 86 | it("should return an empty string if the file name is empty", function () { 87 | const result = clientUtil.getBaseNameWithoutExtention(""); 88 | assert.strictEqual(result, ""); 89 | }); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | const { CleanWebpackPlugin } = require('clean-webpack-plugin'); 4 | 5 | const outputDirectory = 'dist'; 6 | 7 | const portConfig = require('./src/config/port-config'); 8 | const { default_http_port } = portConfig; 9 | 10 | const config = { 11 | entry: ['babel-polyfill', './src/client/index.js'], 12 | output: { 13 | path: path.join(__dirname, outputDirectory), 14 | filename: 'bundle.js', 15 | publicPath:"/" 16 | }, 17 | module: { 18 | rules: [ 19 | { 20 | test: /\.js$/, 21 | exclude: /node_modules/, 22 | use: { 23 | loader: 'babel-loader' 24 | } 25 | }, 26 | { 27 | test: /\.css$/, 28 | use: ['style-loader', 'css-loader'] 29 | }, 30 | { 31 | test: /\.(png|woff|woff2|eot|ttf|svg)$/, 32 | // loader: 'url-loader?limit=100000' 33 | use:[ 34 | { 35 | loader: 'url-loader', 36 | options: { 37 | limit: 1000, 38 | } 39 | } 40 | ] 41 | 42 | },{ 43 | test: /\.scss$/, 44 | use: ["style-loader", "css-loader", { 45 | loader: 'sass-loader', 46 | options: { 47 | implementation: require('dart-sass'), 48 | }, 49 | }] 50 | },{ 51 | test: /\.less$/, 52 | use: ["style-loader" ,"css-loader", "less-loader"] 53 | } 54 | ] 55 | }, 56 | devServer: { 57 | open: true, 58 | host: '0.0.0.0', 59 | allowedHosts: "all", 60 | historyApiFallback: true, 61 | hot: false, 62 | proxy: { 63 | '/api': `http://127.0.0.1:${default_http_port}` 64 | }, 65 | static: [{ 66 | directory: path.join(__dirname, 'public'), 67 | publicPath:"/" 68 | },{ 69 | directory: path.join(__dirname, 'resource'), 70 | publicPath:"/" 71 | }, 72 | { 73 | directory: __dirname, 74 | publicPath:"/" 75 | }], 76 | port: 9000, 77 | }, 78 | plugins: [ 79 | // new CleanWebpackPlugin([outputDirectory]), 80 | new CleanWebpackPlugin(), 81 | new HtmlWebpackPlugin({ 82 | template: './public/index.html', 83 | favicon: './public/favicon-96x96.png' 84 | }) 85 | ] 86 | }; 87 | 88 | config.resolve = { 89 | alias: { 90 | "@common": path.resolve(__dirname, 'src/common/'), 91 | "@config": path.resolve(__dirname, 'src/config/'), 92 | "@name-parser": path.resolve(__dirname, 'src/name-parser/index'), 93 | } 94 | } 95 | 96 | module.exports = config; 97 | --------------------------------------------------------------------------------