├── 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 |
7 |
搜索历史
8 |
9 |
{{ text(a) }}
10 |
11 |
12 |
13 |
14 |
15 |
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 |
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 |
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 |
19 |
22 |
25 |
39 |
40 | 杂项
41 |
44 |
45 | 保存历史记录(取消此项并保存会删除历史)
46 |
47 |
50 |
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 | zLib ID |
84 | 书名 |
85 | 作者 |
86 | 出版社 |
87 | 扩展名 |
88 | 语言 |
89 | ISBN |
90 | 大小 |
91 | 页码 |
92 | |
93 |
94 |
95 | | 下载 |
96 | {{ a.id }} |
97 | {{ a.name }} |
98 | {{ a.author }} |
99 | {{ a.publisher }} |
100 | {{ a.ext }} |
101 | {{ a.lang }} |
102 | {{ a.isbn }} |
103 | {{ formatBytes(a.filesize) }} |
104 | {{ a.pages }} |
105 | 下载 |
106 |
107 |
108 |
109 |
--------------------------------------------------------------------------------