├── client ├── src │ ├── assets │ │ ├── .gitkeep │ │ ├── robots.txt │ │ ├── favicon.ico │ │ ├── logo.webp │ │ └── apple-touch-icon-precomposed.png │ ├── app │ │ ├── about │ │ │ ├── about.component.html │ │ │ └── about.component.ts │ │ ├── bookmark │ │ │ ├── bookmark.component.html │ │ │ └── bookmark.component.ts │ │ ├── common │ │ │ ├── book-suggest.ts │ │ │ ├── proxy.service.ts │ │ │ ├── bootstrap.component.ts │ │ │ ├── nav.service.ts │ │ │ ├── session.service.ts │ │ │ ├── bootstrap.component.html │ │ │ └── type.ts │ │ ├── history │ │ │ ├── history.component.html │ │ │ ├── history.component.ts │ │ │ └── history.service.ts │ │ ├── setting │ │ │ ├── setting.component.ts │ │ │ ├── setting.component.html │ │ │ └── setting.service.ts │ │ ├── app-routing.module.ts │ │ ├── search │ │ │ ├── search.component.ts │ │ │ ├── search.service.ts │ │ │ └── search.component.html │ │ └── app.module.ts │ ├── favicon.ico │ ├── style │ │ ├── header.scss │ │ ├── index.scss │ │ ├── setting.scss │ │ ├── history.scss │ │ ├── search.scss │ │ └── init.scss │ ├── main.ts │ └── index.html ├── .config.default ├── .vscode │ ├── extensions.json │ ├── launch.json │ └── tasks.json ├── tsconfig.app.json ├── tsconfig.spec.json ├── .editorconfig ├── Makefile ├── .gitignore ├── tsconfig.json ├── README.md ├── dist │ └── build.sh ├── package.json ├── .eslintrc.json └── angular.json ├── misc ├── logo │ ├── readme.webp │ ├── original.png │ └── convert.sh └── nginx │ ├── dev.conf │ └── prod.conf ├── PLAN.md ├── LICENSE └── README.md /client/src/assets/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/assets/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: / 3 | -------------------------------------------------------------------------------- /client/src/app/about/about.component.html: -------------------------------------------------------------------------------- 1 |

about works!

2 | -------------------------------------------------------------------------------- /client/.config.default: -------------------------------------------------------------------------------- 1 | port = 22030 2 | domain = zlib.anna.9farm.com 3 | -------------------------------------------------------------------------------- /misc/logo/readme.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhengkai/zebra/HEAD/misc/logo/readme.webp -------------------------------------------------------------------------------- /client/src/app/bookmark/bookmark.component.html: -------------------------------------------------------------------------------- 1 |
coming (not) soon
2 | -------------------------------------------------------------------------------- /client/src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhengkai/zebra/HEAD/client/src/favicon.ico -------------------------------------------------------------------------------- /misc/logo/original.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhengkai/zebra/HEAD/misc/logo/original.png -------------------------------------------------------------------------------- /client/src/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhengkai/zebra/HEAD/client/src/assets/favicon.ico -------------------------------------------------------------------------------- /client/src/assets/logo.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhengkai/zebra/HEAD/client/src/assets/logo.webp -------------------------------------------------------------------------------- /client/src/assets/apple-touch-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhengkai/zebra/HEAD/client/src/assets/apple-touch-icon-precomposed.png -------------------------------------------------------------------------------- /client/src/style/header.scss: -------------------------------------------------------------------------------- 1 | mat-toolbar { 2 | > span { 3 | display: block; 4 | width: 100%; 5 | max-width: 1280px; 6 | margin: 0 auto; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /client/src/style/index.scss: -------------------------------------------------------------------------------- 1 | @import "init.scss"; 2 | @import "header.scss"; 3 | @import "search.scss"; 4 | @import "setting.scss"; 5 | @import "history.scss"; 6 | -------------------------------------------------------------------------------- /client/src/style/setting.scss: -------------------------------------------------------------------------------- 1 | section.op { 2 | padding-top: 2rem; 3 | text-align: center; 4 | button { 5 | margin-left: 1rem; 6 | zoom: 125%; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /client/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846 3 | "recommendations": ["angular.ng-template"] 4 | } 5 | -------------------------------------------------------------------------------- /client/src/app/common/book-suggest.ts: -------------------------------------------------------------------------------- 1 | const list = [ 2 | '静静的顿河', 3 | '马丁·伊登', 4 | '卧底经济学', 5 | '皇帝新脑', 6 | '四世同堂', 7 | '元素的盛宴', 8 | ]; 9 | 10 | export const BookSuggest = { 11 | name: list[+Date.now() % list.length], 12 | }; 13 | -------------------------------------------------------------------------------- /client/src/main.ts: -------------------------------------------------------------------------------- 1 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 2 | 3 | import { AppModule } from './app/app.module'; 4 | 5 | 6 | platformBrowserDynamic().bootstrapModule(AppModule) 7 | .catch(err => console.error(err)); 8 | -------------------------------------------------------------------------------- /client/src/app/about/about.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-about', 5 | templateUrl: './about.component.html', 6 | styles: [ 7 | ] 8 | }) 9 | export class AboutComponent { 10 | 11 | } 12 | -------------------------------------------------------------------------------- /client/src/app/common/proxy.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { SearchArgs } from './type'; 3 | 4 | @Injectable({ 5 | providedIn: 'root', 6 | }) 7 | export class ProxyService { 8 | 9 | saveHistory = (a: SearchArgs) => { 10 | console.log(a); 11 | }; 12 | } 13 | -------------------------------------------------------------------------------- /client/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/app", 6 | "types": [] 7 | }, 8 | "files": [ 9 | "src/main.ts" 10 | ], 11 | "include": [ 12 | "src/**/*.d.ts" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /client/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/spec", 6 | "types": [ 7 | "jasmine" 8 | ] 9 | }, 10 | "include": [ 11 | "src/**/*.spec.ts", 12 | "src/**/*.d.ts" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /client/.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.ts] 12 | quote_type = single 13 | 14 | [*.md] 15 | max_line_length = off 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /PLAN.md: -------------------------------------------------------------------------------- 1 | PLAN 2 | ====== 3 | 4 | 2022-12-03 5 | 6 | 在跳板机上搭起了 [zu1k/zlib-searcher](https://github.com/zu1k/zlib-searcher) 后,意识到这是个了不起的东西:本地搜索一个 2GB 的索引文件,从而能下载那几十 TB 的 zlib 上的所有书。真是因祸得福,zlib 被封后,没想到经过网友的改进、下载能变得如此方便。 7 | 8 | 原版的搜索不太好用,不过由于结构简单,所以很好分离。没在原版上提 PR,一个是我不会 vue 只会 angular(能写 hello world 的水平),另外我觉得这个项目应该前后端分成两套代码:前后端 web 可以是多对多的关系,这样任意单一站点挂了,整体还可以继续使用。毕竟这注定是个要经常被封掉的东西。 9 | -------------------------------------------------------------------------------- /misc/logo/convert.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | COLOR="#3f51b5" 4 | 5 | cd "$(dirname "$(readlink -f "$0")")" || exit 1 6 | 7 | convert ./original.png -resize 512x512 -gravity center -background "$COLOR" -extent 512x512 readme.webp 8 | 9 | convert readme.webp -resize 128x128 ../../client/src/assets/favicon.ico 10 | convert readme.webp -resize 96x96 ../../client/src/assets/logo.webp 11 | -------------------------------------------------------------------------------- /client/src/app/bookmark/bookmark.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { NavService } from '../common/nav.service'; 3 | 4 | @Component({ 5 | selector: 'app-bookmark', 6 | templateUrl: './bookmark.component.html', 7 | styles: [ 8 | ], 9 | }) 10 | export class BookmarkComponent { 11 | 12 | constructor( 13 | public nav: NavService, 14 | ) { 15 | this.nav.go('bookmark'); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /client/src/app/common/bootstrap.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { NavService } from './nav.service'; 3 | import { SettingService } from '../setting/setting.service'; 4 | 5 | @Component({ 6 | selector: 'app-root', 7 | templateUrl: './bootstrap.component.html', 8 | styles: [], 9 | }) 10 | export class BootstrapComponent { 11 | title = 'SearchUI'; 12 | 13 | constructor( 14 | public nav: NavService, 15 | public setting: SettingService, 16 | ) { 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /client/src/app/history/history.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

下载历史

4 |

coming (not) soon

5 |
6 | 16 |
17 | -------------------------------------------------------------------------------- /client/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 3 | "version": "0.2.0", 4 | "configurations": [ 5 | { 6 | "name": "ng serve", 7 | "type": "pwa-chrome", 8 | "request": "launch", 9 | "preLaunchTask": "npm: start", 10 | "url": "http://localhost:4200/" 11 | }, 12 | { 13 | "name": "ng test", 14 | "type": "chrome", 15 | "request": "launch", 16 | "preLaunchTask": "npm: test", 17 | "url": "http://localhost:9876/debug.html" 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /client/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Zebra 书籍搜索 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /client/src/style/history.scss: -------------------------------------------------------------------------------- 1 | .history-box { 2 | display: flex; 3 | justify-content: space-between; 4 | > div { 5 | width: calc(50% - 0.5rem); 6 | } 7 | > .download { 8 | color: black; 9 | } 10 | > .search { 11 | > div { 12 | border: 1px solid silver; 13 | padding: 0 1rem 0.5rem; 14 | font-size: 1.125rem; 15 | margin: 0.5rem 0; 16 | &:hover { 17 | background-color: #f3f3f3; 18 | } 19 | > div.op { 20 | display: flex; 21 | justify-content: space-between; 22 | } 23 | } 24 | } 25 | } 26 | @media screen and (max-width: 1000px) { 27 | .history-box { 28 | flex-wrap: wrap; 29 | > div { 30 | width: 100%; 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /client/Makefile: -------------------------------------------------------------------------------- 1 | SHELL:=/bin/bash 2 | 3 | NG := "./node_modules/@angular/cli/bin/ng.js" 4 | 5 | $(shell cp -n .config.default config.ini) 6 | -include config.ini 7 | 8 | local: 9 | @if [ ! -d node_modules ]; then NG_CLI_ANALYTICS=ci npm install; fi 10 | $(NG) serve --port $(port) --host 127.0.0.1 --public-host "$(domain)" 11 | 12 | init: 13 | NG_CLI_ANALYTICS=ci npm install 14 | # ./node_modules/sass-migrator/sass-migrator.js division './node_modules/font-awesome/scss/*.scss' 15 | # npm audit fix 16 | 17 | prod: 18 | ./dist/build.sh zebra.9farm.com prod 19 | ssh dart 'mkdir -p /www/zebra/client' 20 | rsync --partial -vzrtopg -e ssh ./dist/prod/ dart:/www/zebra/client/ 21 | scp ../misc/nginx/prod.conf dart:/www/zebra/nginx.conf 22 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | /config.ini 3 | 4 | # Compiled output 5 | /dist 6 | /tmp 7 | /out-tsc 8 | /bazel-out 9 | 10 | # Node 11 | /node_modules 12 | npm-debug.log 13 | yarn-error.log 14 | 15 | # IDEs and editors 16 | .idea/ 17 | .project 18 | .classpath 19 | .c9/ 20 | *.launch 21 | .settings/ 22 | *.sublime-workspace 23 | 24 | # Visual Studio Code 25 | .vscode/* 26 | !.vscode/settings.json 27 | !.vscode/tasks.json 28 | !.vscode/launch.json 29 | !.vscode/extensions.json 30 | .history/* 31 | 32 | # Miscellaneous 33 | /.angular/cache 34 | .sass-cache/ 35 | /connect.lock 36 | /coverage 37 | /libpeerconnection.log 38 | testem.log 39 | /typings 40 | 41 | # System files 42 | .DS_Store 43 | Thumbs.db 44 | -------------------------------------------------------------------------------- /client/src/app/common/nav.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | @Injectable({ 4 | providedIn: 'root', 5 | }) 6 | export class NavService { 7 | 8 | list = [ 9 | { 10 | key: 'search', 11 | name: '搜索', 12 | icon: 'search', 13 | path: '/', 14 | }, 15 | { 16 | key: 'setting', 17 | name: '设置', 18 | icon: 'settings', 19 | path: '/setting', 20 | }, 21 | { 22 | key: 'history', 23 | name: '历史', 24 | icon: 'history', 25 | path: '/history', 26 | }, 27 | /* 28 | { 29 | key: 'bookmark', 30 | name: '书签', 31 | icon: 'bookmarks', 32 | path: '/bookmark', 33 | }, 34 | */ 35 | ]; 36 | 37 | selectKey = 'search'; 38 | 39 | loading = false; 40 | 41 | // constructor() { } 42 | 43 | go(key: string) { 44 | this.selectKey = key; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /client/src/app/setting/setting.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { NavService } from '../common/nav.service'; 3 | import { SettingService } from './setting.service'; 4 | import { KeyName } from '../common/type'; 5 | 6 | @Component({ 7 | selector: 'app-setting', 8 | templateUrl: './setting.component.html', 9 | styles: [ 10 | ], 11 | }) 12 | export class SettingComponent { 13 | 14 | dlSite = 'https://cloudflare-ipfs.com/'; 15 | 16 | keyName: any = KeyName; 17 | 18 | dlSiteSuggest = [ 19 | 'https://cloudflare-ipfs.com/', 20 | 'https://dweb.link/', 21 | 'https://ipfs.io/', 22 | 'https://gateway.pinata.cloud/', 23 | 'http://127.0.0.1:8080/', 24 | 'ipfs://', 25 | ]; 26 | 27 | constructor( 28 | public nav: NavService, 29 | public srv: SettingService, 30 | ) { 31 | this.nav.go('setting'); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /client/src/app/common/session.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { BookSuggest } from './book-suggest'; 3 | import { SearchKeyList, SearchArgs } from './type'; 4 | 5 | @Injectable({ 6 | providedIn: 'root', 7 | }) 8 | export class SessionService { 9 | 10 | search: SearchArgs; 11 | 12 | constructor() { 13 | let d: any = {}; 14 | let load = false; 15 | try { 16 | const j = localStorage.getItem('session'); 17 | if (j) { 18 | d = JSON.parse(j); 19 | load = true; 20 | } 21 | } catch (x) { 22 | } 23 | if (!load) { 24 | d = BookSuggest; 25 | } 26 | 27 | for (const k of SearchKeyList) { 28 | d[k] = '' + (d?.[k] || ''); 29 | } 30 | this.search = new SearchArgs(d); 31 | } 32 | 33 | save(a: SearchArgs) { 34 | this.search = a; 35 | const j = JSON.stringify(a); 36 | localStorage.setItem('session', j); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /client/src/app/app-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { RouterModule, Routes } from '@angular/router'; 3 | 4 | import { SearchComponent } from './search/search.component'; 5 | import { SettingComponent } from './setting/setting.component'; 6 | import { BookmarkComponent } from './bookmark/bookmark.component'; 7 | import { HistoryComponent } from './history/history.component'; 8 | import { AboutComponent } from './about/about.component'; 9 | 10 | const routes: Routes = [ 11 | { path: '', component: SearchComponent }, 12 | { path: 'setting', component: SettingComponent }, 13 | { path: 'bookmark', component: BookmarkComponent }, 14 | { path: 'history', component: HistoryComponent }, 15 | { path: 'about', component: AboutComponent }, 16 | { path: '**', redirectTo: '/' }, 17 | ]; 18 | 19 | @NgModule({ 20 | imports: [RouterModule.forRoot(routes)], 21 | exports: [RouterModule], 22 | }) 23 | export class RoutingModule { } 24 | -------------------------------------------------------------------------------- /client/src/app/common/bootstrap.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | Zebra LogoZebra 书籍搜索 5 |
6 |
7 | 8 |
9 | 12 |
13 |
14 |
15 | 16 |
17 | 18 | 24 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "compileOnSave": false, 4 | "compilerOptions": { 5 | "baseUrl": "./", 6 | "outDir": "./dist/out-tsc", 7 | "forceConsistentCasingInFileNames": true, 8 | "strict": true, 9 | "noImplicitOverride": true, 10 | "noPropertyAccessFromIndexSignature": true, 11 | "noImplicitReturns": true, 12 | "noFallthroughCasesInSwitch": true, 13 | "sourceMap": true, 14 | "declaration": false, 15 | "downlevelIteration": true, 16 | "experimentalDecorators": true, 17 | "moduleResolution": "node", 18 | "importHelpers": true, 19 | "target": "ES2022", 20 | "module": "ES2022", 21 | "useDefineForClassFields": false, 22 | "lib": [ 23 | "ES2022", 24 | "dom" 25 | ] 26 | }, 27 | "angularCompilerOptions": { 28 | "enableI18nLegacyMessageIdFormat": false, 29 | "strictInjectionParameters": true, 30 | "strictInputAccessModifiers": true, 31 | "strictTemplates": true 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /client/.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558 3 | "version": "2.0.0", 4 | "tasks": [ 5 | { 6 | "type": "npm", 7 | "script": "start", 8 | "isBackground": true, 9 | "problemMatcher": { 10 | "owner": "typescript", 11 | "pattern": "$tsc", 12 | "background": { 13 | "activeOnStart": true, 14 | "beginsPattern": { 15 | "regexp": "(.*?)" 16 | }, 17 | "endsPattern": { 18 | "regexp": "bundle generation complete" 19 | } 20 | } 21 | } 22 | }, 23 | { 24 | "type": "npm", 25 | "script": "test", 26 | "isBackground": true, 27 | "problemMatcher": { 28 | "owner": "typescript", 29 | "pattern": "$tsc", 30 | "background": { 31 | "activeOnStart": true, 32 | "beginsPattern": { 33 | "regexp": "(.*?)" 34 | }, 35 | "endsPattern": { 36 | "regexp": "bundle generation complete" 37 | } 38 | } 39 | } 40 | } 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 郑凯 zhengkai@gmail.com 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 | -------------------------------------------------------------------------------- /client/src/app/history/history.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { Router } from '@angular/router'; 3 | import { NavService } from '../common/nav.service'; 4 | import { HistoryService } from './history.service'; 5 | import { SearchService } from '../search/search.service'; 6 | import { SearchArgs, SearchKeyList, KeyName } from '../common/type'; 7 | 8 | @Component({ 9 | selector: 'app-history', 10 | templateUrl: './history.component.html', 11 | styles: [ 12 | ], 13 | }) 14 | export class HistoryComponent { 15 | 16 | constructor( 17 | public nav: NavService, 18 | public srv: HistoryService, 19 | public search: SearchService, 20 | private router: Router, 21 | ) { 22 | this.nav.go('history'); 23 | } 24 | 25 | text(a: SearchArgs) { 26 | return SearchKeyList.map((k) => { 27 | const s = a[k]; 28 | if (!s.length) { 29 | return; 30 | } 31 | return `${KeyName[k]}:${a[k]}`; 32 | }).filter(s => !!s?.length).join(' ,'); 33 | } 34 | 35 | go(a: SearchArgs) { 36 | this.search.Search(a); 37 | this.router.navigate(['/']); 38 | window.scroll({ 39 | top: 0, 40 | left: 0, 41 | }); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | # SearchUI 2 | 3 | This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 15.0.2. 4 | 5 | ## Development server 6 | 7 | Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The application will automatically reload if you change any of the source files. 8 | 9 | ## Code scaffolding 10 | 11 | Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`. 12 | 13 | ## Build 14 | 15 | Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. 16 | 17 | ## Running unit tests 18 | 19 | Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io). 20 | 21 | ## Running end-to-end tests 22 | 23 | Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To use this command, you need to first add a package that implements end-to-end testing capabilities. 24 | 25 | ## Further help 26 | 27 | To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.io/cli) page. 28 | -------------------------------------------------------------------------------- /client/dist/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | # 4 | # 主要是执行 ng build,额外有这么几点: 5 | # 6 | # 先生成到临时目录,再 rsync 过去,这样编译过程中不影响原目录访问 7 | # 8 | # 大于 1KB 的文件打 gzip,方便 nginx 9 | # 10 | 11 | DOMAIN="$1" 12 | SUB_DIR="$2" 13 | if [ -z "$DOMAIN" ] || [ -z "$SUB_DIR" ]; then 14 | >&2 echo 15 | >&2 echo "usage: $0 [domain] [sub_dir]" 16 | >&2 echo 17 | exit 1 18 | fi 19 | 20 | DIR=$(readlink -f "$0") && DIR=$(dirname "$DIR") && cd "$DIR" 21 | cd .. 22 | 23 | #if [ ! -f ./src/pb/pb.js ]; then 24 | # >&2 echo protobuf not generated 25 | # exit 1 26 | #fi 27 | 28 | URL="https://${DOMAIN}/" 29 | 30 | TMP_DIR="dist/.${SUB_DIR}-tmp" 31 | SUB_DIR="dist/${SUB_DIR}" 32 | 33 | echo 34 | echo " url: $URL" 35 | echo " dir: $SUB_DIR" 36 | echo 37 | 38 | set -x 39 | 40 | NG_CLI_ANALYTICS=ci ./node_modules/@angular/cli/bin/ng.js \ 41 | build --configuration production --output-path "$TMP_DIR" --base-href "$URL" 42 | 43 | mkdir -p "$SUB_DIR" 44 | 45 | rsync -r --delete "${TMP_DIR}/." "$SUB_DIR" 46 | 47 | find "$SUB_DIR" -size +1k -type f \( -name '*.html' -o -name '*.css' -o -name '*.js' -o -name '*.txt' -o -name '*.svg' -o -name '*.ico' \) -exec gzip -k --best {} \; 48 | find "$SUB_DIR" -size +1k -type f \( -name '*.html' -o -name '*.css' -o -name '*.js' -o -name '*.txt' -o -name '*.svg' -o -name '*.ico' \) -exec brotli -Z {} \; 49 | -------------------------------------------------------------------------------- /misc/nginx/dev.conf: -------------------------------------------------------------------------------- 1 | server { 2 | 3 | server_name zlib.anna.9farm.com; 4 | 5 | listen [::]:443 ssl http2; 6 | 7 | ssl_certificate ssl.d/anna.9farm.com.crt; 8 | ssl_certificate_key ssl.d/anna.9farm.com.key; 9 | 10 | access_log /log/zlib/access.log; 11 | error_log /log/zlib/error.log; 12 | 13 | root /www/zlib/client/dist; 14 | 15 | location /ng-cli-ws { 16 | proxy_http_version 1.1; 17 | proxy_set_header Upgrade $http_upgrade; 18 | proxy_set_header Connection "upgrade"; 19 | proxy_set_header Origin $host; 20 | proxy_pass http://127.0.0.1:22030; 21 | } 22 | 23 | location /search { 24 | add_header Access-Control-Allow-Origin *; 25 | proxy_pass http://127.0.0.1:7070; 26 | } 27 | 28 | location / { 29 | proxy_pass http://127.0.0.1:22030; 30 | } 31 | 32 | location /assets { 33 | autoindex on; 34 | root /www/zlib/client/src; 35 | } 36 | 37 | location = /robots.txt { 38 | access_log off; log_not_found off; 39 | root /www/zlib/client/src/assets; 40 | } 41 | location = /favicon.ico { 42 | expires max; 43 | access_log off; log_not_found off; 44 | root /www/zlib/client/src/assets; 45 | } 46 | location ~ /\. { access_log off; log_not_found off; deny all; } 47 | } 48 | 49 | server { 50 | 51 | server_name zlib.anna.9farm.com; 52 | 53 | listen [::]:80; 54 | 55 | location / { 56 | return 301 https://$host$request_uri; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /misc/nginx/prod.conf: -------------------------------------------------------------------------------- 1 | proxy_cache_path /www/zlib/nginx-cache levels=2:2 keys_zone=zlib:50m inactive=48h max_size=1g; 2 | 3 | server { 4 | 5 | server_name zebra.9farm.com; 6 | 7 | listen [::]:443 ssl http2; 8 | 9 | ssl_certificate ssl.d/9farm.com.crt; 10 | ssl_certificate_key ssl.d/9farm.com.key; 11 | 12 | add_header Strict-Transport-Security "max-age=99999999; includeSubDomains; preload" always; 13 | 14 | access_log /log/zebra/access.log; 15 | error_log /log/zebra/error.log; 16 | 17 | root /www/zebra/client; 18 | 19 | location /search { 20 | add_header Access-Control-Allow-Origin *; 21 | proxy_cache zlib; 22 | proxy_cache_key $uri$is_args$args; 23 | proxy_cache_valid 200 304 10m; 24 | proxy_pass http://127.0.0.1:7070; 25 | } 26 | 27 | location / { 28 | try_files $uri $uri/ /index.html; 29 | index index.html; 30 | } 31 | 32 | location = /robots.txt { 33 | expires max; 34 | access_log off; log_not_found off; 35 | root /www/zebra/client/assets; 36 | } 37 | location = /favicon.ico { 38 | expires max; 39 | access_log off; log_not_found off; 40 | root /www/zebra/client/assets; 41 | } 42 | location = /apple-touch-icon-precomposed.png { 43 | expires max; 44 | access_log off; log_not_found off; 45 | root /www/zebra/client/assets; 46 | } 47 | location ~ /\. { access_log off; log_not_found off; deny all; } 48 | } 49 | 50 | server { 51 | 52 | server_name zebra.9farm.com; 53 | 54 | listen [::]:80; 55 | 56 | location / { 57 | return 301 https://$host$request_uri; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /client/src/app/history/history.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { SearchArgs } from '../common/type'; 3 | import { ProxyService } from '../common/proxy.service'; 4 | 5 | @Injectable({ 6 | providedIn: 'root', 7 | }) 8 | export class HistoryService { 9 | 10 | list: SearchArgs[] = []; 11 | 12 | constructor( 13 | public proxy: ProxyService, 14 | ) { 15 | proxy.saveHistory = (a: SearchArgs) => { 16 | this.save(a); 17 | }; 18 | try { 19 | const j = localStorage.getItem('history'); 20 | if (j) { 21 | const list = JSON.parse(j); 22 | for (const a of list) { 23 | this.list.push(new SearchArgs(a)); 24 | } 25 | } 26 | } catch (x) { 27 | } 28 | } 29 | 30 | save(a: SearchArgs) { 31 | this.tidy(a); 32 | if (this.list.length > 50) { 33 | this.list.length = 50; 34 | } 35 | this.store(); 36 | } 37 | 38 | store() { 39 | localStorage.setItem('history', JSON.stringify(this.list)); 40 | } 41 | 42 | del(a: SearchArgs) { 43 | const idx = this.list.indexOf(a); 44 | if (idx < 0) { 45 | return; 46 | } 47 | this.list.splice(idx, 1); 48 | this.store(); 49 | } 50 | 51 | tidy(a: SearchArgs) { 52 | a = new SearchArgs(a); 53 | for (const i in this.list) { 54 | const o = this.list[i]; 55 | if (o.includes(a)) { 56 | this.list.splice(+i, 1); 57 | this.list.unshift(o); 58 | return; 59 | } 60 | } 61 | 62 | this.list = this.list.filter((o) => { 63 | return !a.includes(o); 64 | }); 65 | this.list.unshift(a); 66 | } 67 | 68 | clean() { 69 | this.list.length = 0; 70 | localStorage.removeItem('history'); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "search-ui", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "ng": "ng", 6 | "start": "ng serve", 7 | "build": "ng build", 8 | "watch": "ng build --watch --configuration development", 9 | "test": "ng test", 10 | "lint": "ng lint" 11 | }, 12 | "private": true, 13 | "dependencies": { 14 | "@angular/animations": "^15.1.4", 15 | "@angular/cdk": "^15.1.4", 16 | "@angular/common": "^15.1.4", 17 | "@angular/compiler": "^15.1.4", 18 | "@angular/core": "^15.1.4", 19 | "@angular/forms": "^15.1.4", 20 | "@angular/material": "^15.1.4", 21 | "@angular/platform-browser": "^15.1.4", 22 | "@angular/platform-browser-dynamic": "^15.1.4", 23 | "@angular/router": "^15.1.4", 24 | "rxjs": "~7.8.0", 25 | "tslib": "^2.5.0", 26 | "zone.js": "~0.12.0" 27 | }, 28 | "devDependencies": { 29 | "@angular-devkit/build-angular": "^15.1.5", 30 | "@angular-eslint/builder": "15.2.0", 31 | "@angular-eslint/eslint-plugin": "15.2.0", 32 | "@angular-eslint/eslint-plugin-template": "15.2.0", 33 | "@angular-eslint/schematics": "15.2.0", 34 | "@angular-eslint/template-parser": "15.2.0", 35 | "@angular/cli": "~15.1.5", 36 | "@angular/compiler-cli": "^15.1.4", 37 | "@types/jasmine": "~4.3.1", 38 | "@typescript-eslint/eslint-plugin": "5.51.0", 39 | "@typescript-eslint/parser": "5.51.0", 40 | "eslint": "^8.33.0", 41 | "jasmine-core": "~4.5.0", 42 | "karma": "~6.4.1", 43 | "karma-chrome-launcher": "~3.1.1", 44 | "karma-coverage": "~2.2.0", 45 | "karma-jasmine": "~5.1.0", 46 | "karma-jasmine-html-reporter": "~2.0.0", 47 | "typescript": "~4.9.5" 48 | } 49 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Zebra:基于 [Book Searcher](https://github.com/book-searcher-org/book-searcher) 的 Web 界面 2 | ====== 3 | 4 | 5 | 6 | Zebra Logo 7 | 8 | [plan](https://github.com/zhengkai/zebra/blob/master/PLAN.md) 9 | 10 | 最近没时间搞了,简单描述一下 11 | 12 | 安装 13 | ------ 14 | 15 | 在本地跑所需要的脚本在 `client/Makefile`,在 `client/` 目录下直接敲 `make` 可以启动 angular 开发环境,修改 `misc/nginx/dev.conf` 为自己的(尤其是 `/search` 里对应的 Book Bearcher 端口)并添加到 nginx 里 16 | 17 | 特别是,如果你没有安装自己的 Book Searcher,我的 nginx 配置文件里已经写了 18 | 19 | ``` 20 | add_header Access-Control-Allow-Origin *; 21 | ``` 22 | 23 | 不检查跨站谁都能用,也就是你把 `/client/src/app/search/search.service.ts` 里 `SearchService` 的 `baseURL` 加上我的域名前缀,最终为 24 | 25 | ``` 26 | baseURL = 'https://zebra.9farm.com/search?limit=100&query='; 27 | ``` 28 | 29 | 就可以让前端能跑起来,当然如果你有能力架 Book Searcher 希望也能关闭跨站检查(上面那行 `add_header`) 30 | 31 | 部署可以参考 `Makefile` 里的 `prod` 段内容,在 `client/` 目录直接 `./dist/build.sh 你的域名 prod`,并修改 `misc/nginx/dev.conf` 对应你的部署目录 32 | 33 | 缓存 34 | ------ 35 | 36 | 在我的 nginx 配置 `misc/nginx/prod.conf` 里,有这么两段,开头的 37 | 38 | ``` 39 | proxy_cache_path /www/zlib/nginx-cache levels=2:2 keys_zone=zlib:50m inactive=48h max_size=1g; 40 | ``` 41 | 42 | 以及 `server` 的定义中有 43 | 44 | ``` 45 | location /search { 46 | add_header Access-Control-Allow-Origin *; 47 | proxy_cache zlib; 48 | proxy_cache_key $uri$is_args$args; 49 | proxy_cache_valid 200 304 10m; 50 | proxy_pass http://127.0.0.1:7070; 51 | } 52 | ``` 53 | 54 | 这些可以让搜索结果缓存为静态文件,应该可以降低不少负载,可以参考一下 55 | 56 | TODO: 57 | ------ 58 | 59 | * ~~自定义搜索项/结果字段~~ (2022.12.05) 60 | * ~~自定义下载按钮~~ (2022.12.05) 61 | * 导入导出设置 62 | * ~~搜索历史~~ (2022.12.07) 63 | * 保存单本书 64 | * 保存搜索历史页 65 | * 分享书单 66 | * 离线也能查看已搜过页面 67 | * 多个后台汇总,防止单点 68 | * 对单个下载的评价/打分 69 | -------------------------------------------------------------------------------- /client/src/app/common/type.ts: -------------------------------------------------------------------------------- 1 | export const KeyName = { 2 | name: '书名', 3 | author: '作者', 4 | publisher: '出版社', 5 | lang: '语言', 6 | ext: '扩展名', 7 | isbn: 'ISBN', 8 | zlib_id: 'zLib ID', 9 | id: 'zLib ID', 10 | pages: '页码', 11 | filesize: '文件大小', 12 | }; 13 | 14 | export interface IResultRow { 15 | id: number; 16 | name: string; 17 | author: string; 18 | publisher: string; 19 | ext: string; 20 | filesize: number; 21 | lang: string; 22 | year: number; 23 | pages: number; 24 | isbn: string; 25 | ipfs_cid: string; 26 | idx: number; // 默认结果顺序 27 | } 28 | 29 | export interface ISearchArgs { 30 | name: string; 31 | author: string; 32 | lang: string; 33 | publisher: string; 34 | ext: string; 35 | isbn: string; 36 | id: string; 37 | } 38 | 39 | export type SearchKey = keyof ISearchArgs; 40 | export const SearchKeyList: SearchKey[] = ['name', 'author', 'publisher', 'lang', 'ext', 'isbn', 'id']; 41 | 42 | const keyBackend = { 43 | name: 'title', 44 | ext: 'extension', 45 | lang: 'language', 46 | }; 47 | 48 | export class SearchArgs { 49 | 50 | name: string; 51 | author: string; 52 | lang: string; 53 | publisher: string; 54 | ext: string; 55 | isbn: string; 56 | id: string; 57 | 58 | constructor(a: ISearchArgs) { 59 | this.name = a.name; 60 | this.author = a.author; 61 | this.lang = a.lang; 62 | this.publisher = a.publisher; 63 | this.ext = a.ext; 64 | this.isbn = a.isbn; 65 | this.id = a.id; 66 | } 67 | 68 | includes(a: ISearchArgs) { 69 | for (const k of SearchKeyList) { 70 | if (!this[k].includes(a[k])) { 71 | return false; 72 | } 73 | } 74 | return true; 75 | } 76 | 77 | query(): string { 78 | if (this.id.length) { 79 | return this._buildKey('id'); 80 | } 81 | return SearchKeyList.map(s => this._buildKey(s)).join(''); 82 | } 83 | 84 | _buildKey(key: SearchKey): string { 85 | let s = this[key].replace(/"/g, ''); 86 | if (key === 'id') { 87 | s = s.replace(/[^0-9]/g, ''); 88 | } else if (key === 'ext') { 89 | s = s.replace(/\s+/g, ''); 90 | } 91 | if (!s.length) { 92 | return ''; 93 | } 94 | key = (keyBackend as any)[key] || key; 95 | return `${key}:${s} `; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /client/src/app/search/search.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { SearchService } from './search.service'; 3 | import { NavService } from '../common/nav.service'; 4 | import { SettingService } from '../setting/setting.service'; 5 | import { HistoryService } from '../history/history.service'; 6 | import { IResultRow } from '../common/type'; 7 | 8 | @Component({ 9 | selector: 'app-search', 10 | templateUrl: './search.component.html', 11 | styles: [ 12 | ], 13 | }) 14 | export class SearchComponent { 15 | 16 | langOption = ['chinese', 'english']; 17 | extOption = ['epub', 'mobi', 'azw3', 'pdf', 'txt']; 18 | 19 | keyBackend = { 20 | name: 'title', 21 | ext: 'extension', 22 | lang: 'language', 23 | }; 24 | 25 | lastResult = ''; 26 | 27 | name = ''; 28 | author = ''; 29 | lang = ''; 30 | publisher = ''; 31 | ext = ''; 32 | isbn = ''; 33 | id = ''; 34 | 35 | error = false; 36 | 37 | constructor( 38 | public srv: SearchService, 39 | public nav: NavService, 40 | public setting: SettingService, 41 | public history: HistoryService, 42 | ) { 43 | this.nav.go('search'); 44 | } 45 | 46 | search() { 47 | this.srv.Search(); 48 | } 49 | 50 | changeSort() { 51 | this.srv.changeSort(); 52 | } 53 | 54 | formatBytes(n: number) { 55 | if (n <= 0) { 56 | return ''; 57 | } 58 | if (n < 1024) { 59 | return '1K'; 60 | } 61 | 62 | const units = ['KB', 'MB', 'GB', 'TB']; 63 | let i = 0; 64 | 65 | for (; n >= 1024 && i < 4; i++) { 66 | n /= 1024; 67 | } 68 | 69 | return n.toFixed(1) + ' ' + units[i - 1]; 70 | } 71 | 72 | buildLink(r: IResultRow) { 73 | 74 | const setting = this.setting.current['misc.dlSite']; 75 | const base = setting === 'ipfs://' 76 | ? 'ipfs://' 77 | : setting.replace(/[/]+$/, '') + '/ipfs/'; 78 | 79 | let name = r.name; 80 | if (name !== r.author && this.setting.current['fileName.author']) { 81 | name += '_' + r.author; 82 | } 83 | if (name !== r.publisher && this.setting.current['fileName.publisher']) { 84 | name += '_' + r.publisher; 85 | } 86 | if (this.setting.current['fileName.zlib_id']) { 87 | name += '_' + r.id; 88 | } 89 | name = name.replace(/(\.|,)/g, '').replace(/[\s/]+/g, '_'); 90 | 91 | const url = `${base}${r.ipfs_cid}?filename=${encodeURIComponent(name)}.${r.ext}`; 92 | 93 | return url; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /client/src/app/setting/setting.component.html: -------------------------------------------------------------------------------- 1 |

搜索字段

2 |
3 | 4 | {{ keyName[k.split('.')[1]] }} 5 | 6 |
7 | 8 |

展示字段

9 |
10 | 11 | {{ keyName[k.split('.')[1]] }} 12 | 13 |
14 | 15 |

下载

16 |
17 | 文件名包括出版社 18 |
19 |
20 | 文件名包括作者 21 |
22 |
23 | 文件名包括 zLib ID 24 |
25 |
26 | 27 | 下载站 28 | 29 | 32 | 33 | 34 | {{ url }} 35 | 36 | 37 | 38 |
39 | 40 |

杂项

41 |
42 | 记住最后一次搜索条件 43 |
44 |
45 | 保存历史记录(取消此项并保存会删除历史) 46 |
47 |
48 | 下载按钮在最左边 49 |
50 |
51 | 定宽(最大 1280px) 52 |
53 | 54 |
55 | 56 | 57 |
58 | -------------------------------------------------------------------------------- /client/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "ignorePatterns": [ 4 | "projects/**/*" 5 | ], 6 | "overrides": [ 7 | { 8 | "files": [ 9 | "*.ts" 10 | ], 11 | "extends": [ 12 | "eslint:recommended", 13 | "plugin:@typescript-eslint/recommended", 14 | "plugin:@angular-eslint/recommended", 15 | "plugin:@angular-eslint/template/process-inline-templates" 16 | ], 17 | "rules": { 18 | "@angular-eslint/directive-selector": [ 19 | "error", 20 | { 21 | "type": "attribute", 22 | "prefix": "app", 23 | "style": "camelCase" 24 | } 25 | ], 26 | "@angular-eslint/component-selector": [ 27 | "error", 28 | { 29 | "type": "element", 30 | "prefix": "app", 31 | "style": "kebab-case" 32 | } 33 | ], 34 | "quotes": ["error", "single"], 35 | "semi": "error", 36 | "indent": ["error", "tab", { 37 | "SwitchCase": 1, 38 | "outerIIFEBody": 1, 39 | "MemberExpression": 1 40 | }], 41 | "curly": "error", 42 | "@typescript-eslint/no-explicit-any": "off", 43 | "no-sparse-arrays": "off", 44 | "@typescript-eslint/no-this-alias": "off", 45 | "@typescript-eslint/explicit-function-return-type": "off", 46 | "no-unused-expressions": ["error", { "allowShortCircuit": true }], 47 | "no-unneeded-ternary": "error", 48 | "array-bracket-spacing": ["error", "never"], 49 | "arrow-spacing": "error", 50 | "block-spacing": "error", 51 | "comma-dangle": ["error", "always-multiline"], 52 | "comma-spacing": ["error", { "before": false, "after": true }], 53 | "comma-style": "error", 54 | "func-call-spacing": ["error", "never"], 55 | "brace-style": ["error", "1tbs", { "allowSingleLine": false }], 56 | "key-spacing": "error", 57 | "keyword-spacing": "error", 58 | "no-constant-condition": "off", 59 | "no-empty": ["error", { "allowEmptyCatch": true }], 60 | "no-empty-function": ["error", { "allow": ["constructors"] }], 61 | "no-multi-spaces": "error", 62 | "no-unused-vars": ["warn", { "varsIgnorePattern": "_", "args": "none" }], 63 | "@typescript-eslint/no-unused-vars": [ "warn", { 64 | "argsIgnorePattern": "^_", 65 | "varsIgnorePattern": "^_" 66 | }], 67 | "no-var": "error", 68 | "object-curly-spacing": ["error", "always"], 69 | "prefer-arrow-callback": "error", 70 | "prefer-const": "error", 71 | "quote-props": ["error", "as-needed"], 72 | "space-in-parens": "error", 73 | "space-infix-ops": ["error", { "int32Hint": false }], 74 | "space-before-blocks": "error" 75 | } 76 | }, 77 | { 78 | "files": [ 79 | "*.html" 80 | ], 81 | "extends": [ 82 | "plugin:@angular-eslint/template/recommended" 83 | ], 84 | "rules": {} 85 | } 86 | ] 87 | } 88 | -------------------------------------------------------------------------------- /client/src/style/search.scss: -------------------------------------------------------------------------------- 1 | .search-box { 2 | margin: 0 auto; 3 | flex-wrap: wrap; 4 | > .name, > .author { 5 | width: calc(50% - 0.5rem); 6 | margin: 0 0.25rem; 7 | } 8 | > .publisher, > .ext, > .lang, > .isbn, > .zlib-id { 9 | width: calc(25% - 0.5rem); 10 | margin: 0 0.25rem; 11 | } 12 | } 13 | 14 | .search-box-fixed { 15 | max-width: 1288px; 16 | } 17 | 18 | @media screen and (max-width:650px) { 19 | .search-box { 20 | > .name, > .author { 21 | width: calc(100% - 0.5rem); 22 | } 23 | > .publisher, > .ext, > .lang, > .isbn, > .zlib-id { 24 | width: calc(50% - 0.5rem); 25 | } 26 | } 27 | } 28 | 29 | .search-result { 30 | min-height: 400px; 31 | margin: 0 auto; 32 | > div.fail { 33 | max-width: 640px; 34 | margin: 0 auto; 35 | text-align: center; 36 | button { 37 | margin-left: 0.5rem; 38 | } 39 | } 40 | table { 41 | width: 100%; 42 | border-collapse: collapse; 43 | border-spacing: 0; 44 | font-size: 1rem; 45 | border: 1px solid #eee; 46 | th { 47 | background-color: #eee; 48 | padding: 0.5rem; 49 | white-space: nowrap; 50 | cursor: default; 51 | &.id { 52 | cursor: pointer; 53 | } 54 | &.sort-1::after { 55 | color: gray; 56 | content: "⇧"; 57 | } 58 | &.sort-2::after { 59 | color: gray; 60 | content: "⇩"; 61 | } 62 | } 63 | tr { 64 | &:hover { 65 | background-color: #f3f3f3; 66 | } 67 | td { 68 | padding: 0.375rem; 69 | border: 1px solid silver; 70 | &.id { 71 | width: 5rem; 72 | text-align: right; 73 | white-space: nowrap; 74 | } 75 | &.name { 76 | min-width: 10rem; 77 | span { 78 | line-break: anywhere; 79 | } 80 | } 81 | &.author, &.publisher { 82 | max-width: 10rem; 83 | min-width: 6rem; 84 | text-overflow: ellipsis; 85 | overflow: hidden; 86 | white-space: nowrap; 87 | } 88 | &.filesize { 89 | text-align: right; 90 | min-width: 4.5rem; 91 | padding-right: 0.5rem; 92 | white-space: nowrap; 93 | } 94 | &.ext { 95 | text-align: center; 96 | } 97 | &.isbn { 98 | max-width: 8rem; 99 | } 100 | &.pages { 101 | white-space: nowrap; 102 | text-align: right; 103 | } 104 | &.dl { 105 | min-width: 4rem; 106 | text-align: center; 107 | } 108 | a { 109 | background-color: #3f51b5; 110 | span { 111 | color: #fff; 112 | } 113 | } 114 | } 115 | } 116 | } 117 | } 118 | 119 | .search-result-fixed { 120 | max-width: 1280px; 121 | } 122 | 123 | @media screen and (max-width:650px) { 124 | .search-result { 125 | table { 126 | tr { 127 | td { 128 | &.author { 129 | max-width: 5rem; 130 | width: 5rem; 131 | } 132 | } 133 | } 134 | } 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /client/src/app/setting/setting.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { HistoryService } from '../history/history.service'; 3 | 4 | const isMobile = navigator.userAgent.match(/iPhone|Android/i); 5 | 6 | const d = Object.freeze({ 7 | 'misc.rememberLastSearch': true, 8 | 'misc.dlLeftButton': isMobile, 9 | 'misc.dlSite': 'https://cloudflare-ipfs.com/', 10 | 'misc.fixedWidth': true, 11 | 'misc.history': true, 12 | 'searchCol.author': true, 13 | 'searchCol.lang': !isMobile, 14 | 'searchCol.publisher': !isMobile, 15 | 'searchCol.ext': true, 16 | 'searchCol.isbn': !isMobile, 17 | 'searchCol.zlib_id': false, 18 | 'resultCol.author': true, 19 | 'resultCol.lang': !isMobile, 20 | 'resultCol.publisher': !isMobile, 21 | 'resultCol.ext': true, 22 | 'resultCol.isbn': !isMobile, 23 | 'resultCol.filesize': true, 24 | 'resultCol.pages': !isMobile, 25 | 'resultCol.zlib_id': false, 26 | 'fileName.publisher': false, 27 | 'fileName.author': false, 28 | 'fileName.zlib_id': false, 29 | }); 30 | 31 | type settingKey = keyof typeof d; 32 | 33 | @Injectable({ 34 | providedIn: 'root', 35 | }) 36 | export class SettingService { 37 | 38 | d: any = {}; 39 | current: any = {}; 40 | 41 | keyList: string[] = []; 42 | 43 | expandList: any = {}; 44 | 45 | tmpData: any = {}; 46 | 47 | constructor( 48 | public history: HistoryService, 49 | ) { 50 | try { 51 | const j = localStorage.getItem('setting'); 52 | if (j) { 53 | this.tmpData = JSON.parse(j); 54 | } 55 | } catch (x) { 56 | this.tmpData = {}; 57 | } 58 | 59 | for (const s of Object.keys(d)) { 60 | const k = s as settingKey; 61 | let v = this.tmpData[k]; 62 | if (v === undefined) { 63 | v = d[k]; 64 | } else { 65 | this.d[k] = v; 66 | } 67 | this.current[k] = v; 68 | } 69 | this.keyList = Object.keys(d); 70 | } 71 | 72 | saveButtonDisabled(): boolean { 73 | if (!this.current['misc.dlSite']?.length) { 74 | return true; 75 | } 76 | for (const s of Object.keys(this.current)) { 77 | if (this.d[s as settingKey] !== this.current[s]) { 78 | return false; 79 | } 80 | } 81 | return true; 82 | } 83 | 84 | isNavButtonHide(key: string) { 85 | if (key === 'history') { 86 | return !this.current['misc.history']; 87 | } 88 | return false; 89 | } 90 | 91 | reset() { 92 | for (const s of Object.keys(d)) { 93 | this.current[s] = d[s as settingKey]; 94 | } 95 | } 96 | 97 | save() { 98 | if (!this.current['misc.dlSite']) { 99 | this.current['misc.dlSite'] = d['misc.dlSite']; 100 | } 101 | for (const s of Object.keys(this.current)) { 102 | this.d[s as settingKey] = this.current[s]; 103 | } 104 | if (!this.current['misc.rememberLastSearch']) { 105 | localStorage.removeItem('session'); 106 | } 107 | if (!this.current['misc.history']) { 108 | this.history.clean(); 109 | } 110 | localStorage.setItem('setting', JSON.stringify(this.current)); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /client/src/style/init.scss: -------------------------------------------------------------------------------- 1 | html, body { 2 | height: 100%; 3 | } 4 | 5 | body { 6 | margin: 0; 7 | padding: 0; 8 | font-family: Roboto, "Helvetica Neue", sans-serif; 9 | overflow-y: scroll; 10 | } 11 | 12 | p.clean { 13 | clear: both; 14 | height: 5rem; 15 | } 16 | 17 | .hide { 18 | display: none; 19 | } 20 | 21 | header, footer { 22 | background-color: #3f51b5; 23 | > div { 24 | max-width: 1280px; 25 | margin: 0 auto; 26 | } 27 | } 28 | 29 | header { 30 | height: 4rem; 31 | margin-bottom: 1rem; 32 | color: #fff; 33 | > div { 34 | line-height: 4rem; 35 | display: flex; 36 | justify-content: space-between; 37 | .title { 38 | font-size: 1.75rem; 39 | font-weight: bold; 40 | cursor: default; 41 | img { 42 | width: 3rem; 43 | height: 3rem; 44 | vertical-align: -0.75rem; 45 | margin-right: 0.5rem; 46 | } 47 | } 48 | .loading { 49 | display: inline-flex; 50 | align-items: center; 51 | justify-content: center; 52 | } 53 | nav { 54 | font-size: 1.25rem; 55 | a { 56 | display: inline-flex; 57 | align-items: center; 58 | justify-content: center; 59 | width: 10rem; 60 | text-align: center; 61 | height: 4rem; 62 | color: #eee; 63 | text-decoration: none; 64 | mat-icon { 65 | margin-right: 0.5rem; 66 | } 67 | &.selected { 68 | background-color: rgba(255, 255, 255, 0.125); 69 | } 70 | &:hover { 71 | background-color: rgba(0, 0, 0, 0.125); 72 | color: #fff; 73 | font-weight: bold; 74 | } 75 | &:disabled { 76 | color: #ccc; 77 | background-color: red; 78 | } 79 | } 80 | } 81 | } 82 | } 83 | 84 | @media screen and (max-width: 1000px) { 85 | header { 86 | > div { 87 | nav { 88 | a { 89 | width: 6rem; 90 | font-size: 1.125rem; 91 | } 92 | } 93 | } 94 | } 95 | } 96 | 97 | @media screen and (max-width:650px) { 98 | header { 99 | > div { 100 | height: 2rem; 101 | .title { 102 | img { 103 | margin-right: 0; 104 | } 105 | span { 106 | display: none; 107 | } 108 | } 109 | nav { 110 | font-size: 1rem; 111 | a { 112 | width: 5rem; 113 | } 114 | } 115 | } 116 | } 117 | } 118 | 119 | app-root > div { 120 | min-height: calc(100vh - 14rem); 121 | margin: 0 auto; 122 | padding: 0 2rem; 123 | &.fixed { 124 | max-width: 1280px; 125 | } 126 | } 127 | 128 | @media screen and (max-width:682px) { 129 | app-root > div { 130 | padding: 0; 131 | } 132 | } 133 | 134 | footer { 135 | clear: both; 136 | margin-top: 3rem; 137 | min-height: 6rem; 138 | padding: 1rem; 139 | box-sizing: border-box; 140 | font-size: 1rem; 141 | color: #eee; 142 | line-height: 2; 143 | a { 144 | color: #fff; 145 | font-weight: bold; 146 | text-underline-offset: 2px; 147 | } 148 | } 149 | 150 | .coming { 151 | text-align: center; 152 | padding-top: 8rem; 153 | padding-bottom: 8rem; 154 | font-size: 2rem; 155 | color: gray; 156 | } 157 | 158 | mat-checkbox { 159 | label { 160 | font-size: 1rem; 161 | } 162 | } 163 | 164 | h2 { 165 | padding-top: 1rem; 166 | margin: 0; 167 | font-size: 1.25rem; 168 | } 169 | -------------------------------------------------------------------------------- /client/angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "SearchUI": { 7 | "projectType": "application", 8 | "schematics": { 9 | "@schematics/angular:component": { 10 | "inlineStyle": true, 11 | "style": "scss", 12 | "skipTests": true 13 | }, 14 | "@schematics/angular:class": { 15 | "skipTests": true 16 | }, 17 | "@schematics/angular:directive": { 18 | "skipTests": true 19 | }, 20 | "@schematics/angular:guard": { 21 | "skipTests": true 22 | }, 23 | "@schematics/angular:interceptor": { 24 | "skipTests": true 25 | }, 26 | "@schematics/angular:pipe": { 27 | "skipTests": true 28 | }, 29 | "@schematics/angular:resolver": { 30 | "skipTests": true 31 | }, 32 | "@schematics/angular:service": { 33 | "skipTests": true 34 | } 35 | }, 36 | "root": "", 37 | "sourceRoot": "src", 38 | "prefix": "app", 39 | "architect": { 40 | "build": { 41 | "builder": "@angular-devkit/build-angular:browser", 42 | "options": { 43 | "outputPath": "dist/search-ui", 44 | "index": "src/index.html", 45 | "main": "src/main.ts", 46 | "polyfills": [ 47 | "zone.js" 48 | ], 49 | "tsConfig": "tsconfig.app.json", 50 | "inlineStyleLanguage": "scss", 51 | "assets": [ 52 | "src/favicon.ico", 53 | "src/assets" 54 | ], 55 | "styles": [ 56 | "@angular/material/prebuilt-themes/indigo-pink.css", 57 | "src/style/index.scss" 58 | ], 59 | "scripts": [] 60 | }, 61 | "configurations": { 62 | "production": { 63 | "budgets": [ 64 | { 65 | "type": "initial", 66 | "maximumWarning": "500kb", 67 | "maximumError": "1mb" 68 | }, 69 | { 70 | "type": "anyComponentStyle", 71 | "maximumWarning": "2kb", 72 | "maximumError": "4kb" 73 | } 74 | ], 75 | "outputHashing": "all" 76 | }, 77 | "development": { 78 | "buildOptimizer": false, 79 | "optimization": false, 80 | "vendorChunk": true, 81 | "extractLicenses": false, 82 | "sourceMap": true, 83 | "namedChunks": true 84 | } 85 | }, 86 | "defaultConfiguration": "production" 87 | }, 88 | "serve": { 89 | "builder": "@angular-devkit/build-angular:dev-server", 90 | "configurations": { 91 | "production": { 92 | "browserTarget": "SearchUI:build:production" 93 | }, 94 | "development": { 95 | "browserTarget": "SearchUI:build:development" 96 | } 97 | }, 98 | "defaultConfiguration": "development" 99 | }, 100 | "extract-i18n": { 101 | "builder": "@angular-devkit/build-angular:extract-i18n", 102 | "options": { 103 | "browserTarget": "SearchUI:build" 104 | } 105 | }, 106 | "test": { 107 | "builder": "@angular-devkit/build-angular:karma", 108 | "options": { 109 | "polyfills": [ 110 | "zone.js", 111 | "zone.js/testing" 112 | ], 113 | "tsConfig": "tsconfig.spec.json", 114 | "inlineStyleLanguage": "scss", 115 | "assets": [ 116 | "src/favicon.ico", 117 | "src/assets" 118 | ], 119 | "styles": [ 120 | "@angular/material/prebuilt-themes/indigo-pink.css", 121 | "src/styles.scss" 122 | ], 123 | "scripts": [] 124 | } 125 | }, 126 | "lint": { 127 | "builder": "@angular-eslint/builder:lint", 128 | "options": { 129 | "lintFilePatterns": [ 130 | "src/**/*.ts", 131 | "src/**/*.html" 132 | ] 133 | } 134 | } 135 | } 136 | } 137 | }, 138 | "cli": { 139 | "schematicCollections": [ 140 | "@angular-eslint/schematics" 141 | ], 142 | "analytics": false 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /client/src/app/search/search.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { HttpClient } from '@angular/common/http'; 3 | import { SessionService } from '../common/session.service'; 4 | import { SettingService } from '../setting/setting.service'; 5 | import { NavService } from '../common/nav.service'; 6 | import { HistoryService } from '../history/history.service'; 7 | import { SearchKey, SearchArgs, IResultRow } from '../common/type'; 8 | 9 | export interface Page { 10 | query: string; 11 | result: IResultRow[]; 12 | error: boolean; 13 | done: boolean; 14 | cbList: (() => void)[]; 15 | } 16 | 17 | @Injectable({ 18 | providedIn: 'root', 19 | }) 20 | export class SearchService { 21 | 22 | args: SearchArgs; 23 | 24 | baseURL = '/search?limit=100&query='; 25 | 26 | lastQuery = ''; 27 | lastResult = ''; 28 | 29 | error = false; 30 | 31 | sortType = 1; 32 | 33 | cache: { [key: string]: Page } = {}; 34 | result: IResultRow[] = []; 35 | 36 | constructor( 37 | public session: SessionService, 38 | public setting: SettingService, 39 | public nav: NavService, 40 | public history: HistoryService, 41 | private http: HttpClient, 42 | ) { 43 | 44 | let sortType = +(localStorage.getItem('sortType') || 0); 45 | if (![1, 2].includes(sortType)) { 46 | sortType = 0; 47 | } 48 | this.sortType = sortType; 49 | 50 | this.args = session.search; 51 | this.Search(); 52 | } 53 | 54 | async Search(a?: SearchArgs) { 55 | if (!a) { 56 | a = this.args; 57 | } else { 58 | this.args = new SearchArgs(a); 59 | } 60 | const query = a.query(); 61 | this.error = false; 62 | if (this.setting.current['misc.rememberLastSearch']) { 63 | this.session.save(a); 64 | } 65 | this.nav.loading = true; 66 | 67 | const { ok, error, result } = await this._search(query); 68 | if (ok) { 69 | this.error = error; 70 | this.nav.loading = false; 71 | } 72 | if (!ok || this.lastResult === query) { 73 | return; 74 | } 75 | this.lastResult = query; 76 | this.sort(result); 77 | this.result = result; 78 | if (result?.length) { 79 | this.history.save(a); 80 | return; 81 | } 82 | } 83 | 84 | async _search(query: string) { 85 | this.lastQuery = query; 86 | let p = this.cache[query]; 87 | if (!p) { 88 | p = { 89 | query, 90 | result: [], 91 | error: false, 92 | done: false, 93 | cbList: [], 94 | }; 95 | this.fetch(p); 96 | this.cache[query] = p; 97 | } else if (p.error && p.done) { 98 | p.done = false; 99 | this.fetch(p); 100 | } 101 | // await new Promise((p) => setTimeout(p, 1000)); 102 | if (query === '') { 103 | p.done = true; 104 | } 105 | if (!p.done) { 106 | const a = new Promise((resolve) => { 107 | p.cbList.push(() => { 108 | resolve(null); 109 | }); 110 | }); 111 | await a; 112 | } 113 | return { 114 | ok: this.lastQuery === query, 115 | error: p.error, 116 | result: p.result, 117 | }; 118 | } 119 | 120 | changeSort() { 121 | this.sortType++; 122 | if (this.sortType > 2) { 123 | this.sortType = 0; 124 | } 125 | localStorage.setItem('sortType', '' + this.sortType); 126 | this.sort(this.result); 127 | } 128 | 129 | sort(result: IResultRow[]) { 130 | result.sort((a, b) => { 131 | if (this.sortType === 1) { 132 | return a.id - b.id; 133 | } 134 | if (this.sortType === 2) { 135 | return b.id - a.id; 136 | } 137 | return a.idx - b.idx; 138 | }); 139 | } 140 | 141 | fetch(p: Page) { 142 | const url = this.baseURL + encodeURIComponent(p.query); 143 | 144 | const end = () => { 145 | 146 | p.done = true; 147 | for (const cb of p.cbList) { 148 | cb(); 149 | } 150 | p.cbList.length = 0; 151 | }; 152 | 153 | this.http.get(url, { 154 | responseType: 'json', 155 | }).subscribe({ 156 | next: (data: unknown) => { 157 | this.parseData(p, data); 158 | end(); 159 | }, 160 | error: () => { 161 | p.error = true; 162 | end(); 163 | }, 164 | }); 165 | } 166 | 167 | parseData(p: Page, data: any) { 168 | const list = data?.books; 169 | if (!list) { 170 | p.error = true; 171 | } 172 | let idx = 0; 173 | for (const row of list) { 174 | if (!row?.id) { 175 | continue; 176 | } 177 | idx++; 178 | const r = { 179 | id: +row.id, 180 | name: '' + (row?.title || ''), 181 | author: '' + (row?.author || ''), 182 | publisher: '' + (row?.publisher || ''), 183 | ext: '' + (row?.extension || ''), 184 | filesize: +row?.filesize, 185 | lang: '' + (row?.language || ''), 186 | year: +row?.year, 187 | pages: +row?.pages, 188 | isbn: (row?.isbn || '').replace(/,/g, ', '), 189 | ipfs_cid: '' + (row?.ipfs_cid || ''), 190 | idx, 191 | }; 192 | 193 | if (r.ext.length) { 194 | // 去掉书名里多余的 .txt 之类的 195 | const kl: SearchKey[] = ['name', 'author', 'publisher']; 196 | for (const k of kl) { 197 | const v = r[k] as string; 198 | if (!v.endsWith('.' + r.ext)) { 199 | continue; 200 | } 201 | (r[k] as any) = '' + v.substring(0, v.length - 1 - r.ext.length); 202 | } 203 | } 204 | p.result.push(r); 205 | } 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /client/src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { BrowserModule } from '@angular/platform-browser'; 3 | 4 | import { RoutingModule } from './app-routing.module'; 5 | import { BootstrapComponent } from './common/bootstrap.component'; 6 | // import { NoopAnimationsModule } from '@angular/platform-browser/animations'; 7 | import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; 8 | 9 | import { FormsModule } from '@angular/forms'; 10 | 11 | import { HttpClientModule } from '@angular/common/http'; 12 | 13 | import { MatFormFieldModule } from '@angular/material/form-field'; 14 | import { MatToolbarModule } from '@angular/material/toolbar'; 15 | import { MatInputModule } from '@angular/material/input'; 16 | import { MatAutocompleteModule } from '@angular/material/autocomplete'; 17 | import { MatIconModule } from '@angular/material/icon'; 18 | import { MatButtonModule } from '@angular/material/button'; 19 | 20 | import { SearchComponent } from './search/search.component'; 21 | import { SettingComponent } from './setting/setting.component'; 22 | import { BookmarkComponent } from './bookmark/bookmark.component'; 23 | import { HistoryComponent } from './history/history.component'; 24 | import { AboutComponent } from './about/about.component'; 25 | 26 | // import { A11yModule } from '@angular/cdk/a11y'; 27 | // import { CdkAccordionModule } from '@angular/cdk/accordion'; 28 | // import { ClipboardModule } from '@angular/cdk/clipboard'; 29 | // import { DragDropModule } from '@angular/cdk/drag-drop'; 30 | // import { PortalModule } from '@angular/cdk/portal'; 31 | // import { ScrollingModule } from '@angular/cdk/scrolling'; 32 | // import { CdkStepperModule } from '@angular/cdk/stepper'; 33 | // import { CdkTableModule } from '@angular/cdk/table'; 34 | // import { CdkTreeModule } from '@angular/cdk/tree'; 35 | // import { MatBadgeModule } from '@angular/material/badge'; 36 | // import { MatBottomSheetModule } from '@angular/material/bottom-sheet'; 37 | // import { MatButtonToggleModule } from '@angular/material/button-toggle'; 38 | // import { MatCardModule } from '@angular/material/card'; 39 | import { MatCheckboxModule } from '@angular/material/checkbox'; 40 | // import { MatChipsModule } from '@angular/material/chips'; 41 | // import { MatStepperModule } from '@angular/material/stepper'; 42 | // import { MatDatepickerModule } from '@angular/material/datepicker'; 43 | // import { MatDialogModule } from '@angular/material/dialog'; 44 | // import { MatDividerModule } from '@angular/material/divider'; 45 | // import { MatExpansionModule } from '@angular/material/expansion'; 46 | // import { MatGridListModule } from '@angular/material/grid-list'; 47 | // import { MatListModule } from '@angular/material/list'; 48 | // import { MatMenuModule } from '@angular/material/menu'; 49 | // import { MatNativeDateModule, MatRippleModule } from '@angular/material/core'; 50 | // import { MatPaginatorModule } from '@angular/material/paginator'; 51 | // import { MatProgressBarModule } from '@angular/material/progress-bar'; 52 | import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; 53 | // import { MatRadioModule } from '@angular/material/radio'; 54 | // import { MatSelectModule } from '@angular/material/select'; 55 | // import { MatSidenavModule } from '@angular/material/sidenav'; 56 | // import { MatSliderModule } from '@angular/material/slider'; 57 | // import { MatSlideToggleModule } from '@angular/material/slide-toggle'; 58 | // import { MatSnackBarModule } from '@angular/material/snack-bar'; 59 | // import { MatSortModule } from '@angular/material/sort'; 60 | import { MatTableModule } from '@angular/material/table'; 61 | // import { MatTabsModule } from '@angular/material/tabs'; 62 | // import { MatTooltipModule } from '@angular/material/tooltip'; 63 | // import { MatTreeModule } from '@angular/material/tree'; 64 | // import { OverlayModule } from '@angular/cdk/overlay'; 65 | // import { CdkMenuModule } from '@angular/cdk/menu'; 66 | // import { DialogModule } from '@angular/cdk/dialog'; 67 | 68 | @NgModule({ 69 | declarations: [ 70 | BootstrapComponent, 71 | SearchComponent, 72 | SettingComponent, 73 | BookmarkComponent, 74 | HistoryComponent, 75 | AboutComponent, 76 | ], 77 | imports: [ 78 | BrowserModule, 79 | RoutingModule, 80 | // NoopAnimationsModule, 81 | BrowserAnimationsModule, 82 | FormsModule, 83 | MatFormFieldModule, 84 | HttpClientModule, 85 | 86 | MatToolbarModule, 87 | MatInputModule, 88 | MatIconModule, 89 | MatAutocompleteModule, 90 | MatButtonModule, 91 | MatCheckboxModule, 92 | MatProgressSpinnerModule, 93 | ], 94 | providers: [], 95 | bootstrap: [ 96 | BootstrapComponent, 97 | ], 98 | }) 99 | export class AppModule { 100 | ignore = [ 101 | // A11yModule, 102 | // CdkAccordionModule, 103 | // CdkMenuModule, 104 | // CdkStepperModule, 105 | // CdkTableModule, 106 | // CdkTreeModule, 107 | // ClipboardModule, 108 | // DialogModule, 109 | // DragDropModule, 110 | // MatBadgeModule, 111 | // MatBottomSheetModule, 112 | // MatButtonToggleModule, 113 | // MatCardModule, 114 | // MatChipsModule, 115 | // MatDatepickerModule, 116 | // MatDialogModule, 117 | // MatDividerModule, 118 | // MatExpansionModule, 119 | // MatGridListModule, 120 | // MatListModule, 121 | // MatMenuModule, 122 | // MatNativeDateModule, 123 | // MatPaginatorModule, 124 | // MatProgressBarModule, 125 | // MatRadioModule, 126 | // MatRippleModule, 127 | // MatSelectModule, 128 | // MatSidenavModule, 129 | // MatSlideToggleModule, 130 | // MatSliderModule, 131 | // MatSnackBarModule, 132 | // MatSortModule, 133 | // MatStepperModule, 134 | MatTableModule, 135 | // MatTabsModule, 136 | // MatTooltipModule, 137 | // MatTreeModule, 138 | // OverlayModule, 139 | // PortalModule, 140 | // ScrollingModule, 141 | ]; 142 | } 143 | -------------------------------------------------------------------------------- /client/src/app/search/search.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 书名 4 | 5 | 8 | 9 | 10 | 11 | 作者 12 | 13 | 16 | 17 | 18 | 19 | 出版社 20 | 21 | 24 | 25 | 26 | 27 | 扩展名 28 | 29 | 32 | 33 | 34 | {{option}} 35 | 36 | 37 | 38 | 39 | 40 | 语言 41 | 42 | 45 | 46 | 47 | {{option}} 48 | 49 | 50 | 51 | 52 | 53 | ISBN 54 | 55 | 58 | 59 | 60 | 61 | zLib ID 62 | 63 | 66 | 67 |
68 | 69 |
70 | 71 |
72 |

最近一次搜索失败,可以尝试改变搜索条件或 73 | 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 |
 zLib ID书名作者出版社扩展名语言ISBN大小页码 
下载{{ a.id }}{{ a.name }}{{ a.author }}{{ a.publisher }}{{ a.ext }}{{ a.lang }}{{ a.isbn }}{{ formatBytes(a.filesize) }}{{ a.pages }}下载
108 |
109 | --------------------------------------------------------------------------------